Desktop app (#308)
* Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
parent
c0d0e57e79
commit
b20ade9946
247 changed files with 43321 additions and 7087 deletions
|
|
@ -0,0 +1,5 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
internal actual val SharedReaderDiagnosticsEnabled: Boolean = false
|
||||
|
||||
internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean = false
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
@Composable
|
||||
internal actual fun SharedReaderModalLayer(
|
||||
onDismiss: () -> Unit,
|
||||
level: SharedReaderModalLevel,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.ReaderSearchOptions
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ sealed interface LibraryAction {
|
|||
data class SortChanged(val sortOrder: SortOrder) : LibraryAction
|
||||
data class FiltersChanged(val filters: LibraryFilters) : LibraryAction
|
||||
data class BookSelectionToggled(val bookId: String) : LibraryAction
|
||||
data class BookSelectionReplaced(val bookIds: Set<String>) : LibraryAction
|
||||
data object SelectionCleared : LibraryAction
|
||||
data class ShelfSelectionToggled(val shelfId: String) : LibraryAction
|
||||
data object ShelfSelectionCleared : LibraryAction
|
||||
|
|
@ -24,8 +26,18 @@ sealed interface ReaderAction {
|
|||
data class GoToProgress(val progress: Float) : ReaderAction
|
||||
data class GoToChapter(val chapterIndex: Int) : ReaderAction
|
||||
data class GoToLocator(val locator: ReaderLocator) : ReaderAction
|
||||
data class JumpToPage(val pageIndex: Int) : ReaderAction
|
||||
data class JumpToPageNumber(val pageNumber: Int) : ReaderAction
|
||||
data class JumpToChapter(val chapterIndex: Int) : ReaderAction
|
||||
data class JumpToLocator(val locator: ReaderLocator) : ReaderAction
|
||||
data class VisiblePageChanged(val pageIndex: Int, val locator: ReaderLocator? = null) : ReaderAction
|
||||
data class GoToSearchResult(val resultIndex: Int) : ReaderAction
|
||||
data class JumpToSearchResult(val resultIndex: Int) : ReaderAction
|
||||
data object JumpToNextSearchResult : ReaderAction
|
||||
data object JumpToPreviousSearchResult : ReaderAction
|
||||
data object JumpBack : ReaderAction
|
||||
data object JumpForward : ReaderAction
|
||||
data object JumpHistoryCleared : ReaderAction
|
||||
data class SearchChanged(val query: String) : ReaderAction
|
||||
data object SearchOpened : ReaderAction
|
||||
data object SearchClosed : ReaderAction
|
||||
|
|
@ -71,11 +83,14 @@ sealed interface AppAction {
|
|||
data object AllTabsClosed : AppAction
|
||||
data class HomePinToggled(val bookId: String) : AppAction
|
||||
data class LibraryPinToggled(val bookId: String) : AppAction
|
||||
data class ReaderDefaultSettingsChanged(val settings: ReaderSettings) : AppAction
|
||||
data class PdfReaderDefaultSettingsChanged(val settings: ReaderSettings) : AppAction
|
||||
data class ReaderToolbarPreferencesChanged(val preferences: ReaderToolbarPreferences) : AppAction
|
||||
data class ReaderToolVisibilityChanged(val tool: ReaderTool, val hidden: Boolean) : AppAction
|
||||
data class ReaderToolPlacementChanged(val tool: ReaderTool, val bottom: Boolean) : AppAction
|
||||
data class ReaderToolOrderChanged(val toolOrder: List<ReaderTool>) : AppAction
|
||||
data class ReaderHighlightPaletteChanged(val palette: ReaderHighlightPalette) : AppAction
|
||||
data class PdfHighlighterPaletteChanged(val palette: SharedPdfHighlighterPalette) : AppAction
|
||||
data class ReaderTtsReplacementPreferencesChanged(
|
||||
val preferences: ReaderTtsReplacementPreferences,
|
||||
) : AppAction
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
data class BannerMessage(
|
||||
val message: String,
|
||||
|
|
@ -104,7 +106,7 @@ data class SharedReaderScreenState(
|
|||
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
||||
val libraryFilters: LibraryFilters = LibraryFilters(),
|
||||
val recentFilesLimit: Int = 0,
|
||||
val isTabsEnabled: Boolean = false,
|
||||
val isTabsEnabled: Boolean = true,
|
||||
val openTabIds: List<String> = emptyList(),
|
||||
val openTabs: List<BookItem> = emptyList(),
|
||||
val activeTabBookId: String? = null,
|
||||
|
|
@ -117,9 +119,12 @@ data class SharedReaderScreenState(
|
|||
val appTextDimFactorDark: Float = 1.0f,
|
||||
val appSeedColor: Color? = null,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
val allTags: List<Tag> = emptyList(),
|
||||
val showTagSelectionDialogFor: Set<String> = emptySet(),
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(),
|
||||
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,43 @@ data class FileTypeCapability(
|
|||
}
|
||||
|
||||
object SharedFileCapabilities {
|
||||
private val codeOrDataExtensions = setOf(
|
||||
"csv",
|
||||
"tsv",
|
||||
"json",
|
||||
"xml",
|
||||
"log",
|
||||
"java",
|
||||
"kt",
|
||||
"py",
|
||||
"js",
|
||||
"cpp",
|
||||
"c",
|
||||
"cs",
|
||||
"rb",
|
||||
"go"
|
||||
)
|
||||
|
||||
private val manualOnlyReaderMimeTypes = setOf(
|
||||
"text/csv",
|
||||
"text/comma-separated-values",
|
||||
"text/tab-separated-values",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"text/xml",
|
||||
"text/x-java-source",
|
||||
"text/x-python",
|
||||
"text/x-kotlin",
|
||||
"text/javascript",
|
||||
"application/javascript",
|
||||
"text/x-c",
|
||||
"text/x-c++",
|
||||
"text/x-csharp",
|
||||
"text/x-ruby",
|
||||
"text/x-go",
|
||||
"text/x-log"
|
||||
)
|
||||
|
||||
val all: List<FileTypeCapability> = listOf(
|
||||
FileTypeCapability(
|
||||
type = FileType.EPUB,
|
||||
|
|
@ -122,6 +159,13 @@ object SharedFileCapabilities {
|
|||
extensions = setOf("fodt"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.PPTX,
|
||||
displayName = "PPTX",
|
||||
extensions = setOf("pptx"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = null
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -129,6 +173,23 @@ object SharedFileCapabilities {
|
|||
private val typesByExtension: Map<String, FileType> = all
|
||||
.flatMap { capability -> capability.extensions.map { it.lowercase() to capability.type } }
|
||||
.toMap()
|
||||
private val mimeTypesByType: Map<FileType, String> = mapOf(
|
||||
FileType.PDF to "application/pdf",
|
||||
FileType.EPUB to "application/epub+zip",
|
||||
FileType.MOBI to "application/x-mobipocket-ebook",
|
||||
FileType.MD to "text/markdown",
|
||||
FileType.TXT to "text/plain",
|
||||
FileType.HTML to "text/html",
|
||||
FileType.FB2 to "application/x-fictionbook+xml",
|
||||
FileType.CBZ to "application/zip",
|
||||
FileType.CBR to "application/zip",
|
||||
FileType.CB7 to "application/zip",
|
||||
FileType.DOCX to "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
FileType.PPTX to "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
FileType.ODT to "application/vnd.oasis.opendocument.text",
|
||||
FileType.FODT to "application/x-vnd.oasis.opendocument.text-flat-xml"
|
||||
)
|
||||
val knownFileTypes: Set<FileType> = all.mapTo(mutableSetOf()) { it.type }
|
||||
|
||||
fun capabilityFor(type: FileType): FileTypeCapability? {
|
||||
return capabilitiesByType[type]
|
||||
|
|
@ -138,12 +199,63 @@ object SharedFileCapabilities {
|
|||
return capabilityFor(type)?.displayName ?: type.name
|
||||
}
|
||||
|
||||
fun primaryExtensionFor(type: FileType): String? {
|
||||
return capabilityFor(type)?.extensions?.firstOrNull()
|
||||
}
|
||||
|
||||
fun mimeTypeFor(type: FileType): String? {
|
||||
return mimeTypesByType[type]
|
||||
}
|
||||
|
||||
fun fileTypeForName(fileName: String): FileType {
|
||||
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.lowercase()
|
||||
return typesByExtension[extension] ?: FileType.UNKNOWN
|
||||
return resolveFileTypeForName(fileName) ?: FileType.UNKNOWN
|
||||
}
|
||||
|
||||
fun resolveFileTypeForName(fileName: String?): FileType? {
|
||||
val normalized = fileName?.normalizedFileName()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val effectiveName = normalized.withTransparentTextSuffix()
|
||||
return fileTypeForEffectiveName(effectiveName)
|
||||
}
|
||||
|
||||
fun isCodeOrDataFileName(fileName: String): Boolean {
|
||||
return fileName.normalizedFileName()
|
||||
.withTransparentTextSuffix()
|
||||
.extensionAfterLastDot() in codeOrDataExtensions
|
||||
}
|
||||
|
||||
fun isManualOnlyReaderFileName(fileName: String?): Boolean {
|
||||
return fileName?.let(::isCodeOrDataFileName) ?: false
|
||||
}
|
||||
|
||||
fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
|
||||
val normalized = mimeType?.lowercase() ?: return false
|
||||
return normalized in manualOnlyReaderMimeTypes
|
||||
}
|
||||
|
||||
fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean {
|
||||
if (isManualOnlyReaderFileName(name)) return false
|
||||
if (resolveFileTypeForName(name) != null) return true
|
||||
return !isManualOnlyReaderMimeType(mimeType)
|
||||
}
|
||||
|
||||
fun fileExtensionSuffixForName(fileName: String?): String? {
|
||||
val normalized = fileName?.normalizedFileName()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val effectiveName = normalized.withTransparentTextSuffix()
|
||||
val effectiveSuffix = when {
|
||||
effectiveName.endsWith(".fb2.zip") -> ".fb2.zip"
|
||||
effectiveName.endsWith(".markdown") -> ".markdown"
|
||||
effectiveName.endsWith(".xhtml") -> ".xhtml"
|
||||
effectiveName.extensionAfterLastDot() != null && resolveFileTypeForName(effectiveName) != null -> {
|
||||
".${effectiveName.extensionAfterLastDot()}"
|
||||
}
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
return if (effectiveName != normalized && normalized.endsWith(".txt")) {
|
||||
"$effectiveSuffix.txt"
|
||||
} else {
|
||||
effectiveSuffix
|
||||
}
|
||||
}
|
||||
|
||||
fun surfaceFor(type: FileType, platform: ReaderPlatform): ReaderFeatureSurface? {
|
||||
|
|
@ -177,4 +289,30 @@ object SharedFileCapabilities {
|
|||
.filter { it.isReadableOnAndroid && !it.isReadableOnDesktop }
|
||||
.map { it.type }
|
||||
}
|
||||
|
||||
private fun fileTypeForEffectiveName(fileName: String): FileType? {
|
||||
if (fileName.endsWith(".fb2.zip")) return FileType.FB2
|
||||
val extension = fileName.extensionAfterLastDot() ?: return null
|
||||
if (extension in codeOrDataExtensions) return FileType.HTML
|
||||
return typesByExtension[extension]
|
||||
}
|
||||
|
||||
private fun String.normalizedFileName(): String {
|
||||
return trim()
|
||||
.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
private fun String.withTransparentTextSuffix(): String {
|
||||
if (!endsWith(".txt")) return this
|
||||
val innerName = removeSuffix(".txt")
|
||||
if (innerName.isBlank() || !innerName.contains('.')) return this
|
||||
return if (fileTypeForEffectiveName(innerName) != null) innerName else this
|
||||
}
|
||||
|
||||
private fun String.extensionAfterLastDot(): String? {
|
||||
val dotIndex = lastIndexOf('.')
|
||||
return if (dotIndex > 0 && dotIndex < lastIndex) substring(dotIndex + 1) else null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
enum class SharedImportDecisionStatus {
|
||||
IMPORTABLE,
|
||||
DUPLICATE,
|
||||
UNSUPPORTED
|
||||
}
|
||||
|
||||
data class SharedImportDecision(
|
||||
val file: ImportedBookFile,
|
||||
val id: String,
|
||||
val type: FileType,
|
||||
val status: SharedImportDecisionStatus
|
||||
)
|
||||
|
||||
data class SharedImportPlan(
|
||||
val decisions: List<SharedImportDecision>,
|
||||
val importedBooks: List<BookItem>
|
||||
) {
|
||||
val supportedFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filterNot { it.status == SharedImportDecisionStatus.UNSUPPORTED }
|
||||
.map { it.file }
|
||||
|
||||
val importableFiles: List<ImportedBookFile>
|
||||
get() = supportedFiles
|
||||
|
||||
val importedFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filter { it.status == SharedImportDecisionStatus.IMPORTABLE }
|
||||
.map { it.file }
|
||||
|
||||
val duplicateFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filter { it.status == SharedImportDecisionStatus.DUPLICATE }
|
||||
.map { it.file }
|
||||
|
||||
val unsupportedFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filter { it.status == SharedImportDecisionStatus.UNSUPPORTED }
|
||||
.map { it.file }
|
||||
|
||||
val importedCount: Int get() = importedBooks.size
|
||||
val duplicateCount: Int get() = duplicateFiles.size
|
||||
val unsupportedCount: Int get() = unsupportedFiles.size
|
||||
}
|
||||
|
||||
data class SharedImportOutcomeCounts(
|
||||
val addedCount: Int = 0,
|
||||
val duplicateCount: Int = 0,
|
||||
val unsupportedCount: Int = 0,
|
||||
val failedCount: Int = 0
|
||||
)
|
||||
|
||||
data class SharedImportFeedback(
|
||||
val message: String,
|
||||
val isError: Boolean
|
||||
)
|
||||
|
||||
object SharedImportPlanner {
|
||||
fun plan(
|
||||
files: List<ImportedBookFile>,
|
||||
existingBookIds: Set<String>,
|
||||
platform: ReaderPlatform,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedImportPlan {
|
||||
val seenIds = existingBookIds.toMutableSet()
|
||||
val decisions = files.map { file ->
|
||||
val id = stableImportId(file)
|
||||
val type = SharedFileCapabilities.fileTypeForName(file.name)
|
||||
val status = when {
|
||||
!SharedFileCapabilities.canOpen(type, platform) -> SharedImportDecisionStatus.UNSUPPORTED
|
||||
!seenIds.add(id) -> SharedImportDecisionStatus.DUPLICATE
|
||||
else -> SharedImportDecisionStatus.IMPORTABLE
|
||||
}
|
||||
SharedImportDecision(
|
||||
file = file,
|
||||
id = id,
|
||||
type = type,
|
||||
status = status
|
||||
)
|
||||
}
|
||||
val importedBooks = decisions.mapIndexedNotNull { index, decision ->
|
||||
if (decision.status != SharedImportDecisionStatus.IMPORTABLE) return@mapIndexedNotNull null
|
||||
val file = decision.file
|
||||
BookItem(
|
||||
id = decision.id,
|
||||
path = file.localPath ?: file.uriString,
|
||||
type = decision.type,
|
||||
displayName = file.name,
|
||||
timestamp = nowMillis + index,
|
||||
title = file.name.substringBeforeLast('.'),
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.sourceFolder,
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
return SharedImportPlan(decisions, importedBooks)
|
||||
}
|
||||
|
||||
fun feedbackForCounts(
|
||||
counts: SharedImportOutcomeCounts,
|
||||
importedMessage: String,
|
||||
duplicateMessage: String,
|
||||
unsupportedMessage: String,
|
||||
failedMessage: String
|
||||
): SharedImportFeedback {
|
||||
val message = when {
|
||||
counts.addedCount > 0 -> importedMessage
|
||||
counts.duplicateCount > 0 -> duplicateMessage
|
||||
counts.unsupportedCount > 0 -> unsupportedMessage
|
||||
else -> failedMessage
|
||||
}
|
||||
return SharedImportFeedback(
|
||||
message = message,
|
||||
isError = counts.addedCount == 0 && counts.duplicateCount == 0
|
||||
)
|
||||
}
|
||||
|
||||
fun stableImportId(file: ImportedBookFile): String {
|
||||
return file.id ?: file.localPath ?: file.uriString ?: file.name
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderViewport
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
enum class FileType {
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, UNKNOWN
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, PPTX, UNKNOWN
|
||||
}
|
||||
|
||||
val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||
val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX)
|
||||
|
||||
val EPUB_READER_FILE_TYPES = setOf(
|
||||
FileType.EPUB,
|
||||
|
|
@ -68,7 +69,7 @@ data class SyncedFolder(
|
|||
val uriString: String,
|
||||
val name: String,
|
||||
val lastScanTime: Long,
|
||||
val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
|
||||
val allowedFileTypes: Set<FileType> = SharedFileCapabilities.knownFileTypes
|
||||
)
|
||||
|
||||
data class BookItem(
|
||||
|
|
@ -80,18 +81,27 @@ data class BookItem(
|
|||
val coverImagePath: String? = null,
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val description: String? = null,
|
||||
val originalTitle: String? = null,
|
||||
val originalAuthor: String? = null,
|
||||
val originalSeriesName: String? = null,
|
||||
val originalSeriesIndex: Double? = null,
|
||||
val originalDescription: String? = null,
|
||||
val progressPercentage: Float? = null,
|
||||
val isRecent: Boolean = true,
|
||||
val fileSize: Long = 0L,
|
||||
val fileContentModifiedTimestamp: Long = 0L,
|
||||
val sourceFolder: String? = null,
|
||||
val folderTextMetadataParsed: Boolean = false,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val lastPageIndex: Int? = null,
|
||||
val readerPosition: ReaderLocator? = null,
|
||||
val readerSettings: ReaderSettings? = null,
|
||||
val readerBookmarks: List<ReaderBookmark> = emptyList(),
|
||||
val readerHighlights: List<UserHighlight> = emptyList()
|
||||
val readerHighlights: List<UserHighlight> = emptyList(),
|
||||
val pdfReaderViewport: SharedPdfReaderViewport? = null
|
||||
)
|
||||
|
||||
data class Shelf(
|
||||
|
|
|
|||
|
|
@ -28,29 +28,18 @@ class LibraryProjector {
|
|||
|
||||
fun withImportedFiles(state: LibraryState, files: List<ImportedFile>): LibraryState {
|
||||
if (files.isEmpty()) return state
|
||||
val now = currentTimestamp()
|
||||
val existingIds = state.books.mapTo(mutableSetOf()) { it.id }
|
||||
val imported = files.mapIndexedNotNull { index, file ->
|
||||
val id = file.path ?: file.name
|
||||
if (!existingIds.add(id)) {
|
||||
null
|
||||
} else {
|
||||
BookItem(
|
||||
id = id,
|
||||
path = file.path,
|
||||
type = file.name.toFileType(),
|
||||
displayName = file.name,
|
||||
timestamp = now + index,
|
||||
title = file.name.substringBeforeLast('.'),
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.sourceFolder ?: file.path?.parentPath(),
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = files.map { it.toImportedBookFile() },
|
||||
existingBookIds = state.books.mapTo(mutableSetOf()) { it.id },
|
||||
platform = ReaderPlatform.DESKTOP
|
||||
)
|
||||
return state.copy(
|
||||
books = imported + state.books,
|
||||
message = if (imported.isEmpty()) "Those files are already in the desktop library." else "Imported ${imported.size} file(s). Reader support comes later."
|
||||
books = plan.importedBooks + state.books,
|
||||
message = when {
|
||||
plan.importedCount > 0 -> "Imported ${plan.importedCount} file(s). Reader support comes later."
|
||||
plan.unsupportedCount > 0 -> "No supported files were imported."
|
||||
else -> "Those files are already in the desktop library."
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -136,12 +125,6 @@ class LibraryProjector {
|
|||
}
|
||||
}
|
||||
|
||||
private fun String.parentPath(): String? {
|
||||
val normalized = replace('\\', '/')
|
||||
val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
return parent.ifBlank { null }
|
||||
}
|
||||
|
||||
private fun String.folderDisplayName(): String {
|
||||
return replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { "Local Folder" }
|
||||
}
|
||||
|
|
@ -153,6 +136,16 @@ data class ImportedFile(
|
|||
val sourceFolder: String? = null
|
||||
)
|
||||
|
||||
private fun ImportedFile.toImportedBookFile(): ImportedBookFile {
|
||||
return ImportedBookFile(
|
||||
name = name,
|
||||
uriString = null,
|
||||
localPath = path,
|
||||
size = size,
|
||||
sourceFolder = sourceFolder
|
||||
)
|
||||
}
|
||||
|
||||
expect fun currentTimestamp(): Long
|
||||
|
||||
fun String.toFileType(): FileType {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class SharedLibraryStateProjector(
|
|||
fun project(input: SharedLibraryProjectionInput): SharedReaderScreenState {
|
||||
val current = input.state
|
||||
val allLibraryBooks = input.booksFromStore
|
||||
val syncedFolders = current.syncedFolders.withSourceFolderFallbacks(allLibraryBooks)
|
||||
val queried = filterBySearch(allLibraryBooks, current.searchQuery)
|
||||
val filtered = applyLibraryFilters(queried, current.libraryFilters)
|
||||
val sortedLibraryBooks = sortBooks(filtered, current.sortOrder)
|
||||
|
|
@ -54,7 +55,7 @@ class SharedLibraryStateProjector(
|
|||
shelfRefs = input.shelfRefs,
|
||||
tags = input.tags,
|
||||
sortOrder = current.sortOrder,
|
||||
syncedFolders = current.syncedFolders
|
||||
syncedFolders = syncedFolders
|
||||
)
|
||||
val validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id }
|
||||
val viewingShelfId = current.viewingShelfId?.takeIf { it in validShelfIds }
|
||||
|
|
@ -89,7 +90,8 @@ class SharedLibraryStateProjector(
|
|||
openTabIds = openTabIds,
|
||||
activeTabBookId = activeTabBookId,
|
||||
booksAvailableForAdding = booksAvailableForAdding,
|
||||
allTags = input.tags
|
||||
allTags = input.tags,
|
||||
syncedFolders = syncedFolders
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +254,21 @@ class SharedLibraryStateProjector(
|
|||
)
|
||||
}
|
||||
|
||||
private fun List<SyncedFolder>.withSourceFolderFallbacks(books: List<BookItem>): List<SyncedFolder> {
|
||||
val knownFolders = mapTo(linkedSetOf()) { it.uriString }
|
||||
val missingFolders = books
|
||||
.mapNotNull { it.sourceFolder?.takeIf(String::isNotBlank) }
|
||||
.filterTo(linkedSetOf()) { knownFolders.add(it) }
|
||||
.map { sourceFolder ->
|
||||
SyncedFolder(
|
||||
uriString = sourceFolder,
|
||||
name = sourceFolder.folderDisplayName(),
|
||||
lastScanTime = 0L
|
||||
)
|
||||
}
|
||||
return if (missingFolders.isEmpty()) this else this + missingFolders
|
||||
}
|
||||
|
||||
private fun String.folderDisplayName(): String {
|
||||
return replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { "Local Folder" }
|
||||
}
|
||||
|
|
@ -303,43 +320,24 @@ fun SharedReaderScreenState.withImportedFiles(
|
|||
now: Long = currentTimestamp()
|
||||
): SharedReaderScreenState {
|
||||
if (files.isEmpty()) return this
|
||||
val existingIds = rawLibraryBooks.mapTo(mutableSetOf()) { it.id }
|
||||
val imported = files.mapIndexedNotNull { index, file ->
|
||||
val id = file.localPath ?: file.uriString ?: file.name
|
||||
if (!existingIds.add(id)) {
|
||||
null
|
||||
} else {
|
||||
BookItem(
|
||||
id = id,
|
||||
path = file.localPath ?: file.uriString,
|
||||
type = file.name.toFileType(),
|
||||
displayName = file.name,
|
||||
timestamp = now + index,
|
||||
title = file.name.substringBeforeLast('.'),
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.sourceFolder ?: file.localPath?.parentPath(),
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = files,
|
||||
existingBookIds = rawLibraryBooks.mapTo(mutableSetOf()) { it.id },
|
||||
platform = ReaderPlatform.DESKTOP,
|
||||
nowMillis = now
|
||||
)
|
||||
return copy(
|
||||
rawLibraryBooks = imported + rawLibraryBooks,
|
||||
rawLibraryBooks = plan.importedBooks + rawLibraryBooks,
|
||||
bannerMessage = BannerMessage(
|
||||
if (imported.isEmpty()) {
|
||||
"Those files are already in the library."
|
||||
} else {
|
||||
"Imported ${imported.size} file(s)."
|
||||
when {
|
||||
plan.importedCount > 0 -> "Imported ${plan.importedCount} file(s)."
|
||||
plan.unsupportedCount > 0 -> "No supported files were imported."
|
||||
else -> "Those files are already in the library."
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.parentPath(): String? {
|
||||
val normalized = replace('\\', '/')
|
||||
val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
return parent.ifBlank { null }
|
||||
}
|
||||
|
||||
private fun List<BookItem>.withPinnedFirst(pinnedBookIds: Set<String>): List<BookItem> {
|
||||
if (pinnedBookIds.isEmpty()) return this
|
||||
return withIndex()
|
||||
|
|
|
|||
|
|
@ -17,13 +17,34 @@ import kotlinx.serialization.json.longOrNull
|
|||
|
||||
const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData"
|
||||
const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations"
|
||||
const val LOCAL_FOLDER_SIDECAR_HASH_PREFIX = "book_"
|
||||
|
||||
internal expect fun localFolderSyncSha256ShortHex(value: String): String
|
||||
|
||||
fun localFolderSyncSidecarStem(bookId: String): String {
|
||||
return LOCAL_FOLDER_SIDECAR_HASH_PREFIX + localFolderSyncSha256ShortHex(bookId)
|
||||
}
|
||||
|
||||
fun localFolderSyncMetadataFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}.json"
|
||||
}
|
||||
|
||||
fun localFolderSyncMetadataTempFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}.tmp"
|
||||
}
|
||||
|
||||
fun localFolderSyncAnnotationFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}$LOCAL_FOLDER_ANNOTATION_SUFFIX.json"
|
||||
}
|
||||
|
||||
fun localFolderSyncAnnotationTempFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}$LOCAL_FOLDER_ANNOTATION_SUFFIX.tmp"
|
||||
}
|
||||
|
||||
data class SharedFolderBookMetadata(
|
||||
val bookId: String,
|
||||
val title: String?,
|
||||
val author: String?,
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val displayName: String,
|
||||
val type: String,
|
||||
val lastChapterIndex: Int?,
|
||||
|
|
@ -36,7 +57,15 @@ data class SharedFolderBookMetadata(
|
|||
val locatorBlockIndex: Int?,
|
||||
val locatorCharOffset: Int?,
|
||||
val customName: String?,
|
||||
val highlightsJson: String?
|
||||
val highlightsJson: String?,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val description: String? = null,
|
||||
val originalTitle: String? = null,
|
||||
val originalAuthor: String? = null,
|
||||
val originalSeriesName: String? = null,
|
||||
val originalSeriesIndex: Double? = null,
|
||||
val originalDescription: String? = null
|
||||
) {
|
||||
fun toJsonString(): String {
|
||||
return folderSyncJson.encodeToString(
|
||||
|
|
@ -44,8 +73,6 @@ data class SharedFolderBookMetadata(
|
|||
JsonObject(
|
||||
mapOf(
|
||||
"bookId" to JsonPrimitive(bookId),
|
||||
"title" to title.asJson(),
|
||||
"author" to author.asJson(),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"type" to JsonPrimitive(type),
|
||||
"lastChapterIndex" to JsonPrimitive(lastChapterIndex ?: -1),
|
||||
|
|
@ -76,6 +103,7 @@ data class SharedFolderBookMetadata(
|
|||
.takeIf { it.isNotEmpty() }
|
||||
val parsedType = runCatching { FileType.valueOf(type) }.getOrNull() ?: file.type
|
||||
val metadataTimestamp = lastModifiedTimestamp.takeIf { it > 0L } ?: nowMillis
|
||||
val parsedReaderPosition = readerPositionOrNull()
|
||||
|
||||
return (existing ?: BookItem(
|
||||
id = bookId,
|
||||
|
|
@ -83,9 +111,9 @@ data class SharedFolderBookMetadata(
|
|||
type = parsedType,
|
||||
displayName = displayName.ifBlank { file.name },
|
||||
timestamp = metadataTimestamp,
|
||||
title = title ?: displayName.ifBlank { file.name },
|
||||
author = author,
|
||||
title = file.name.substringBeforeLast('.', missingDelimiterValue = file.name),
|
||||
fileSize = file.size,
|
||||
fileContentModifiedTimestamp = file.lastModified,
|
||||
sourceFolder = file.sourceFolder,
|
||||
isRecent = isRecent
|
||||
)).copy(
|
||||
|
|
@ -95,19 +123,38 @@ data class SharedFolderBookMetadata(
|
|||
displayName = displayName.ifBlank { file.name },
|
||||
timestamp = if (isRecent || existing == null) metadataTimestamp else existing.timestamp,
|
||||
coverImagePath = existing?.coverImagePath,
|
||||
title = title ?: existing?.title ?: displayName.ifBlank { file.name },
|
||||
author = author ?: existing?.author,
|
||||
title = existing?.title ?: file.name.substringBeforeLast('.', missingDelimiterValue = file.name),
|
||||
author = existing?.author,
|
||||
description = existing?.description,
|
||||
originalTitle = existing?.originalTitle,
|
||||
originalAuthor = existing?.originalAuthor,
|
||||
originalSeriesName = existing?.originalSeriesName,
|
||||
originalSeriesIndex = existing?.originalSeriesIndex,
|
||||
originalDescription = existing?.originalDescription,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent || (existing?.isRecent ?: false),
|
||||
fileSize = file.size.takeIf { it > 0L } ?: existing?.fileSize ?: 0L,
|
||||
fileContentModifiedTimestamp = file.lastModified.takeIf { it > 0L } ?: existing?.fileContentModifiedTimestamp ?: 0L,
|
||||
sourceFolder = file.sourceFolder,
|
||||
folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false,
|
||||
seriesName = existing?.seriesName,
|
||||
seriesIndex = existing?.seriesIndex,
|
||||
lastPageIndex = lastPage,
|
||||
readerPosition = parsedReaderPosition ?: existing?.readerPosition,
|
||||
readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(),
|
||||
readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private fun readerPositionOrNull(): ReaderLocator? {
|
||||
if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null
|
||||
return ReaderLocator.fromLegacy(
|
||||
chapterIndex = lastChapterIndex,
|
||||
cfi = lastPositionCfi,
|
||||
pageIndex = lastPage
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseReaderBookmarks(bookId: String): List<ReaderBookmark> {
|
||||
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
|
||||
.mapIndexed { index, bookmark ->
|
||||
|
|
@ -135,8 +182,8 @@ data class SharedFolderBookMetadata(
|
|||
val bookId = obj.string("bookId")?.takeIf { it.isNotBlank() } ?: return null
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = bookId,
|
||||
title = obj.string("title"),
|
||||
author = obj.string("author"),
|
||||
title = null,
|
||||
author = null,
|
||||
displayName = obj.string("displayName") ?: "Unknown",
|
||||
type = obj.string("type") ?: FileType.PDF.name,
|
||||
lastChapterIndex = obj.sentinelInt("lastChapterIndex"),
|
||||
|
|
@ -149,7 +196,15 @@ data class SharedFolderBookMetadata(
|
|||
locatorBlockIndex = obj.sentinelInt("locatorBlockIndex"),
|
||||
locatorCharOffset = obj.sentinelInt("locatorCharOffset"),
|
||||
customName = obj.string("customName"),
|
||||
highlightsJson = obj.string("highlightsJson")
|
||||
highlightsJson = obj.string("highlightsJson"),
|
||||
seriesName = null,
|
||||
seriesIndex = null,
|
||||
description = null,
|
||||
originalTitle = null,
|
||||
originalAuthor = null,
|
||||
originalSeriesName = null,
|
||||
originalSeriesIndex = null,
|
||||
originalDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -286,7 +341,10 @@ object LocalFolderSyncEngine {
|
|||
} else {
|
||||
val updatedForFile = existing.withScannedFile(file)
|
||||
val updated = metadata
|
||||
?.takeIf { it.lastModifiedTimestamp > updatedForFile.localFolderModifiedTimestamp() }
|
||||
?.takeIf {
|
||||
it.lastModifiedTimestamp > 0L &&
|
||||
it.lastModifiedTimestamp >= updatedForFile.localFolderModifiedTimestamp()
|
||||
}
|
||||
?.toBookItem(file = file, existing = updatedForFile, nowMillis = nowMillis)
|
||||
?: updatedForFile
|
||||
booksById[stableId] = updated
|
||||
|
|
@ -348,19 +406,33 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
|
|||
val highlightsJson = readerHighlights
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(EpubAnnotationSerializer::highlightsToJson)
|
||||
val hasProgress = (progressPercentage ?: 0f) > 0f || lastPageIndex != null
|
||||
val isDirty = isRecent || hasProgress || !bookmarksJson.isNullOrBlank() || !highlightsJson.isNullOrBlank()
|
||||
val position = readerPosition
|
||||
val hasProgress = (progressPercentage ?: 0f) > 0f || lastPageIndex != null || position != null
|
||||
val isDirty = isRecent ||
|
||||
hasProgress ||
|
||||
!bookmarksJson.isNullOrBlank() ||
|
||||
!highlightsJson.isNullOrBlank()
|
||||
if (!isDirty) return null
|
||||
val positionCfi = position?.cfi ?: position?.let { locator ->
|
||||
val chapterIndex = locator.chapterIndex
|
||||
val startOffset = locator.startOffset
|
||||
val endOffset = locator.endOffset ?: startOffset
|
||||
if (chapterIndex != null && startOffset != null && endOffset != null) {
|
||||
"desktop:$chapterIndex:$startOffset:$endOffset"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = id,
|
||||
title = title,
|
||||
author = author,
|
||||
title = null,
|
||||
author = null,
|
||||
displayName = displayName,
|
||||
type = type.name,
|
||||
lastChapterIndex = null,
|
||||
lastPage = lastPageIndex,
|
||||
lastPositionCfi = null,
|
||||
lastChapterIndex = position?.chapterIndex,
|
||||
lastPage = position?.pageIndex ?: lastPageIndex,
|
||||
lastPositionCfi = positionCfi,
|
||||
progressPercentage = progressPercentage ?: 0f,
|
||||
isRecent = isRecent,
|
||||
lastModifiedTimestamp = localFolderModifiedTimestamp(),
|
||||
|
|
@ -368,7 +440,15 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
|
|||
locatorBlockIndex = null,
|
||||
locatorCharOffset = null,
|
||||
customName = null,
|
||||
highlightsJson = highlightsJson
|
||||
highlightsJson = highlightsJson,
|
||||
seriesName = null,
|
||||
seriesIndex = null,
|
||||
description = null,
|
||||
originalTitle = null,
|
||||
originalAuthor = null,
|
||||
originalSeriesName = null,
|
||||
originalSeriesIndex = null,
|
||||
originalDescription = null
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -402,6 +482,7 @@ private fun SharedFolderScannedFile.toBookItem(bookId: String, nowMillis: Long):
|
|||
timestamp = nowMillis,
|
||||
title = name.substringBeforeLast('.', missingDelimiterValue = name),
|
||||
fileSize = size,
|
||||
fileContentModifiedTimestamp = lastModified,
|
||||
sourceFolder = sourceFolder,
|
||||
isRecent = false
|
||||
)
|
||||
|
|
@ -409,14 +490,28 @@ private fun SharedFolderScannedFile.toBookItem(bookId: String, nowMillis: Long):
|
|||
|
||||
private fun BookItem.withScannedFile(file: SharedFolderScannedFile): BookItem {
|
||||
val sizeChanged = fileSize > 0L && file.size > 0L && fileSize != file.size
|
||||
val modifiedChanged = file.lastModified > 0L &&
|
||||
file.lastModified != fileContentModifiedTimestamp
|
||||
val contentChanged = sizeChanged || modifiedChanged
|
||||
return copy(
|
||||
path = file.path,
|
||||
type = file.type,
|
||||
displayName = file.name,
|
||||
coverImagePath = if (sizeChanged) null else coverImagePath,
|
||||
coverImagePath = if (contentChanged) null else coverImagePath,
|
||||
title = if (contentChanged) file.name.substringBeforeLast('.', missingDelimiterValue = file.name) else title,
|
||||
author = if (contentChanged) null else author,
|
||||
description = if (contentChanged) null else description,
|
||||
originalTitle = if (contentChanged) null else originalTitle,
|
||||
originalAuthor = if (contentChanged) null else originalAuthor,
|
||||
originalSeriesName = if (contentChanged) null else originalSeriesName,
|
||||
originalSeriesIndex = if (contentChanged) null else originalSeriesIndex,
|
||||
originalDescription = if (contentChanged) null else originalDescription,
|
||||
seriesName = if (contentChanged) null else seriesName,
|
||||
seriesIndex = if (contentChanged) null else seriesIndex,
|
||||
fileSize = file.size.takeIf { it > 0L } ?: fileSize,
|
||||
fileContentModifiedTimestamp = file.lastModified.takeIf { it > 0L } ?: fileContentModifiedTimestamp,
|
||||
sourceFolder = file.sourceFolder,
|
||||
folderTextMetadataParsed = if (sizeChanged) false else folderTextMetadataParsed
|
||||
folderTextMetadataParsed = if (contentChanged) false else folderTextMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,11 +53,8 @@ object EpubAnnotationSerializer {
|
|||
|
||||
fun parseHighlightJsonLenient(rawJson: String?): UserHighlight? {
|
||||
if (rawJson.isNullOrBlank()) return null
|
||||
parseHighlightJson(rawJson)?.let { return it }
|
||||
val unwrapped = runCatching {
|
||||
json.parseToJsonElement(rawJson).jsonPrimitive.content
|
||||
}.getOrNull()
|
||||
return parseHighlightJson(unwrapped)
|
||||
val element = runCatching { json.parseToJsonElement(rawJson) }.getOrNull() ?: return null
|
||||
return element.asHighlightLenientOrNull()
|
||||
}
|
||||
|
||||
fun highlightsToJson(highlights: Collection<UserHighlight>): String {
|
||||
|
|
@ -202,6 +199,22 @@ object EpubAnnotationSerializer {
|
|||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asHighlightLenientOrNull(): UserHighlight? {
|
||||
return when (this) {
|
||||
is JsonObject -> asHighlightOrNull()
|
||||
is JsonArray -> {
|
||||
for (element in this) {
|
||||
element.asHighlightLenientOrNull()?.let { return it }
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> contentOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.startsWith("{") || it.startsWith("[") }
|
||||
?.let { parseHighlightJsonLenient(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserHighlight.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
|
|
|
|||
|
|
@ -319,8 +319,18 @@ object ReaderTtsPlanner {
|
|||
}
|
||||
|
||||
fun chunksFromCurrentLocation(session: ReaderSessionState): List<ReaderTtsChunk> {
|
||||
val pageIndex = session.reader.currentPageIndex
|
||||
return chunksForPages(session.reader.book, session.reader.pages.drop(pageIndex.coerceAtLeast(0)))
|
||||
val anchor = session.navigationLocator
|
||||
val pageIndex = anchor?.pageIndex ?: session.reader.currentPageIndex
|
||||
val pages = session.reader.pages.dropWhile { it.pageIndex < pageIndex.coerceAtLeast(0) }
|
||||
val chunks = chunksForPages(session.reader.book, pages)
|
||||
val chapterIndex = anchor?.chapterIndex
|
||||
val startOffset = anchor?.startOffset
|
||||
if (chapterIndex == null && startOffset == null) return chunks
|
||||
var nextIndex = 0
|
||||
return chunks.mapNotNull { chunk ->
|
||||
chunk.afterLocator(chapterIndex = chapterIndex, startOffset = startOffset)
|
||||
?.copy(index = nextIndex++)
|
||||
}
|
||||
}
|
||||
|
||||
fun chunksForText(
|
||||
|
|
@ -388,6 +398,35 @@ object ReaderTtsPlanner {
|
|||
}
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunk.afterLocator(chapterIndex: Int?, startOffset: Int?): ReaderTtsChunk? {
|
||||
if (chapterIndex != null) {
|
||||
if (this.chapterIndex < chapterIndex) return null
|
||||
if (this.chapterIndex > chapterIndex) return this
|
||||
}
|
||||
val anchorOffset = startOffset ?: return this
|
||||
if (endOffset <= anchorOffset) return null
|
||||
if (anchorOffset <= this.startOffset) return this
|
||||
return trimStartTo(anchorOffset)
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunk.trimStartTo(sourceOffset: Int): ReaderTtsChunk? {
|
||||
val boundedOffset = sourceOffset.coerceIn(startOffset, endOffset)
|
||||
if (boundedOffset <= startOffset) return this
|
||||
if (boundedOffset >= endOffset) return null
|
||||
val rawDrop = (boundedOffset - startOffset).coerceIn(0, text.length)
|
||||
val remaining = text.drop(rawDrop)
|
||||
val leadingWhitespace = remaining.indexOfFirst { !it.isWhitespace() }
|
||||
if (leadingWhitespace < 0) return null
|
||||
val nextText = remaining.drop(leadingWhitespace)
|
||||
if (nextText.isBlank()) return null
|
||||
val nextStartOffset = (boundedOffset + leadingWhitespace).coerceAtMost(endOffset)
|
||||
return copy(
|
||||
text = nextText,
|
||||
spokenText = nextText,
|
||||
startOffset = nextStartOffset
|
||||
)
|
||||
}
|
||||
|
||||
private fun chunksForSemanticPages(
|
||||
chapter: SharedEpubChapter,
|
||||
pages: List<ReaderPage>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ data class ImportedBookFile(
|
|||
val uriString: String?,
|
||||
val localPath: String?,
|
||||
val size: Long,
|
||||
val sourceFolder: String? = null
|
||||
val sourceFolder: String? = null,
|
||||
val id: String? = null
|
||||
)
|
||||
|
||||
interface BookRepository {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,987 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
enum class SharedSettingsPlatform {
|
||||
ANDROID,
|
||||
DESKTOP
|
||||
}
|
||||
|
||||
enum class SharedSettingsSection(
|
||||
val title: String,
|
||||
val summary: String
|
||||
) {
|
||||
READER(
|
||||
title = "Reader settings",
|
||||
summary = "Global defaults for text, EPUB, PDF, toolbar, and speech"
|
||||
),
|
||||
APP_LIBRARY(
|
||||
title = "App & library",
|
||||
summary = "App preferences, imports, tabs, and local library behavior"
|
||||
),
|
||||
SYNC_ACCOUNTS(
|
||||
title = "Sync & accounts",
|
||||
summary = "Sign-in, cloud sync, and folder backup"
|
||||
),
|
||||
AI_TTS(
|
||||
title = "AI & TTS",
|
||||
summary = "Reader AI, keys, models, voices, and speech preferences"
|
||||
),
|
||||
STORAGE_ADVANCED(
|
||||
title = "Storage & advanced",
|
||||
summary = "Caches and diagnostic tools"
|
||||
),
|
||||
HELP(
|
||||
title = "Help",
|
||||
summary = "Feedback, support, and app information"
|
||||
),
|
||||
EXTRA(
|
||||
title = "Extra",
|
||||
summary = "Overflow options, maintenance, and diagnostics"
|
||||
)
|
||||
}
|
||||
|
||||
enum class SharedSettingsDestination {
|
||||
ROOT,
|
||||
EPUB_TEXT,
|
||||
PDF_COMICS,
|
||||
THEME_APPEARANCE,
|
||||
TTS_AI,
|
||||
LIBRARY_SYNC_STORAGE,
|
||||
SYNC_ACCOUNTS,
|
||||
EXTRA,
|
||||
HELP_ABOUT,
|
||||
EPUB_FORMAT,
|
||||
EPUB_THEME_TEXTURE,
|
||||
EPUB_VISUAL_DEFAULTS,
|
||||
PDF_APPEARANCE_DEFAULTS,
|
||||
PDF_READER_TOOLS,
|
||||
READER_TOOLBAR_DEFAULTS,
|
||||
EPUB_TTS_REPLACEMENTS,
|
||||
GLOBAL_TTS_REPLACEMENTS
|
||||
}
|
||||
|
||||
fun SharedSettingsDestination.parentDestination(): SharedSettingsDestination? {
|
||||
return when (this) {
|
||||
SharedSettingsDestination.ROOT -> null
|
||||
SharedSettingsDestination.EPUB_TEXT,
|
||||
SharedSettingsDestination.PDF_COMICS,
|
||||
SharedSettingsDestination.THEME_APPEARANCE,
|
||||
SharedSettingsDestination.TTS_AI,
|
||||
SharedSettingsDestination.LIBRARY_SYNC_STORAGE,
|
||||
SharedSettingsDestination.SYNC_ACCOUNTS,
|
||||
SharedSettingsDestination.EXTRA,
|
||||
SharedSettingsDestination.HELP_ABOUT -> SharedSettingsDestination.ROOT
|
||||
SharedSettingsDestination.EPUB_FORMAT,
|
||||
SharedSettingsDestination.EPUB_THEME_TEXTURE,
|
||||
SharedSettingsDestination.EPUB_VISUAL_DEFAULTS,
|
||||
SharedSettingsDestination.READER_TOOLBAR_DEFAULTS,
|
||||
SharedSettingsDestination.EPUB_TTS_REPLACEMENTS -> SharedSettingsDestination.EPUB_TEXT
|
||||
SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS,
|
||||
SharedSettingsDestination.PDF_READER_TOOLS -> SharedSettingsDestination.PDF_COMICS
|
||||
SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> SharedSettingsDestination.TTS_AI
|
||||
}
|
||||
}
|
||||
|
||||
enum class SharedSettingsPageKind {
|
||||
ROOT,
|
||||
CATEGORY,
|
||||
DETAIL
|
||||
}
|
||||
|
||||
data class SharedSettingsCategoryModel(
|
||||
val destination: SharedSettingsDestination,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val itemCount: Int
|
||||
) {
|
||||
fun matches(query: String): Boolean {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return true
|
||||
return title.contains(normalized, ignoreCase = true) ||
|
||||
summary.contains(normalized, ignoreCase = true) ||
|
||||
destination.name.contains(normalized, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedSettingsPageModel(
|
||||
val destination: SharedSettingsDestination,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val kind: SharedSettingsPageKind,
|
||||
val parent: SharedSettingsDestination?,
|
||||
val categories: List<SharedSettingsCategoryModel> = emptyList(),
|
||||
val items: List<SharedSettingsItemModel> = emptyList(),
|
||||
val localOverrideNote: SharedSettingsItemModel? = null
|
||||
)
|
||||
|
||||
data class SharedSettingsSearchResult(
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val breadcrumb: String,
|
||||
val destination: SharedSettingsDestination? = null,
|
||||
val action: SharedSettingsAction? = null,
|
||||
val kind: SharedSettingsItemKind = SharedSettingsItemKind.NAVIGATION,
|
||||
val enabled: Boolean = true,
|
||||
val checked: Boolean? = null
|
||||
)
|
||||
|
||||
enum class SharedSettingsItemKind {
|
||||
CONTROL,
|
||||
NAVIGATION,
|
||||
TOGGLE,
|
||||
DESTRUCTIVE,
|
||||
INFO
|
||||
}
|
||||
|
||||
enum class SharedSettingsAction {
|
||||
TEXT_READER_DEFAULTS,
|
||||
PDF_READER_DEFAULTS,
|
||||
READER_TOOLBAR,
|
||||
TTS_REPLACEMENTS,
|
||||
LOCAL_OVERRIDE_NOTE,
|
||||
APP_THEME,
|
||||
LANGUAGE,
|
||||
TABS_TOGGLE,
|
||||
RECENT_LIMIT,
|
||||
STRICT_FILE_FILTER,
|
||||
EXTERNAL_FILE_BEHAVIOR,
|
||||
SCREEN_CAPTURE_PROTECTION,
|
||||
CUSTOM_FONTS,
|
||||
SIGN_IN,
|
||||
SIGN_OUT,
|
||||
CLOUD_SYNC,
|
||||
FOLDER_SYNC,
|
||||
DEVICE_MANAGEMENT,
|
||||
AI_SETTINGS,
|
||||
HIDE_READER_AI,
|
||||
TTS_SETTINGS,
|
||||
CLEAR_BOOK_CACHE,
|
||||
CLEAR_REFLOW_CACHE,
|
||||
CLEAR_CLOUD_LOCAL_DATA,
|
||||
TEST_PANEL_DETECTION,
|
||||
TEST_SPEECH_BUBBLE_DETECTION,
|
||||
EXPORT_LOGS,
|
||||
DEBUG_ACTIONS,
|
||||
HELP_FEEDBACK,
|
||||
SUPPORT,
|
||||
ABOUT
|
||||
}
|
||||
|
||||
data class SharedSettingsItemModel(
|
||||
val action: SharedSettingsAction,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val kind: SharedSettingsItemKind = SharedSettingsItemKind.NAVIGATION,
|
||||
val enabled: Boolean = true,
|
||||
val checked: Boolean? = null,
|
||||
val destination: SharedSettingsDestination? = null
|
||||
) {
|
||||
fun matches(query: String): Boolean {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return true
|
||||
return title.contains(normalized, ignoreCase = true) ||
|
||||
summary.contains(normalized, ignoreCase = true) ||
|
||||
action.name.contains(normalized, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedSettingsSectionModel(
|
||||
val section: SharedSettingsSection,
|
||||
val items: List<SharedSettingsItemModel>
|
||||
) {
|
||||
fun matches(query: String): Boolean {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return true
|
||||
return section.title.contains(normalized, ignoreCase = true) ||
|
||||
section.summary.contains(normalized, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedSettingsHubModel(
|
||||
val platform: SharedSettingsPlatform,
|
||||
val sections: List<SharedSettingsSectionModel>
|
||||
) {
|
||||
val rootCategories: List<SharedSettingsCategoryModel>
|
||||
get() = buildRootCategories()
|
||||
|
||||
fun page(destination: SharedSettingsDestination): SharedSettingsPageModel {
|
||||
return when (destination) {
|
||||
SharedSettingsDestination.ROOT -> SharedSettingsPageModel(
|
||||
destination = SharedSettingsDestination.ROOT,
|
||||
title = "Settings",
|
||||
summary = "Global defaults, app preferences, and advanced options",
|
||||
kind = SharedSettingsPageKind.ROOT,
|
||||
parent = null,
|
||||
categories = rootCategories
|
||||
)
|
||||
SharedSettingsDestination.EPUB_TEXT -> categoryPage(
|
||||
destination = destination,
|
||||
title = "EPUB & Text",
|
||||
summary = "Defaults for reflowable reading, layout, EPUB themes, and reader tools",
|
||||
items = epubAndTextItems()
|
||||
)
|
||||
SharedSettingsDestination.PDF_COMICS -> categoryPage(
|
||||
destination = destination,
|
||||
title = "PDF & Comics",
|
||||
summary = "Defaults for fixed-layout reading, PDF themes, and PDF-specific tools",
|
||||
items = pdfAndComicItems()
|
||||
)
|
||||
SharedSettingsDestination.THEME_APPEARANCE -> categoryPage(
|
||||
destination = destination,
|
||||
title = "App Preferences",
|
||||
summary = "App theme and general app behavior",
|
||||
items = themeAndAppearanceItems()
|
||||
)
|
||||
SharedSettingsDestination.TTS_AI -> categoryPage(
|
||||
destination = destination,
|
||||
title = ttsAiTitle(),
|
||||
summary = ttsAiSummary(),
|
||||
items = ttsAndAiItems()
|
||||
)
|
||||
SharedSettingsDestination.LIBRARY_SYNC_STORAGE -> categoryPage(
|
||||
destination = destination,
|
||||
title = "Library & Files",
|
||||
summary = "Recent files and local reading fonts",
|
||||
items = libraryAndFileItems()
|
||||
)
|
||||
SharedSettingsDestination.SYNC_ACCOUNTS -> categoryPage(
|
||||
destination = destination,
|
||||
title = "Sync & Accounts",
|
||||
summary = "Sign-in, cloud sync, folder sync, and devices",
|
||||
items = syncAndAccountItems()
|
||||
)
|
||||
SharedSettingsDestination.EXTRA -> categoryPage(
|
||||
destination = destination,
|
||||
title = "Extra",
|
||||
summary = "More-menu options, maintenance actions, diagnostics, and app info",
|
||||
items = extraItems()
|
||||
)
|
||||
SharedSettingsDestination.HELP_ABOUT -> categoryPage(
|
||||
destination = destination,
|
||||
title = "Help & About",
|
||||
summary = "Feedback, support, project information, and licenses",
|
||||
items = helpAndAboutItems()
|
||||
)
|
||||
SharedSettingsDestination.EPUB_FORMAT -> detailPage(
|
||||
destination = destination,
|
||||
title = "Format Defaults",
|
||||
summary = "Font, size, spacing, margins, alignment, and reading mode",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.EPUB_THEME_TEXTURE -> detailPage(
|
||||
destination = destination,
|
||||
title = "EPUB Theme & Texture",
|
||||
summary = "Default EPUB reading theme, paper texture, and texture strength",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.EPUB_VISUAL_DEFAULTS -> detailPage(
|
||||
destination = destination,
|
||||
title = "Visual Defaults",
|
||||
summary = "Page indicators, system UI, images, and chapter-turn behavior",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS -> detailPage(
|
||||
destination = destination,
|
||||
title = "PDF Theme Defaults",
|
||||
summary = "Default PDF and comic theme where fixed-layout appearance is supported",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.PDF_READER_TOOLS -> detailPage(
|
||||
destination = destination,
|
||||
title = "PDF Reader Tools",
|
||||
summary = "Auto-scroll, OCR, annotation, and PDF-only tools remain in the PDF reader",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.READER_TOOLBAR_DEFAULTS -> detailPage(
|
||||
destination = destination,
|
||||
title = "Reader Toolbar Defaults",
|
||||
summary = "Visible tools, bottom-bar actions, and reader overflow tools",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.EPUB_TTS_REPLACEMENTS -> detailPage(
|
||||
destination = destination,
|
||||
title = "Global TTS Replacements",
|
||||
summary = "Words and phrases replaced only during speech playback",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> detailPage(
|
||||
destination = destination,
|
||||
title = "Global TTS Replacements",
|
||||
summary = "Words and phrases replaced only during speech playback",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun searchResults(query: String): List<SharedSettingsSearchResult> {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
|
||||
val categoryResults = rootCategories
|
||||
.filter { it.matches(normalized) }
|
||||
.map { category ->
|
||||
SharedSettingsSearchResult(
|
||||
title = category.title,
|
||||
summary = category.summary,
|
||||
breadcrumb = "Settings",
|
||||
destination = category.destination
|
||||
)
|
||||
}
|
||||
|
||||
val itemResults = listOf(
|
||||
SharedSettingsDestination.EPUB_TEXT,
|
||||
SharedSettingsDestination.PDF_COMICS,
|
||||
SharedSettingsDestination.THEME_APPEARANCE,
|
||||
SharedSettingsDestination.TTS_AI,
|
||||
SharedSettingsDestination.LIBRARY_SYNC_STORAGE,
|
||||
SharedSettingsDestination.SYNC_ACCOUNTS,
|
||||
SharedSettingsDestination.EXTRA
|
||||
).flatMap { destination ->
|
||||
val page = page(destination)
|
||||
page.items
|
||||
.filter { it.matches(normalized) }
|
||||
.map { item ->
|
||||
SharedSettingsSearchResult(
|
||||
title = item.title,
|
||||
summary = item.summary,
|
||||
breadcrumb = "Settings / ${page.title}",
|
||||
destination = item.destination,
|
||||
action = item.action,
|
||||
kind = item.kind,
|
||||
enabled = item.enabled,
|
||||
checked = item.checked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (categoryResults + itemResults)
|
||||
.distinctBy { result -> result.searchIdentity() }
|
||||
}
|
||||
|
||||
fun filtered(query: String): SharedSettingsHubModel {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return this
|
||||
return copy(
|
||||
sections = sections.mapNotNull { section ->
|
||||
val matchingItems = section.items.filter { it.matches(normalized) }
|
||||
when {
|
||||
matchingItems.isNotEmpty() -> section.copy(items = matchingItems)
|
||||
section.matches(normalized) -> section
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun itemsIn(section: SharedSettingsSection): List<SharedSettingsItemModel> {
|
||||
return sections.firstOrNull { it.section == section }?.items.orEmpty()
|
||||
}
|
||||
|
||||
private fun buildRootCategories(): List<SharedSettingsCategoryModel> {
|
||||
return listOf(
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.EPUB_TEXT,
|
||||
title = "EPUB & Text",
|
||||
summary = "Format, EPUB theme, visual defaults, and reader tools",
|
||||
itemCount = epubAndTextItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.PDF_COMICS,
|
||||
title = "PDF & Comics",
|
||||
summary = "Separate PDF theme and fixed-layout reader defaults",
|
||||
itemCount = pdfAndComicItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.THEME_APPEARANCE,
|
||||
title = "App Preferences",
|
||||
summary = "App theme and general app behavior",
|
||||
itemCount = themeAndAppearanceItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.TTS_AI,
|
||||
title = ttsAiTitle(),
|
||||
summary = ttsAiSummary(),
|
||||
itemCount = ttsAndAiItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.LIBRARY_SYNC_STORAGE,
|
||||
title = "Library & Files",
|
||||
summary = "Recent files and local reading fonts",
|
||||
itemCount = libraryAndFileItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.SYNC_ACCOUNTS,
|
||||
title = "Sync & Accounts",
|
||||
summary = "Sign-in, cloud sync, folder sync, and devices",
|
||||
itemCount = syncAndAccountItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.EXTRA,
|
||||
title = "Extra",
|
||||
summary = "More-menu options, maintenance, diagnostics, and app info",
|
||||
itemCount = extraItems().size
|
||||
)
|
||||
).filter { it.itemCount > 0 }
|
||||
}
|
||||
|
||||
private fun rootCategory(
|
||||
destination: SharedSettingsDestination,
|
||||
title: String,
|
||||
summary: String,
|
||||
itemCount: Int
|
||||
): SharedSettingsCategoryModel {
|
||||
return SharedSettingsCategoryModel(
|
||||
destination = destination,
|
||||
title = title,
|
||||
summary = summary,
|
||||
itemCount = itemCount
|
||||
)
|
||||
}
|
||||
|
||||
private fun ttsAiTitle(): String {
|
||||
return if (hasAiSettingsItem()) "TTS & AI" else "TTS"
|
||||
}
|
||||
|
||||
private fun ttsAiSummary(): String {
|
||||
return if (hasAiSettingsItem()) {
|
||||
"Global voice, speech replacements, keys, and reader AI"
|
||||
} else {
|
||||
"Global voice, speech behavior, and TTS replacements"
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasAiSettingsItem(): Boolean {
|
||||
return baseItem(SharedSettingsAction.AI_SETTINGS) != null
|
||||
}
|
||||
|
||||
private fun categoryPage(
|
||||
destination: SharedSettingsDestination,
|
||||
title: String,
|
||||
summary: String,
|
||||
items: List<SharedSettingsItemModel>
|
||||
): SharedSettingsPageModel {
|
||||
return SharedSettingsPageModel(
|
||||
destination = destination,
|
||||
title = title,
|
||||
summary = summary,
|
||||
kind = SharedSettingsPageKind.CATEGORY,
|
||||
parent = destination.parentDestination(),
|
||||
items = items
|
||||
)
|
||||
}
|
||||
|
||||
private fun detailPage(
|
||||
destination: SharedSettingsDestination,
|
||||
title: String,
|
||||
summary: String,
|
||||
localOverrideNote: SharedSettingsItemModel?
|
||||
): SharedSettingsPageModel {
|
||||
return SharedSettingsPageModel(
|
||||
destination = destination,
|
||||
title = title,
|
||||
summary = summary,
|
||||
kind = SharedSettingsPageKind.DETAIL,
|
||||
parent = destination.parentDestination(),
|
||||
localOverrideNote = localOverrideNote
|
||||
)
|
||||
}
|
||||
|
||||
private fun epubAndTextItems(): List<SharedSettingsItemModel> {
|
||||
return buildList {
|
||||
baseItem(SharedSettingsAction.TEXT_READER_DEFAULTS)?.let { item ->
|
||||
add(
|
||||
item.destinationRow(
|
||||
destination = SharedSettingsDestination.EPUB_FORMAT,
|
||||
title = "Format defaults",
|
||||
summary = "Font, size, line spacing, margins, alignment, and reading mode"
|
||||
)
|
||||
)
|
||||
add(
|
||||
item.destinationRow(
|
||||
destination = SharedSettingsDestination.EPUB_THEME_TEXTURE,
|
||||
title = "Theme and texture",
|
||||
summary = "Reading theme, texture, and page feel for new books"
|
||||
)
|
||||
)
|
||||
add(
|
||||
item.destinationRow(
|
||||
destination = SharedSettingsDestination.EPUB_VISUAL_DEFAULTS,
|
||||
title = "Visual defaults",
|
||||
summary = "System UI, page info, images, and chapter-turn behavior"
|
||||
)
|
||||
)
|
||||
}
|
||||
baseItem(SharedSettingsAction.READER_TOOLBAR)?.let { item ->
|
||||
add(item.destinationRow(SharedSettingsDestination.READER_TOOLBAR_DEFAULTS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pdfAndComicItems(): List<SharedSettingsItemModel> {
|
||||
return buildList {
|
||||
baseItem(SharedSettingsAction.PDF_READER_DEFAULTS)?.let { item ->
|
||||
add(
|
||||
item.destinationRow(
|
||||
destination = SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS,
|
||||
title = "PDF theme defaults",
|
||||
summary = "PDF and comic theme defaults, separate from EPUB themes"
|
||||
)
|
||||
)
|
||||
add(
|
||||
item.destinationRow(
|
||||
destination = SharedSettingsDestination.PDF_READER_TOOLS,
|
||||
title = "PDF reader tools",
|
||||
summary = "Auto-scroll, OCR, annotations, and PDF-only tools"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun themeAndAppearanceItems(): List<SharedSettingsItemModel> {
|
||||
return itemsForActions(
|
||||
SharedSettingsAction.APP_THEME
|
||||
)
|
||||
}
|
||||
|
||||
private fun ttsAndAiItems(): List<SharedSettingsItemModel> {
|
||||
return buildList {
|
||||
addAll(
|
||||
itemsForActions(
|
||||
SharedSettingsAction.TTS_SETTINGS,
|
||||
SharedSettingsAction.AI_SETTINGS
|
||||
)
|
||||
)
|
||||
baseItem(SharedSettingsAction.TTS_REPLACEMENTS)?.let { item ->
|
||||
add(item.destinationRow(SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun libraryAndFileItems(): List<SharedSettingsItemModel> {
|
||||
return itemsForActions(
|
||||
SharedSettingsAction.RECENT_LIMIT,
|
||||
SharedSettingsAction.CUSTOM_FONTS
|
||||
)
|
||||
}
|
||||
|
||||
private fun syncAndAccountItems(): List<SharedSettingsItemModel> {
|
||||
return itemsForActions(
|
||||
SharedSettingsAction.SIGN_IN,
|
||||
SharedSettingsAction.SIGN_OUT,
|
||||
SharedSettingsAction.CLOUD_SYNC,
|
||||
SharedSettingsAction.FOLDER_SYNC
|
||||
)
|
||||
}
|
||||
|
||||
private fun extraItems(): List<SharedSettingsItemModel> {
|
||||
return itemsForActions(
|
||||
SharedSettingsAction.LANGUAGE,
|
||||
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION,
|
||||
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR,
|
||||
SharedSettingsAction.STRICT_FILE_FILTER,
|
||||
SharedSettingsAction.TABS_TOGGLE,
|
||||
SharedSettingsAction.HIDE_READER_AI,
|
||||
SharedSettingsAction.CLEAR_BOOK_CACHE,
|
||||
SharedSettingsAction.CLEAR_REFLOW_CACHE,
|
||||
SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA,
|
||||
SharedSettingsAction.TEST_PANEL_DETECTION,
|
||||
SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION,
|
||||
SharedSettingsAction.EXPORT_LOGS,
|
||||
SharedSettingsAction.DEVICE_MANAGEMENT,
|
||||
SharedSettingsAction.HELP_FEEDBACK,
|
||||
SharedSettingsAction.SUPPORT,
|
||||
SharedSettingsAction.ABOUT
|
||||
)
|
||||
}
|
||||
|
||||
private fun helpAndAboutItems(): List<SharedSettingsItemModel> {
|
||||
return itemsForActions(
|
||||
SharedSettingsAction.HELP_FEEDBACK,
|
||||
SharedSettingsAction.SUPPORT,
|
||||
SharedSettingsAction.ABOUT
|
||||
)
|
||||
}
|
||||
|
||||
private fun itemsForActions(vararg actions: SharedSettingsAction): List<SharedSettingsItemModel> {
|
||||
return actions.mapNotNull(::baseItem)
|
||||
}
|
||||
|
||||
private fun localOverrideItem(): SharedSettingsItemModel? {
|
||||
return baseItem(SharedSettingsAction.LOCAL_OVERRIDE_NOTE)
|
||||
}
|
||||
|
||||
private fun baseItem(action: SharedSettingsAction): SharedSettingsItemModel? {
|
||||
return sections.asSequence()
|
||||
.flatMap { it.items.asSequence() }
|
||||
.firstOrNull { it.action == action }
|
||||
}
|
||||
|
||||
private fun SharedSettingsItemModel.destinationRow(
|
||||
destination: SharedSettingsDestination,
|
||||
title: String = this.title,
|
||||
summary: String = this.summary
|
||||
): SharedSettingsItemModel {
|
||||
return copy(
|
||||
title = title,
|
||||
summary = summary,
|
||||
kind = SharedSettingsItemKind.NAVIGATION,
|
||||
destination = destination
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedSettingsSearchResult.searchIdentity(): String {
|
||||
return when (action) {
|
||||
SharedSettingsAction.TTS_REPLACEMENTS -> SharedSettingsAction.TTS_REPLACEMENTS.name
|
||||
else -> destination?.name ?: action?.name ?: title
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedSettingsHubInput(
|
||||
val platform: SharedSettingsPlatform,
|
||||
val featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard,
|
||||
val isDebugBuild: Boolean = false,
|
||||
val isSignedIn: Boolean = false,
|
||||
val isProUser: Boolean = false,
|
||||
val syncAvailable: Boolean = true,
|
||||
val folderSyncAvailable: Boolean = true,
|
||||
val aiSettingsAvailable: Boolean = true,
|
||||
val ttsSettingsAvailable: Boolean = true,
|
||||
val includePdfReaderDefaults: Boolean = true,
|
||||
val includeReaderToolbar: Boolean = true,
|
||||
val includeLanguage: Boolean = true,
|
||||
val includeScreenCaptureProtection: Boolean = false,
|
||||
val includeExternalFileBehavior: Boolean = true,
|
||||
val includeRecentLimit: Boolean = true,
|
||||
val includeCustomFonts: Boolean = true,
|
||||
val includeStrictFileFilter: Boolean = true,
|
||||
val includeReaderTabs: Boolean = true,
|
||||
val includeHideReaderAi: Boolean = true,
|
||||
val includeCloudLocalDataClear: Boolean = false,
|
||||
val supportProjectAvailable: Boolean = true,
|
||||
val isTabsEnabled: Boolean = true,
|
||||
val isSyncEnabled: Boolean = false,
|
||||
val isFolderSyncEnabled: Boolean = false,
|
||||
val useStrictFileFilter: Boolean = false,
|
||||
val isScreenCaptureProtectionEnabled: Boolean = false,
|
||||
val hideReaderAi: Boolean = false
|
||||
)
|
||||
|
||||
fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubModel {
|
||||
val sections = listOf(
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.READER,
|
||||
items = buildList {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TEXT_READER_DEFAULTS,
|
||||
title = "Text and EPUB defaults",
|
||||
summary = "Format, EPUB theme, texture, visual behavior, and text layout",
|
||||
kind = SharedSettingsItemKind.CONTROL
|
||||
)
|
||||
)
|
||||
if (input.includePdfReaderDefaults) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.PDF_READER_DEFAULTS,
|
||||
title = "PDF and comic defaults",
|
||||
summary = "PDF theme, visual defaults, tools, auto-scroll, OCR, and annotation behavior where available",
|
||||
kind = SharedSettingsItemKind.NAVIGATION
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeReaderToolbar) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.READER_TOOLBAR,
|
||||
title = "Reader toolbar and tools",
|
||||
summary = "Choose visible tools, bottom-bar tools, and reader overflow tools",
|
||||
kind = SharedSettingsItemKind.CONTROL
|
||||
)
|
||||
)
|
||||
}
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.LOCAL_OVERRIDE_NOTE,
|
||||
title = "Per-book overrides",
|
||||
summary = "Local overrides are available from the active reader screen and still win for that book.",
|
||||
kind = SharedSettingsItemKind.INFO
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.APP_LIBRARY,
|
||||
items = buildList {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.APP_THEME,
|
||||
title = "App theme",
|
||||
summary = "Theme mode, contrast, reading text dimming, and custom app colors"
|
||||
)
|
||||
)
|
||||
if (input.includeCustomFonts) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.CUSTOM_FONTS,
|
||||
title = "Custom fonts",
|
||||
summary = "Import, manage, and reuse local reading fonts"
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeRecentLimit) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.RECENT_LIMIT,
|
||||
title = "Recent files limit",
|
||||
summary = "Control how many recent books appear on Home"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.SYNC_ACCOUNTS,
|
||||
items = buildList {
|
||||
if (input.syncAvailable && input.featurePolicy.aiAndCloud) {
|
||||
if (input.isSignedIn) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.SIGN_OUT,
|
||||
title = "Sign out",
|
||||
summary = "Disconnect this device from your account",
|
||||
kind = SharedSettingsItemKind.DESTRUCTIVE
|
||||
)
|
||||
)
|
||||
} else {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.SIGN_IN,
|
||||
title = "Sign in",
|
||||
summary = "Connect sync and account features"
|
||||
)
|
||||
)
|
||||
}
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.CLOUD_SYNC,
|
||||
title = "Cloud library sync",
|
||||
summary = if (input.isProUser) "Sync library metadata across signed-in devices." else "A Pro account is required for cloud sync.",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
enabled = input.isProUser,
|
||||
checked = input.isSyncEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.folderSyncAvailable) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.FOLDER_SYNC,
|
||||
title = "Folder backup and sync",
|
||||
summary = "Keep selected local folders represented in the library",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
checked = input.isFolderSyncEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.isDebugBuild && input.featurePolicy.aiAndCloud && input.syncAvailable) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.DEVICE_MANAGEMENT,
|
||||
title = "Device management",
|
||||
summary = "Inspect registered devices for this account"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.AI_TTS,
|
||||
items = buildList {
|
||||
if (input.aiSettingsAvailable && input.featurePolicy.aiAndCloud) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.AI_SETTINGS,
|
||||
title = "AI keys and models",
|
||||
summary = "Configure reader AI and cloud TTS model access"
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.ttsSettingsAvailable) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TTS_SETTINGS,
|
||||
title = "TTS voice settings",
|
||||
summary = "Choose cloud or device voices and speech behavior"
|
||||
)
|
||||
)
|
||||
}
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TTS_REPLACEMENTS,
|
||||
title = "Global TTS replacements",
|
||||
summary = "Words and phrases replaced only during speech playback",
|
||||
kind = SharedSettingsItemKind.CONTROL
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.STORAGE_ADVANCED,
|
||||
items = buildList {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.CLEAR_BOOK_CACHE,
|
||||
title = "Clear book cache",
|
||||
summary = "Remove generated book cache files and recreate them on demand",
|
||||
kind = SharedSettingsItemKind.DESTRUCTIVE
|
||||
)
|
||||
)
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.CLEAR_REFLOW_CACHE,
|
||||
title = "Clear reflow cache",
|
||||
summary = "Remove generated PDF text-view files",
|
||||
kind = SharedSettingsItemKind.DESTRUCTIVE
|
||||
)
|
||||
)
|
||||
if (input.isDebugBuild) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TEST_PANEL_DETECTION,
|
||||
title = "Test panel detection",
|
||||
summary = "Run the local panel-detection diagnostic"
|
||||
)
|
||||
)
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION,
|
||||
title = "Test speech-bubble detection",
|
||||
summary = "Run the local speech-bubble detection diagnostic"
|
||||
)
|
||||
)
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.EXPORT_LOGS,
|
||||
title = "Export logs",
|
||||
summary = "Export recent diagnostic logs",
|
||||
kind = SharedSettingsItemKind.NAVIGATION
|
||||
)
|
||||
)
|
||||
if (input.includeCloudLocalDataClear && input.featurePolicy.aiAndCloud) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA,
|
||||
title = "Clear cloud and local data",
|
||||
summary = "Delete cloud records and matching local library data",
|
||||
kind = SharedSettingsItemKind.DESTRUCTIVE
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.EXTRA,
|
||||
items = buildList {
|
||||
if (input.includeLanguage) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.LANGUAGE,
|
||||
title = "Language",
|
||||
summary = "Choose the app language"
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeScreenCaptureProtection) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.SCREEN_CAPTURE_PROTECTION,
|
||||
title = "Screen capture protection",
|
||||
summary = "Block screenshots and screen recording on sensitive reader screens",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
checked = input.isScreenCaptureProtectionEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeExternalFileBehavior) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR,
|
||||
title = "External file behavior",
|
||||
summary = "Choose whether external opens are copied into the app library"
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeStrictFileFilter) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.STRICT_FILE_FILTER,
|
||||
title = "Strict file filter",
|
||||
summary = "Use only known reader file types in import pickers",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
checked = input.useStrictFileFilter
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeReaderTabs) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TABS_TOGGLE,
|
||||
title = "Reader tabs",
|
||||
summary = if (input.isTabsEnabled) "Opening PDFs keeps active tabs." else "PDFs replace the active reader session.",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
checked = input.isTabsEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeHideReaderAi && input.featurePolicy.aiAndCloud) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.HIDE_READER_AI,
|
||||
title = "Reader AI visibility",
|
||||
summary = if (input.hideReaderAi) "Reader AI tools are hidden." else "Reader AI tools are shown where available.",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
checked = !input.hideReaderAi
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.HELP,
|
||||
items = buildList {
|
||||
if (input.featurePolicy.projectLinks) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.HELP_FEEDBACK,
|
||||
title = "Help and feedback",
|
||||
summary = "Send feedback or report an issue"
|
||||
)
|
||||
)
|
||||
if (input.supportProjectAvailable) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.SUPPORT,
|
||||
title = "Support project",
|
||||
summary = "Open support options for the project"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.ABOUT,
|
||||
title = "About",
|
||||
summary = "Version, source, licenses, and project information"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
).filter { it.items.isNotEmpty() }
|
||||
|
||||
return SharedSettingsHubModel(
|
||||
platform = input.platform,
|
||||
sections = sections
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
data class SharedFeaturePolicy(
|
||||
val networkAccess: Boolean = true,
|
||||
val opdsCatalogs: Boolean = networkAccess,
|
||||
val aiAndCloud: Boolean = networkAccess,
|
||||
val externalLookup: Boolean = networkAccess,
|
||||
val projectLinks: Boolean = networkAccess,
|
||||
val googleFontsDownload: Boolean = networkAccess
|
||||
) {
|
||||
companion object {
|
||||
val Standard = SharedFeaturePolicy()
|
||||
val OssOffline = SharedFeaturePolicy(
|
||||
networkAccess = false,
|
||||
opdsCatalogs = false,
|
||||
aiAndCloud = false,
|
||||
externalLookup = false,
|
||||
projectLinks = false,
|
||||
googleFontsDownload = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,13 +9,17 @@ import kotlinx.serialization.json.JsonPrimitive
|
|||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.floatOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderViewport
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
|
|
@ -28,7 +32,7 @@ data class SharedLibrarySnapshot(
|
|||
val customFonts: List<CustomFontItem> = emptyList(),
|
||||
val syncedFolders: List<SyncedFolder> = emptyList(),
|
||||
val recentFilesLimit: Int = 12,
|
||||
val isTabsEnabled: Boolean = false,
|
||||
val isTabsEnabled: Boolean = true,
|
||||
val openTabIds: List<String> = emptyList(),
|
||||
val activeTabBookId: String? = null,
|
||||
val pinnedHomeBookIds: Set<String> = emptySet(),
|
||||
|
|
@ -40,13 +44,16 @@ data class SharedLibrarySnapshot(
|
|||
val appTextDimFactorDark: Float = 1.0f,
|
||||
val appSeedColor: Color? = null,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(),
|
||||
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
|
||||
)
|
||||
|
||||
object SharedLibrarySnapshotJson {
|
||||
private const val SCHEMA_VERSION = 10
|
||||
private const val SCHEMA_VERSION = 19
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
|
|
@ -60,6 +67,10 @@ object SharedLibrarySnapshotJson {
|
|||
|
||||
val schemaVersion = root.int("schemaVersion", 1)
|
||||
val openTabIds = root.stringArray("openTabIds")
|
||||
val readerDefaultSettings = root["readerDefaultSettings"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderSettingsOrNull()
|
||||
?: ReaderSettings()
|
||||
return SharedLibrarySnapshot(
|
||||
books = root.array("books")
|
||||
.mapNotNull { it.asBookItemOrNull() }
|
||||
|
|
@ -70,7 +81,7 @@ object SharedLibrarySnapshotJson {
|
|||
customFonts = root.array("customFonts").mapNotNull { it.asCustomFontItemOrNull() },
|
||||
syncedFolders = root.array("syncedFolders").mapNotNull { it.asSyncedFolderOrNull() },
|
||||
recentFilesLimit = root.int("recentFilesLimit", 12),
|
||||
isTabsEnabled = root.boolean("isTabsEnabled", false),
|
||||
isTabsEnabled = root.boolean("isTabsEnabled", true),
|
||||
openTabIds = openTabIds,
|
||||
activeTabBookId = root.string("activeTabBookId"),
|
||||
pinnedHomeBookIds = root.stringArray("pinnedHomeBookIds").toSet(),
|
||||
|
|
@ -90,6 +101,11 @@ object SharedLibrarySnapshotJson {
|
|||
?: 1.0f,
|
||||
appSeedColor = root.int("appSeedColor")?.let { Color(it) },
|
||||
customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() },
|
||||
readerDefaultSettings = readerDefaultSettings.migrateLegacyDefaultReadingMode(schemaVersion),
|
||||
pdfReaderDefaultSettings = root["pdfReaderDefaultSettings"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderSettingsOrNull()
|
||||
?: ReaderSettings(themeId = "no_theme"),
|
||||
readerToolbarPreferences = root["readerToolbarPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderToolbarPreferencesOrNull()
|
||||
|
|
@ -98,6 +114,10 @@ object SharedLibrarySnapshotJson {
|
|||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderHighlightPaletteOrNull()
|
||||
?: ReaderHighlightPalette(),
|
||||
pdfHighlighterPalette = root["pdfHighlighterPalette"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asSharedPdfHighlighterPaletteOrNull()
|
||||
?: SharedPdfHighlighterPalette(),
|
||||
readerTtsReplacementPreferences = root["readerTtsReplacementPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.let { ReaderTtsReplacementPreferencesJson.fromJsonElement(it) }
|
||||
|
|
@ -128,8 +148,11 @@ object SharedLibrarySnapshotJson {
|
|||
"appTextDimFactorDark" to JsonPrimitive(snapshot.appTextDimFactorDark),
|
||||
"appSeedColor" to snapshot.appSeedColor.asJson(),
|
||||
"customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }),
|
||||
"readerDefaultSettings" to snapshot.readerDefaultSettings.asJson(),
|
||||
"pdfReaderDefaultSettings" to snapshot.pdfReaderDefaultSettings.asJson(),
|
||||
"readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(),
|
||||
"readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(),
|
||||
"pdfHighlighterPalette" to snapshot.pdfHighlighterPalette.sanitized().toJsonObject(),
|
||||
"readerTtsReplacementPreferences" to ReaderTtsReplacementPreferencesJson.toJsonElement(
|
||||
snapshot.readerTtsReplacementPreferences,
|
||||
)
|
||||
|
|
@ -149,6 +172,12 @@ private fun JsonObject.stringArray(name: String): List<String> {
|
|||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.intArray(name: String): List<Int> {
|
||||
return array(name).mapNotNull { element ->
|
||||
runCatching { element.jsonPrimitive.intOrNull }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
|
||||
}
|
||||
|
|
@ -196,10 +225,18 @@ private fun List<BookItem>.migrateLegacyRecentState(schemaVersion: Int, openTabI
|
|||
private fun BookItem.hasReaderFootprint(openedBookIds: Set<String>): Boolean {
|
||||
return id in openedBookIds ||
|
||||
lastPageIndex != null ||
|
||||
readerPosition != null ||
|
||||
(progressPercentage ?: 0f) > 0f ||
|
||||
readerSettings != null ||
|
||||
readerBookmarks.isNotEmpty() ||
|
||||
readerHighlights.isNotEmpty()
|
||||
readerHighlights.isNotEmpty() ||
|
||||
pdfReaderViewport != null
|
||||
}
|
||||
|
||||
private fun ReaderSettings.migrateLegacyDefaultReadingMode(schemaVersion: Int): ReaderSettings {
|
||||
if (schemaVersion >= 17 || readingMode != ReaderReadingMode.PAGINATED) return this
|
||||
val oldDefaultSettings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED)
|
||||
return if (this == oldDefaultSettings) copy(readingMode = ReaderReadingMode.VERTICAL) else this
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookItemOrNull(): BookItem? {
|
||||
|
|
@ -216,18 +253,27 @@ private fun JsonElement.asBookItemOrNull(): BookItem? {
|
|||
coverImagePath = obj.string("coverImagePath"),
|
||||
title = obj.string("title"),
|
||||
author = obj.string("author"),
|
||||
description = obj.string("description"),
|
||||
originalTitle = obj.string("originalTitle"),
|
||||
originalAuthor = obj.string("originalAuthor"),
|
||||
originalSeriesName = obj.string("originalSeriesName"),
|
||||
originalSeriesIndex = obj.double("originalSeriesIndex"),
|
||||
originalDescription = obj.string("originalDescription"),
|
||||
progressPercentage = obj.float("progressPercentage"),
|
||||
isRecent = obj.boolean("isRecent", true),
|
||||
fileSize = obj.long("fileSize"),
|
||||
fileContentModifiedTimestamp = obj.long("fileContentModifiedTimestamp"),
|
||||
sourceFolder = obj.string("sourceFolder"),
|
||||
folderTextMetadataParsed = obj.boolean("folderTextMetadataParsed", false),
|
||||
seriesName = obj.string("seriesName"),
|
||||
seriesIndex = obj.double("seriesIndex"),
|
||||
tags = obj.array("tags").mapNotNull { it.asTagOrNull() },
|
||||
lastPageIndex = obj.int("lastPageIndex"),
|
||||
readerPosition = obj["readerPosition"]?.takeUnless { it is JsonNull }?.asReaderLocatorOrNull(),
|
||||
readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(),
|
||||
readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() },
|
||||
readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() }
|
||||
readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() },
|
||||
pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -282,8 +328,9 @@ private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? {
|
|||
lastScanTime = obj.long("lastScanTime"),
|
||||
allowedFileTypes = obj.stringArray("allowedFileTypes")
|
||||
.mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() }
|
||||
.filter { it in SharedFileCapabilities.knownFileTypes }
|
||||
.toSet()
|
||||
.ifEmpty { FileType.entries.toSet() }
|
||||
.ifEmpty { SharedFileCapabilities.knownFileTypes }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -307,18 +354,27 @@ private fun BookItem.toJsonObject(): JsonObject {
|
|||
"coverImagePath" to coverImagePath.asJson(),
|
||||
"title" to title.asJson(),
|
||||
"author" to author.asJson(),
|
||||
"description" to description.asJson(),
|
||||
"originalTitle" to originalTitle.asJson(),
|
||||
"originalAuthor" to originalAuthor.asJson(),
|
||||
"originalSeriesName" to originalSeriesName.asJson(),
|
||||
"originalSeriesIndex" to originalSeriesIndex.asJson(),
|
||||
"originalDescription" to originalDescription.asJson(),
|
||||
"progressPercentage" to progressPercentage.asJson(),
|
||||
"isRecent" to JsonPrimitive(isRecent),
|
||||
"fileSize" to JsonPrimitive(fileSize),
|
||||
"fileContentModifiedTimestamp" to JsonPrimitive(fileContentModifiedTimestamp),
|
||||
"sourceFolder" to sourceFolder.asJson(),
|
||||
"folderTextMetadataParsed" to JsonPrimitive(folderTextMetadataParsed),
|
||||
"seriesName" to seriesName.asJson(),
|
||||
"seriesIndex" to seriesIndex.asJson(),
|
||||
"tags" to JsonArray(tags.map { it.toJsonObject() }),
|
||||
"lastPageIndex" to lastPageIndex.asJson(),
|
||||
"readerPosition" to readerPosition.asJson(),
|
||||
"readerSettings" to readerSettings.asJson(),
|
||||
"readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }),
|
||||
"readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() })
|
||||
"readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() }),
|
||||
"pdfReaderViewport" to pdfReaderViewport.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -374,7 +430,11 @@ private fun SyncedFolder.toJsonObject(): JsonObject {
|
|||
"uriString" to JsonPrimitive(uriString),
|
||||
"name" to JsonPrimitive(name),
|
||||
"lastScanTime" to JsonPrimitive(lastScanTime),
|
||||
"allowedFileTypes" to allowedFileTypes.map { it.name }.sorted().asJsonArray()
|
||||
"allowedFileTypes" to allowedFileTypes
|
||||
.filter { it in SharedFileCapabilities.knownFileTypes }
|
||||
.map { it.name }
|
||||
.sorted()
|
||||
.asJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -400,6 +460,10 @@ private fun List<String>.asJsonArray(): JsonArray {
|
|||
return JsonArray(map { JsonPrimitive(it) })
|
||||
}
|
||||
|
||||
private fun List<Int>.asIntJsonArray(): JsonArray {
|
||||
return JsonArray(map { JsonPrimitive(it) })
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val defaults = ReaderSettings()
|
||||
|
|
@ -435,6 +499,17 @@ private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
|
|||
pageInfoPosition = obj.string("pageInfoPosition")
|
||||
?.let { runCatching { PageInfoPosition.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageInfoPosition,
|
||||
pageSpreadMode = obj.string("pageSpreadMode")
|
||||
?.let { runCatching { ReaderPageSpreadMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageSpreadMode,
|
||||
pdfVerticalPageGapVisible = obj.boolean(
|
||||
"pdfVerticalPageGapVisible",
|
||||
defaults.pdfVerticalPageGapVisible
|
||||
),
|
||||
pdfPageNumberOverlayVisible = obj.boolean(
|
||||
"pdfPageNumberOverlayVisible",
|
||||
defaults.pdfPageNumberOverlayVisible
|
||||
),
|
||||
seamlessChapterNavigation = obj.boolean("seamlessChapterNavigation", defaults.seamlessChapterNavigation),
|
||||
chapterTurnDragMultiplier = obj.float("chapterTurnDragMultiplier") ?: defaults.chapterTurnDragMultiplier
|
||||
)
|
||||
|
|
@ -462,6 +537,29 @@ private fun JsonElement.asReaderHighlightPaletteOrNull(): ReaderHighlightPalette
|
|||
return ReaderHighlightPalette(colors = colors).sanitized()
|
||||
}
|
||||
|
||||
private fun JsonElement.asSharedPdfHighlighterPaletteOrNull(): SharedPdfHighlighterPalette? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return SharedPdfHighlighterPalette(colors = obj.intArray("colorsArgb")).sanitized()
|
||||
}
|
||||
|
||||
private fun JsonElement.asSharedPdfReaderViewportOrNull(): SharedPdfReaderViewport? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val defaults = SharedPdfReaderViewport()
|
||||
return SharedPdfReaderViewport(
|
||||
pageIndex = obj.int("pageIndex") ?: defaults.pageIndex,
|
||||
displayMode = obj.string("displayMode")
|
||||
?.let { runCatching { PdfDisplayMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.displayMode,
|
||||
zoom = obj.float("zoom") ?: defaults.zoom,
|
||||
horizontalScrollOffset = obj.int("horizontalScrollOffset") ?: defaults.horizontalScrollOffset,
|
||||
paginatedVerticalScrollOffset = obj.int("paginatedVerticalScrollOffset")
|
||||
?: defaults.paginatedVerticalScrollOffset,
|
||||
verticalFirstPageIndex = obj.int("verticalFirstPageIndex") ?: defaults.verticalFirstPageIndex,
|
||||
verticalFirstPageScrollOffset = obj.int("verticalFirstPageScrollOffset")
|
||||
?: defaults.verticalFirstPageScrollOffset
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderBookmarkOrNull(): ReaderBookmark? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val pageIndex = obj.int("pageIndex") ?: return null
|
||||
|
|
@ -551,6 +649,9 @@ private fun ReaderSettings?.asJson(): JsonElement {
|
|||
"systemUiMode" to JsonPrimitive(settings.systemUiMode.name),
|
||||
"pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name),
|
||||
"pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name),
|
||||
"pageSpreadMode" to JsonPrimitive(settings.pageSpreadMode.name),
|
||||
"pdfVerticalPageGapVisible" to JsonPrimitive(settings.pdfVerticalPageGapVisible),
|
||||
"pdfPageNumberOverlayVisible" to JsonPrimitive(settings.pdfPageNumberOverlayVisible),
|
||||
"seamlessChapterNavigation" to JsonPrimitive(settings.seamlessChapterNavigation),
|
||||
"chapterTurnDragMultiplier" to JsonPrimitive(settings.chapterTurnDragMultiplier)
|
||||
)
|
||||
|
|
@ -576,6 +677,29 @@ private fun ReaderHighlightPalette.toJsonObject(): JsonObject {
|
|||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfHighlighterPalette.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"colorsArgb" to sanitized().colors.asIntJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderViewport?.asJson(): JsonElement {
|
||||
val viewport = this ?: return JsonNull
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"pageIndex" to JsonPrimitive(viewport.pageIndex),
|
||||
"displayMode" to JsonPrimitive(viewport.displayMode.name),
|
||||
"zoom" to JsonPrimitive(viewport.zoom),
|
||||
"horizontalScrollOffset" to JsonPrimitive(viewport.horizontalScrollOffset),
|
||||
"paginatedVerticalScrollOffset" to JsonPrimitive(viewport.paginatedVerticalScrollOffset),
|
||||
"verticalFirstPageIndex" to JsonPrimitive(viewport.verticalFirstPageIndex),
|
||||
"verticalFirstPageScrollOffset" to JsonPrimitive(viewport.verticalFirstPageScrollOffset)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
|
|
@ -616,3 +740,7 @@ private fun ReaderLocator.toJsonObject(): JsonObject {
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderLocator?.asJson(): JsonElement {
|
||||
return this?.toJsonObject() ?: JsonNull
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ fun LibraryState.reduce(action: LibraryAction): LibraryState {
|
|||
}
|
||||
copy(selectedBookIds = selected)
|
||||
}
|
||||
is LibraryAction.BookSelectionReplaced -> copy(selectedBookIds = action.bookIds)
|
||||
LibraryAction.SelectionCleared -> copy(selectedBookIds = emptySet())
|
||||
is LibraryAction.ShelfSelectionToggled -> this
|
||||
LibraryAction.ShelfSelectionCleared -> this
|
||||
|
|
@ -37,6 +38,7 @@ fun SharedReaderScreenState.reduce(action: LibraryAction): SharedReaderScreenSta
|
|||
}
|
||||
copy(selectedBookIds = selected)
|
||||
}
|
||||
is LibraryAction.BookSelectionReplaced -> copy(selectedBookIds = action.bookIds)
|
||||
LibraryAction.SelectionCleared -> copy(selectedBookIds = emptySet())
|
||||
is LibraryAction.ShelfSelectionToggled -> {
|
||||
val selected = if (action.shelfId in selectedShelfIds) {
|
||||
|
|
@ -52,6 +54,18 @@ fun SharedReaderScreenState.reduce(action: LibraryAction): SharedReaderScreenSta
|
|||
}
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.replaceBookSelectionWithVisibleBooks(
|
||||
visibleBooks: Collection<BookItem>
|
||||
): SharedReaderScreenState {
|
||||
val visibleIds = visibleBooks.mapTo(linkedSetOf()) { it.id }
|
||||
val action = if (visibleIds.isNotEmpty() && selectedBookIds.containsAll(visibleIds)) {
|
||||
LibraryAction.SelectionCleared
|
||||
} else {
|
||||
LibraryAction.BookSelectionReplaced(visibleIds)
|
||||
}
|
||||
return reduce(action)
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
||||
return when (action) {
|
||||
is AppAction.BannerShown -> copy(bannerMessage = action.message)
|
||||
|
|
@ -115,6 +129,12 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
|||
pinnedLibraryBookIds + action.bookId
|
||||
}
|
||||
)
|
||||
is AppAction.ReaderDefaultSettingsChanged -> copy(
|
||||
readerDefaultSettings = action.settings
|
||||
)
|
||||
is AppAction.PdfReaderDefaultSettingsChanged -> copy(
|
||||
pdfReaderDefaultSettings = action.settings
|
||||
)
|
||||
is AppAction.ReaderToolbarPreferencesChanged -> copy(
|
||||
readerToolbarPreferences = action.preferences.sanitized()
|
||||
)
|
||||
|
|
@ -130,6 +150,9 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
|||
is AppAction.ReaderHighlightPaletteChanged -> copy(
|
||||
readerHighlightPalette = action.palette.sanitized()
|
||||
)
|
||||
is AppAction.PdfHighlighterPaletteChanged -> copy(
|
||||
pdfHighlighterPalette = action.palette.sanitized()
|
||||
)
|
||||
is AppAction.ReaderTtsReplacementPreferencesChanged -> copy(
|
||||
readerTtsReplacementPreferences = action.preferences
|
||||
)
|
||||
|
|
@ -145,8 +168,18 @@ fun ReaderSessionState.reduce(action: ReaderAction, readerEngine: ReaderEngine):
|
|||
is ReaderAction.GoToProgress -> readerEngine.goToProgress(this, action.progress)
|
||||
is ReaderAction.GoToChapter -> readerEngine.goToChapter(this, action.chapterIndex)
|
||||
is ReaderAction.GoToLocator -> readerEngine.goToLocator(this, action.locator)
|
||||
is ReaderAction.JumpToPage -> readerEngine.jumpToPage(this, action.pageIndex)
|
||||
is ReaderAction.JumpToPageNumber -> readerEngine.jumpToPageNumber(this, action.pageNumber)
|
||||
is ReaderAction.JumpToChapter -> readerEngine.jumpToChapter(this, action.chapterIndex)
|
||||
is ReaderAction.JumpToLocator -> readerEngine.jumpToLocator(this, action.locator)
|
||||
is ReaderAction.VisiblePageChanged -> readerEngine.syncVisiblePage(this, action.pageIndex, action.locator)
|
||||
is ReaderAction.GoToSearchResult -> readerEngine.goToSearchResult(this, action.resultIndex)
|
||||
is ReaderAction.JumpToSearchResult -> readerEngine.jumpToSearchResult(this, action.resultIndex)
|
||||
ReaderAction.JumpToNextSearchResult -> readerEngine.jumpToNextSearchResult(this)
|
||||
ReaderAction.JumpToPreviousSearchResult -> readerEngine.jumpToPreviousSearchResult(this)
|
||||
ReaderAction.JumpBack -> readerEngine.jumpBack(this)
|
||||
ReaderAction.JumpForward -> readerEngine.jumpForward(this)
|
||||
ReaderAction.JumpHistoryCleared -> readerEngine.clearJumpHistory(this)
|
||||
is ReaderAction.SearchChanged -> readerEngine.search(this, action.query)
|
||||
ReaderAction.SearchOpened -> readerEngine.openSearch(this)
|
||||
ReaderAction.SearchClosed -> readerEngine.closeSearch(this)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ data class OpdsAcquisition(
|
|||
get() = when {
|
||||
mimeType.contains("epub", ignoreCase = true) -> "EPUB"
|
||||
mimeType.contains("pdf", ignoreCase = true) -> "PDF"
|
||||
mimeType.contains("presentationml.presentation", ignoreCase = true) ||
|
||||
mimeType.contains("pptx", ignoreCase = true) -> "PPTX"
|
||||
mimeType.contains("markdown", ignoreCase = true) ||
|
||||
mimeType.contains("text/x-markdown", ignoreCase = true) -> "MD"
|
||||
mimeType.contains("html", ignoreCase = true) ||
|
||||
|
|
@ -58,6 +60,7 @@ data class OpdsAcquisition(
|
|||
get() = when (formatName) {
|
||||
"EPUB" -> 5
|
||||
"PDF" -> 4
|
||||
"PPTX" -> 4
|
||||
"MOBI" -> 3
|
||||
"FB2", "MD", "HTML" -> 2
|
||||
"CBZ", "CBR", "CB7" -> 1
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ object SharedOpdsDownloadNamer {
|
|||
return when (acquisition.formatName) {
|
||||
"EPUB" -> ".epub"
|
||||
"PDF" -> ".pdf"
|
||||
"PPTX" -> ".pptx"
|
||||
"MOBI" -> ".mobi"
|
||||
"FB2" -> ".fb2"
|
||||
"CBZ" -> ".cbz"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ enum class PdfAnnotationKind {
|
|||
}
|
||||
|
||||
enum class PdfInkTool {
|
||||
NONE,
|
||||
PEN,
|
||||
HIGHLIGHTER,
|
||||
HIGHLIGHTER_ROUND,
|
||||
|
|
@ -52,6 +53,7 @@ data class SharedPdfAnnotation(
|
|||
val backgroundArgb: Int = 0x00FFFFFF,
|
||||
val strokeWidth: Float = 2f,
|
||||
val fontSize: Float = 16f,
|
||||
val pageRelativeFontSize: Float? = null,
|
||||
val isBold: Boolean = false,
|
||||
val isItalic: Boolean = false,
|
||||
val isUnderline: Boolean = false,
|
||||
|
|
@ -159,6 +161,7 @@ object SharedPdfAnnotationDefaults {
|
|||
|
||||
fun configFor(tool: PdfInkTool): PdfToolConfig {
|
||||
return when (tool) {
|
||||
PdfInkTool.NONE -> PdfToolConfig(0x00000000, 0.008f)
|
||||
PdfInkTool.PEN -> PdfToolConfig(0xFFFF0000.toInt(), 0.008f)
|
||||
PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF0000FF.toInt(), 0.008f)
|
||||
PdfInkTool.PENCIL -> PdfToolConfig(0xFF444444.toInt(), 0.008f)
|
||||
|
|
@ -170,6 +173,76 @@ object SharedPdfAnnotationDefaults {
|
|||
}
|
||||
}
|
||||
|
||||
data class SharedPdfHighlighterPalette(
|
||||
val colors: List<Int> = defaultColors
|
||||
) {
|
||||
fun sanitized(): SharedPdfHighlighterPalette {
|
||||
val normalized = colors
|
||||
.filter { it != 0 }
|
||||
.map { it.withPdfHighlighterAlpha() }
|
||||
.take(MaxColors)
|
||||
val filled = if (normalized.isEmpty()) {
|
||||
defaultColors
|
||||
} else {
|
||||
normalized + defaultColors.drop(normalized.size)
|
||||
}
|
||||
return copy(colors = filled.take(MaxColors))
|
||||
}
|
||||
|
||||
fun withColorAt(slotIndex: Int, colorArgb: Int): SharedPdfHighlighterPalette {
|
||||
val nextColors = sanitized().colors.toMutableList()
|
||||
if (slotIndex !in nextColors.indices) return sanitized()
|
||||
nextColors[slotIndex] = colorArgb.withPdfHighlighterAlpha()
|
||||
return copy(colors = nextColors).sanitized()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DefaultAlpha: Int = 0x8C
|
||||
const val MaxColors: Int = 5
|
||||
val defaultColors: List<Int>
|
||||
get() = SharedPdfAnnotationDefaults.highlighterPalette.map { it.withPdfHighlighterAlpha() }
|
||||
}
|
||||
}
|
||||
|
||||
object SharedPdfAndroidHighlightColors {
|
||||
const val StoredAlpha: Int = 0x8C
|
||||
const val RenderAlpha: Float = 0.4f
|
||||
|
||||
val colorsByName: Map<String, Int> = mapOf(
|
||||
"YELLOW" to 0xFFFBC02D.toInt(),
|
||||
"GREEN" to 0xFF388E3C.toInt(),
|
||||
"BLUE" to 0xFF1976D2.toInt(),
|
||||
"RED" to 0xFFD32F2F.toInt()
|
||||
)
|
||||
|
||||
val palette: List<Int>
|
||||
get() = colorsByName.keys.map(::argbForName)
|
||||
|
||||
fun argbForName(name: String): Int {
|
||||
val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("YELLOW")
|
||||
return (StoredAlpha shl 24) or (opaqueArgb and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
fun nearestName(argb: Int): String {
|
||||
val rgb = argb and 0x00FFFFFF
|
||||
return colorsByName.minByOrNull { (_, color) ->
|
||||
val candidate = color and 0x00FFFFFF
|
||||
val dr = ((rgb shr 16) and 0xFF) - ((candidate shr 16) and 0xFF)
|
||||
val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF)
|
||||
val db = (rgb and 0xFF) - (candidate and 0xFF)
|
||||
dr * dr + dg * dg + db * db
|
||||
}?.key ?: "YELLOW"
|
||||
}
|
||||
|
||||
fun nearestArgb(argb: Int): Int {
|
||||
return argbForName(nearestName(argb))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.withPdfHighlighterAlpha(): Int {
|
||||
return (SharedPdfHighlighterPalette.DefaultAlpha shl 24) or (this and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfAnnotationStore(
|
||||
val version: Int = 1,
|
||||
|
|
|
|||
|
|
@ -144,17 +144,45 @@ data class SharedPdfJumpHistory(
|
|||
}
|
||||
}
|
||||
|
||||
data class SharedPdfReaderViewport(
|
||||
val pageIndex: Int = 0,
|
||||
val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION,
|
||||
val zoom: Float = PdfZoomSpec().default,
|
||||
val horizontalScrollOffset: Int = 0,
|
||||
val paginatedVerticalScrollOffset: Int = 0,
|
||||
val verticalFirstPageIndex: Int = pageIndex,
|
||||
val verticalFirstPageScrollOffset: Int = 0
|
||||
) {
|
||||
fun sanitized(
|
||||
pageCount: Int,
|
||||
zoomSpec: PdfZoomSpec = PdfZoomSpec()
|
||||
): SharedPdfReaderViewport {
|
||||
val lastPageIndex = (pageCount.coerceAtLeast(0) - 1).coerceAtLeast(0)
|
||||
val safeZoom = if (zoom.isFinite() && zoom > 0f) zoom else zoomSpec.default
|
||||
return copy(
|
||||
pageIndex = pageIndex.coerceIn(0, lastPageIndex),
|
||||
zoom = zoomSpec.clamp(safeZoom),
|
||||
horizontalScrollOffset = horizontalScrollOffset.coerceAtLeast(0),
|
||||
paginatedVerticalScrollOffset = paginatedVerticalScrollOffset.coerceAtLeast(0),
|
||||
verticalFirstPageIndex = verticalFirstPageIndex.coerceIn(0, lastPageIndex),
|
||||
verticalFirstPageScrollOffset = verticalFirstPageScrollOffset.coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfReaderState(
|
||||
val pageIndex: Int = 0,
|
||||
val pageCount: Int = 0,
|
||||
val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION,
|
||||
val zoom: Float = PdfZoomSpec().default,
|
||||
val isSearchActive: Boolean = false,
|
||||
val showSearchResultsPanel: Boolean = true,
|
||||
val searchQuery: String = "",
|
||||
val activeSearchResultIndex: Int = -1,
|
||||
val searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
|
||||
val selectedTool: PdfInkTool = PdfInkTool.PEN,
|
||||
val selectedColorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb,
|
||||
val strokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth,
|
||||
val selectedTool: PdfInkTool = PdfInkTool.NONE,
|
||||
val selectedColorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE).colorArgb,
|
||||
val strokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE).strokeWidth,
|
||||
val isTextSelectionMode: Boolean = false,
|
||||
val bookmarks: List<SharedPdfBookmark> = emptyList(),
|
||||
val selectedAnnotationId: String? = null,
|
||||
|
|
@ -208,6 +236,9 @@ sealed interface SharedPdfReaderAction {
|
|||
data class ZoomChanged(val zoom: Float) : SharedPdfReaderAction
|
||||
data class ZoomBy(val delta: Float) : SharedPdfReaderAction
|
||||
data class SearchChanged(val query: String) : SharedPdfReaderAction
|
||||
data object SearchOpened : SharedPdfReaderAction
|
||||
data object SearchClosed : SharedPdfReaderAction
|
||||
data object SearchResultsPanelToggled : SharedPdfReaderAction
|
||||
data class SearchHighlightModeChanged(val mode: SearchHighlightMode) : SharedPdfReaderAction
|
||||
data object SearchHighlightModeToggled : SharedPdfReaderAction
|
||||
data class GoToSearchResult(
|
||||
|
|
@ -257,10 +288,26 @@ fun SharedPdfReaderState.reduce(
|
|||
)
|
||||
is SharedPdfReaderAction.ZoomChanged -> copy(zoom = zoomSpec.clamp(action.zoom))
|
||||
is SharedPdfReaderAction.ZoomBy -> copy(zoom = zoomSpec.clamp(zoom + action.delta))
|
||||
is SharedPdfReaderAction.SearchChanged -> copy(
|
||||
searchQuery = action.query,
|
||||
is SharedPdfReaderAction.SearchChanged -> {
|
||||
val normalized = action.query.trim()
|
||||
copy(
|
||||
isSearchActive = isSearchActive || normalized.isNotBlank(),
|
||||
showSearchResultsPanel = showSearchResultsPanel || normalized.isNotBlank(),
|
||||
searchQuery = action.query,
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
}
|
||||
SharedPdfReaderAction.SearchOpened -> copy(
|
||||
isSearchActive = true,
|
||||
showSearchResultsPanel = true
|
||||
)
|
||||
SharedPdfReaderAction.SearchClosed -> copy(
|
||||
isSearchActive = false,
|
||||
showSearchResultsPanel = true,
|
||||
searchQuery = "",
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
SharedPdfReaderAction.SearchResultsPanelToggled -> copy(showSearchResultsPanel = !showSearchResultsPanel)
|
||||
is SharedPdfReaderAction.SearchHighlightModeChanged -> copy(searchHighlightMode = action.mode)
|
||||
SharedPdfReaderAction.SearchHighlightModeToggled -> copy(
|
||||
searchHighlightMode = when (searchHighlightMode) {
|
||||
|
|
@ -284,12 +331,25 @@ fun SharedPdfReaderState.reduce(
|
|||
copy(
|
||||
selectedTool = action.tool,
|
||||
selectedColorArgb = config.colorArgb,
|
||||
strokeWidth = config.strokeWidth
|
||||
strokeWidth = config.strokeWidth,
|
||||
isTextSelectionMode = false
|
||||
)
|
||||
}
|
||||
is SharedPdfReaderAction.ColorSelected -> copy(selectedColorArgb = action.colorArgb)
|
||||
is SharedPdfReaderAction.StrokeWidthChanged -> copy(strokeWidth = action.strokeWidth.coerceAtLeast(0.0001f))
|
||||
is SharedPdfReaderAction.TextSelectionModeChanged -> copy(isTextSelectionMode = action.enabled)
|
||||
is SharedPdfReaderAction.TextSelectionModeChanged -> {
|
||||
if (action.enabled) {
|
||||
val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE)
|
||||
copy(
|
||||
isTextSelectionMode = true,
|
||||
selectedTool = PdfInkTool.NONE,
|
||||
selectedColorArgb = config.colorArgb,
|
||||
strokeWidth = config.strokeWidth
|
||||
)
|
||||
} else {
|
||||
copy(isTextSelectionMode = false)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.BookmarksLoaded -> copy(bookmarks = action.bookmarks.normalizedBookmarks(lastPageIndex))
|
||||
is SharedPdfReaderAction.BookmarkToggled -> {
|
||||
val page = action.pageIndex.coerceIn(0, lastPageIndex)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.floor
|
||||
|
||||
data class PdfVisiblePageLayout(
|
||||
val pageIndex: Int,
|
||||
val top: Float,
|
||||
|
|
@ -28,3 +32,73 @@ fun mostVisiblePdfPageIndex(
|
|||
?.pageIndex
|
||||
?: fallbackPageIndex
|
||||
}
|
||||
|
||||
fun pdfVerticalPageGapDp(
|
||||
isPageGapVisible: Boolean,
|
||||
defaultGap: Dp
|
||||
): Dp = if (isPageGapVisible) defaultGap else 0.dp
|
||||
|
||||
data class PdfVerticalPagePlacement(
|
||||
val pageIndex: Int,
|
||||
val topPx: Int,
|
||||
val widthPx: Int,
|
||||
val heightPx: Int
|
||||
) {
|
||||
val bottomPx: Int
|
||||
get() = topPx + heightPx
|
||||
}
|
||||
|
||||
data class PdfVerticalPageLayoutResult(
|
||||
val pages: List<PdfVerticalPagePlacement>,
|
||||
val totalHeightPx: Int
|
||||
)
|
||||
|
||||
fun calculatePdfVerticalPageLayoutPx(
|
||||
pageAspectRatios: List<Float>,
|
||||
viewportWidthPx: Int,
|
||||
viewportHeightPx: Int,
|
||||
pageGapPx: Int
|
||||
): PdfVerticalPageLayoutResult {
|
||||
val safeWidthPx = viewportWidthPx.coerceAtLeast(0)
|
||||
if (pageAspectRatios.isEmpty() || safeWidthPx == 0) {
|
||||
return PdfVerticalPageLayoutResult(emptyList(), 0)
|
||||
}
|
||||
|
||||
val safeGapPx = pageGapPx.coerceAtLeast(0)
|
||||
val safeHeightPx = viewportHeightPx.coerceAtLeast(0)
|
||||
|
||||
fun pageHeightPx(ratio: Float): Int {
|
||||
val safeRatio = if (ratio <= 0f) 1f else ratio
|
||||
return floor(safeWidthPx.toDouble() / safeRatio.toDouble())
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
var currentTopPx = 0
|
||||
if (pageAspectRatios.size == 1) {
|
||||
val singlePageHeightPx = pageHeightPx(pageAspectRatios[0])
|
||||
if (singlePageHeightPx < safeHeightPx) {
|
||||
currentTopPx = (safeHeightPx - singlePageHeightPx) / 2
|
||||
}
|
||||
}
|
||||
|
||||
val pages = pageAspectRatios.mapIndexed { index, ratio ->
|
||||
val heightPx = pageHeightPx(ratio)
|
||||
val placement = PdfVerticalPagePlacement(
|
||||
pageIndex = index,
|
||||
topPx = currentTopPx,
|
||||
widthPx = safeWidthPx,
|
||||
heightPx = heightPx
|
||||
)
|
||||
currentTopPx += heightPx
|
||||
if (index < pageAspectRatios.lastIndex) {
|
||||
currentTopPx += safeGapPx
|
||||
}
|
||||
placement
|
||||
}
|
||||
|
||||
return PdfVerticalPageLayoutResult(
|
||||
pages = pages,
|
||||
totalHeightPx = pages.lastOrNull()?.bottomPx ?: 0
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import kotlinx.serialization.json.jsonArray
|
|||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlin.math.pow
|
||||
|
||||
object SharedPdfAnnotationSidecarCodec {
|
||||
const val KEY_PDF_ANNOTATIONS = "pdfAnnotations"
|
||||
|
|
@ -24,8 +23,6 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
const val KEY_LEGACY_TEXT_BOXES = "textBoxes"
|
||||
const val KEY_LEGACY_HIGHLIGHTS = "highlights"
|
||||
|
||||
private const val LEGACY_TEXT_BOX_FONT_REFERENCE_DP = 500f
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
|
|
@ -56,7 +53,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
fun withCanonicalAnnotations(data: JsonObject): JsonObject {
|
||||
if (data[KEY_PDF_ANNOTATIONS] != null) return data
|
||||
val annotations = annotationsFromData(data)
|
||||
if (annotations.isEmpty()) return data
|
||||
if (annotations.isEmpty() && !data.hasLegacyAndroidAnnotationPayload()) return data
|
||||
return JsonObject(data + (KEY_PDF_ANNOTATIONS to encodeAnnotationsElement(annotations)))
|
||||
}
|
||||
|
||||
|
|
@ -69,16 +66,17 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
annotations: List<SharedPdfAnnotation>,
|
||||
existingData: JsonObject = JsonObject(emptyMap())
|
||||
): JsonObject {
|
||||
if (annotations.isEmpty()) return existingData
|
||||
|
||||
val next = existingData.toMutableMap()
|
||||
if (!existingData[KEY_LEGACY_INK].isLegacyAndroidInkArray()) {
|
||||
val replaceLegacyFromCanonical = existingData[KEY_PDF_ANNOTATIONS] != null
|
||||
if (annotations.isEmpty() && !replaceLegacyFromCanonical) return existingData
|
||||
|
||||
if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_INK].isLegacyAndroidInkArray()) {
|
||||
next[KEY_LEGACY_INK] = annotations.toLegacyAndroidInkArray()
|
||||
}
|
||||
if (!existingData[KEY_LEGACY_TEXT_BOXES].isJsonArray()) {
|
||||
if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_TEXT_BOXES].isJsonArray()) {
|
||||
next[KEY_LEGACY_TEXT_BOXES] = annotations.toLegacyAndroidTextBoxArray()
|
||||
}
|
||||
if (!existingData[KEY_LEGACY_HIGHLIGHTS].isJsonArray()) {
|
||||
if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_HIGHLIGHTS].isJsonArray()) {
|
||||
next[KEY_LEGACY_HIGHLIGHTS] = annotations.toLegacyAndroidHighlightArray()
|
||||
}
|
||||
return JsonObject(next)
|
||||
|
|
@ -87,7 +85,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
fun legacyAndroidDataJsonFromCanonical(rawDataJson: String): String {
|
||||
val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson
|
||||
val annotations = annotationsFromData(data)
|
||||
if (annotations.isEmpty()) return rawDataJson
|
||||
if (annotations.isEmpty() && data[KEY_PDF_ANNOTATIONS] == null) return rawDataJson
|
||||
return json.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
legacyAndroidDataFromAnnotations(annotations, data)
|
||||
|
|
@ -128,6 +126,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
kind = PdfAnnotationKind.INK,
|
||||
tool = tool.toPdfInkTool(),
|
||||
points = points,
|
||||
note = obj.string("note"),
|
||||
colorArgb = obj.int("color") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb,
|
||||
strokeWidth = obj.float("strokeWidth") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth,
|
||||
createdAt = points.firstOrNull()?.timestamp ?: 0L
|
||||
|
|
@ -151,7 +150,8 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
colorArgb = obj.int("color") ?: 0xFF000000.toInt(),
|
||||
backgroundArgb = obj.int("backgroundColor") ?: 0x00000000,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = rawFontSize.legacyTextBoxFontSizeToShared(),
|
||||
fontSize = SharedPdfTextAnnotationDefaults.pageRelativeFontSizeToDisplay(rawFontSize),
|
||||
pageRelativeFontSize = SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(rawFontSize),
|
||||
isBold = obj.boolean("isBold") ?: false,
|
||||
isItalic = obj.boolean("isItalic") ?: false,
|
||||
isUnderline = obj.boolean("isUnderline") ?: false,
|
||||
|
|
@ -189,7 +189,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
boundsList = boundsList,
|
||||
text = obj.string("text").orEmpty(),
|
||||
note = obj.string("note"),
|
||||
colorArgb = colorName.toSharedHighlightArgb(),
|
||||
colorArgb = SharedPdfAndroidHighlightColors.argbForName(colorName),
|
||||
rangeStartIndex = rangeStart,
|
||||
rangeEndIndex = inclusiveRangeEnd
|
||||
)
|
||||
|
|
@ -208,6 +208,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
put("inkType", JsonPrimitive(annotation.tool.name))
|
||||
put("color", JsonPrimitive(annotation.colorArgb))
|
||||
put("strokeWidth", JsonPrimitive(annotation.strokeWidth.toDouble()))
|
||||
annotation.note?.takeIf { it.isNotBlank() }?.let { put("note", JsonPrimitive(it)) }
|
||||
put(
|
||||
"points",
|
||||
JsonArray(
|
||||
|
|
@ -240,7 +241,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
put("text", JsonPrimitive(annotation.text))
|
||||
put("color", JsonPrimitive(annotation.colorArgb))
|
||||
put("backgroundColor", JsonPrimitive(annotation.backgroundArgb))
|
||||
put("fontSize", JsonPrimitive(annotation.fontSize.sharedFontSizeToLegacyTextBox().toDouble()))
|
||||
put("fontSize", JsonPrimitive(annotation.sharedPdfTextPageRelativeFontSize().toDouble()))
|
||||
put("isBold", JsonPrimitive(annotation.isBold))
|
||||
put("isItalic", JsonPrimitive(annotation.isItalic))
|
||||
put("isUnderline", JsonPrimitive(annotation.isUnderline))
|
||||
|
|
@ -262,7 +263,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
buildMap {
|
||||
put("id", JsonPrimitive(annotation.id))
|
||||
put("pageIndex", JsonPrimitive(annotation.pageIndex))
|
||||
put("color", JsonPrimitive(annotation.colorArgb.toLegacyHighlightColorName()))
|
||||
put("color", JsonPrimitive(SharedPdfAndroidHighlightColors.nearestName(annotation.colorArgb)))
|
||||
put("text", JsonPrimitive(annotation.text))
|
||||
val rangeStart = annotation.rangeStartIndex ?: 0
|
||||
val rangeEnd = annotation.rangeEndIndex?.plus(1)?.coerceAtLeast(rangeStart) ?: rangeStart
|
||||
|
|
@ -305,6 +306,12 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
|
||||
private fun JsonElement?.isJsonArray(): Boolean = this?.jsonArrayOrNull() != null
|
||||
|
||||
private fun JsonObject.hasLegacyAndroidAnnotationPayload(): Boolean {
|
||||
return this[KEY_LEGACY_INK] != null ||
|
||||
this[KEY_LEGACY_TEXT_BOXES] != null ||
|
||||
this[KEY_LEGACY_HIGHLIGHTS] != null
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonArray }.getOrNull()
|
||||
|
|
@ -378,38 +385,4 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
return runCatching { PdfInkTool.valueOf(this) }.getOrDefault(PdfInkTool.PEN)
|
||||
}
|
||||
|
||||
private fun Float.legacyTextBoxFontSizeToShared(): Float {
|
||||
return if (this in 0f..1f) {
|
||||
(this * LEGACY_TEXT_BOX_FONT_REFERENCE_DP).coerceIn(8f, 48f)
|
||||
} else {
|
||||
coerceIn(8f, 96f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.sharedFontSizeToLegacyTextBox(): Float {
|
||||
return (this / LEGACY_TEXT_BOX_FONT_REFERENCE_DP).coerceIn(0.012f, 0.12f)
|
||||
}
|
||||
|
||||
private fun String.toSharedHighlightArgb(): Int {
|
||||
val opaqueArgb = legacyHighlightColors[uppercase()] ?: legacyHighlightColors.getValue("YELLOW")
|
||||
return 0x8C000000.toInt() or (opaqueArgb and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
private fun Int.toLegacyHighlightColorName(): String {
|
||||
val rgb = this and 0x00FFFFFF
|
||||
return legacyHighlightColors.minByOrNull { (_, color) ->
|
||||
val candidate = color and 0x00FFFFFF
|
||||
val dr = ((rgb shr 16) and 0xFF) - ((candidate shr 16) and 0xFF)
|
||||
val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF)
|
||||
val db = (rgb and 0xFF) - (candidate and 0xFF)
|
||||
dr.toDouble().pow(2) + dg.toDouble().pow(2) + db.toDouble().pow(2)
|
||||
}?.key ?: "YELLOW"
|
||||
}
|
||||
|
||||
private val legacyHighlightColors = mapOf(
|
||||
"YELLOW" to 0xFFFBC02D.toInt(),
|
||||
"GREEN" to 0xFF388E3C.toInt(),
|
||||
"BLUE" to 0xFF1976D2.toInt(),
|
||||
"RED" to 0xFFD32F2F.toInt()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ object SharedPdfInkRenderer {
|
|||
canvasSize: IntSize
|
||||
): SharedPdfInkRenderData? {
|
||||
if (annotation.kind != PdfAnnotationKind.INK || annotation.points.isEmpty()) return null
|
||||
if (annotation.tool == PdfInkTool.NONE) return null
|
||||
val widthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val heightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val strokeWidthPx = effectiveStrokeWidthPx(annotation.strokeWidth, widthPx)
|
||||
|
|
@ -87,6 +88,7 @@ object SharedPdfInkRenderer {
|
|||
}
|
||||
|
||||
return when (annotation.tool) {
|
||||
PdfInkTool.NONE -> null
|
||||
PdfInkTool.PENCIL -> {
|
||||
val path = annotation.points.toSmoothPath(widthPx, heightPx)
|
||||
val velocityAlpha = annotation.points.velocityAlpha(widthPx, heightPx)
|
||||
|
|
@ -335,6 +337,7 @@ object SharedPdfInkRenderer {
|
|||
|
||||
fun PdfInkTool.sharedPdfStrokeWidthRange(): ClosedFloatingPointRange<Float> {
|
||||
return when (this) {
|
||||
PdfInkTool.NONE -> 0.001f..0.015f
|
||||
PdfInkTool.HIGHLIGHTER,
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> 0.01f..0.06f
|
||||
PdfInkTool.ERASER -> 0.002f..0.10f
|
||||
|
|
|
|||
|
|
@ -45,21 +45,17 @@ import kotlinx.serialization.json.intOrNull
|
|||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlin.math.abs
|
||||
|
||||
const val SHARED_PDF_PAGE_BREAK_CHAR: Char = '\u000C'
|
||||
|
||||
private const val SHARED_PDF_ZWSP = "\u200B"
|
||||
private const val SHARED_PDF_RICH_FONT_PATH_TAG = "pdf-rich-font-path"
|
||||
|
||||
const val SHARED_PDF_RICH_TEXT_LOG_TAG: String = "PdfRichTextTrace"
|
||||
|
||||
object SharedPdfRichTextLog {
|
||||
var enabled: Boolean = true
|
||||
|
||||
fun d(message: String) {
|
||||
if (enabled) {
|
||||
println("$SHARED_PDF_RICH_TEXT_LOG_TAG $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -621,10 +617,26 @@ class SharedPdfRichTextController(
|
|||
|
||||
fun updateLayoutConfig(width: Float, height: Float, density: Density, measurer: TextMeasurer) {
|
||||
if (lastPageWidth != width || lastPageHeight != height || lastDensity != density || lastTextMeasurer != measurer) {
|
||||
val previousPageHeight = lastPageHeight
|
||||
val fontScale = if (previousPageHeight > 0f && height > 0f) {
|
||||
height / previousPageHeight
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
val shouldScaleFonts = abs(fontScale - 1f) > 0.001f
|
||||
SharedPdfRichTextLog.d(
|
||||
"controller.layoutConfig width=${width.richLogFloat()} height=${height.richLogFloat()} " +
|
||||
"density=${density.density.richLogFloat()} old=${lastPageWidth.richLogFloat()}x${lastPageHeight.richLogFloat()}"
|
||||
"density=${density.density.richLogFloat()} old=${lastPageWidth.richLogFloat()}x${previousPageHeight.richLogFloat()} " +
|
||||
"fontScale=${fontScale.richLogFloat()}"
|
||||
)
|
||||
if (shouldScaleFonts) {
|
||||
saveJob?.cancel()
|
||||
globalTextFieldValue = globalTextFieldValue.withScaledSharedPdfRichFontSizes(fontScale)
|
||||
localTextFieldValue = localTextFieldValue.withScaledSharedPdfRichFontSizes(fontScale)
|
||||
if (globalTextFieldValue.text.isNotEmpty()) {
|
||||
debouncedSave(globalTextFieldValue)
|
||||
}
|
||||
}
|
||||
lastPageWidth = width
|
||||
lastPageHeight = height
|
||||
lastDensity = density
|
||||
|
|
@ -1534,6 +1546,45 @@ fun SharedPdfTextStyleConfig.toSharedPdfRichSpanStyle(): SpanStyle {
|
|||
)
|
||||
}
|
||||
|
||||
internal fun AnnotatedString.withScaledSharedPdfRichFontSizes(scale: Float): AnnotatedString {
|
||||
if (!scale.isFinite() || scale <= 0f || abs(scale - 1f) <= 0.001f) return this
|
||||
if (spanStyles.none { it.item.fontSize.isSp }) return this
|
||||
|
||||
val builder = AnnotatedString.Builder(text)
|
||||
spanStyles.forEach { range ->
|
||||
val style = range.item
|
||||
builder.addStyle(
|
||||
style = if (style.fontSize.isSp) {
|
||||
style.copy(fontSize = (style.fontSize.value * scale).sp)
|
||||
} else {
|
||||
style
|
||||
},
|
||||
start = range.start,
|
||||
end = range.end
|
||||
)
|
||||
}
|
||||
paragraphStyles.forEach { range ->
|
||||
builder.addStyle(range.item, range.start, range.end)
|
||||
}
|
||||
getStringAnnotations(
|
||||
tag = SHARED_PDF_RICH_FONT_PATH_TAG,
|
||||
start = 0,
|
||||
end = length
|
||||
).forEach { annotation ->
|
||||
builder.addStringAnnotation(
|
||||
tag = annotation.tag,
|
||||
annotation = annotation.item,
|
||||
start = annotation.start,
|
||||
end = annotation.end
|
||||
)
|
||||
}
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
private fun TextFieldValue.withScaledSharedPdfRichFontSizes(scale: Float): TextFieldValue {
|
||||
return copy(annotatedString = annotatedString.withScaledSharedPdfRichFontSizes(scale))
|
||||
}
|
||||
|
||||
fun SharedPdfRichTextController.currentSharedPdfTextStyleConfig(): SharedPdfTextStyleConfig {
|
||||
val decoration = currentStyle.textDecoration ?: TextDecoration.None
|
||||
return SharedPdfTextStyleConfig(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ data class SharedPdfTextStyleConfig(
|
|||
val colorArgb: Int = 0xFF000000.toInt(),
|
||||
val backgroundColorArgb: Int = 0x00000000,
|
||||
val fontSize: Float = 16f,
|
||||
val pageRelativeFontSize: Float? = null,
|
||||
val isBold: Boolean = false,
|
||||
val isItalic: Boolean = false,
|
||||
val isUnderline: Boolean = false,
|
||||
|
|
@ -46,6 +47,10 @@ data class SharedPdfTextDraft(
|
|||
)
|
||||
|
||||
object SharedPdfTextAnnotationDefaults {
|
||||
private const val AndroidTextBoxFontReferencePx = 500f
|
||||
private const val MinPageRelativeFontSize = 0.012f
|
||||
private const val MaxPageRelativeFontSize = 0.12f
|
||||
|
||||
val fontSizes: List<Float> = listOf(12f, 14f, 16f, 18f, 20f, 24f, 30f)
|
||||
|
||||
val fontPresets: List<SharedPdfTextFontPreset> = listOf(
|
||||
|
|
@ -97,6 +102,7 @@ object SharedPdfTextAnnotationDefaults {
|
|||
backgroundArgb = style.backgroundColorArgb,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = style.fontSize,
|
||||
pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(),
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
|
|
@ -133,9 +139,10 @@ object SharedPdfTextAnnotationDefaults {
|
|||
): PdfPageBounds {
|
||||
val widthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val heightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val widthNorm = estimateWidthNorm(text, style, widthPx).coerceIn(0.18f, 0.62f)
|
||||
val lineCount = estimateLineCount(text, style.fontSize, widthPx * widthNorm)
|
||||
val heightNorm = (((style.fontSize * 1.35f * lineCount) + 14f) / heightPx).coerceIn(0.04f, 0.36f)
|
||||
val fontSizePx = style.sharedPdfTextFontSizePx(canvasSize)
|
||||
val widthNorm = estimateWidthNorm(text, fontSizePx, widthPx).coerceIn(0.18f, 0.62f)
|
||||
val lineCount = estimateLineCount(text, fontSizePx, widthPx * widthNorm)
|
||||
val heightNorm = (((fontSizePx * 1.35f * lineCount) + 14f) / heightPx).coerceIn(0.04f, 0.36f)
|
||||
val left = anchor.x.coerceIn(0f, 1f - widthNorm)
|
||||
val top = anchor.y.coerceIn(0f, 1f - heightNorm)
|
||||
return PdfPageBounds(
|
||||
|
|
@ -158,13 +165,34 @@ object SharedPdfTextAnnotationDefaults {
|
|||
|
||||
private fun estimateWidthNorm(
|
||||
text: String,
|
||||
style: SharedPdfTextStyleConfig,
|
||||
fontSizePx: Float,
|
||||
pageWidthPx: Float
|
||||
): Float {
|
||||
val longestLine = text.lineSequence().maxOfOrNull { it.length } ?: 0
|
||||
val estimatedTextWidth = (longestLine.coerceAtLeast(12) * style.fontSize * 0.55f) + 18f
|
||||
val estimatedTextWidth = (longestLine.coerceAtLeast(12) * fontSizePx * 0.55f) + 18f
|
||||
return (estimatedTextWidth / pageWidthPx).coerceAtLeast(0.28f)
|
||||
}
|
||||
|
||||
internal fun displayFontSizeToPageRelative(fontSize: Float): Float {
|
||||
return (fontSize / AndroidTextBoxFontReferencePx)
|
||||
.coerceIn(MinPageRelativeFontSize, MaxPageRelativeFontSize)
|
||||
}
|
||||
|
||||
internal fun pageRelativeFontSizeToDisplay(fontSize: Float): Float {
|
||||
return if (fontSize in 0f..1f) {
|
||||
(fontSize * AndroidTextBoxFontReferencePx).coerceIn(8f, 48f)
|
||||
} else {
|
||||
fontSize.coerceIn(8f, 96f)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun legacyFontSizeToPageRelative(fontSize: Float): Float {
|
||||
return if (fontSize in 0f..1f) {
|
||||
fontSize.coerceIn(MinPageRelativeFontSize, MaxPageRelativeFontSize)
|
||||
} else {
|
||||
displayFontSizeToPageRelative(fontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.withText(
|
||||
|
|
@ -225,6 +253,7 @@ fun SharedPdfTextDraft.toAnnotation(): SharedPdfAnnotation {
|
|||
backgroundArgb = style.backgroundColorArgb,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = style.fontSize,
|
||||
pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(),
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
|
|
@ -316,6 +345,7 @@ fun SharedPdfAnnotation.sharedPdfTextStyle(): SharedPdfTextStyleConfig {
|
|||
colorArgb = colorArgb,
|
||||
backgroundColorArgb = backgroundArgb,
|
||||
fontSize = fontSize,
|
||||
pageRelativeFontSize = pageRelativeFontSize,
|
||||
isBold = isBold,
|
||||
isItalic = isItalic,
|
||||
isUnderline = isUnderline,
|
||||
|
|
@ -330,6 +360,7 @@ fun SharedPdfAnnotation.withSharedPdfTextStyle(style: SharedPdfTextStyleConfig):
|
|||
colorArgb = style.colorArgb,
|
||||
backgroundArgb = style.backgroundColorArgb,
|
||||
fontSize = style.fontSize,
|
||||
pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(),
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
|
|
@ -339,6 +370,35 @@ fun SharedPdfAnnotation.withSharedPdfTextStyle(style: SharedPdfTextStyleConfig):
|
|||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextStyleConfig.withSharedPdfTextFontSize(fontSize: Float): SharedPdfTextStyleConfig {
|
||||
return copy(
|
||||
fontSize = fontSize,
|
||||
pageRelativeFontSize = SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize)
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextStyleConfig.sharedPdfTextPageRelativeFontSize(): Float {
|
||||
return pageRelativeFontSize
|
||||
?.let { SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(it) }
|
||||
?: SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize)
|
||||
}
|
||||
|
||||
fun SharedPdfTextStyleConfig.sharedPdfTextFontSizePx(canvasSize: IntSize): Float {
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
return (sharedPdfTextPageRelativeFontSize() * pageHeightPx).coerceAtLeast(1f)
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.sharedPdfTextPageRelativeFontSize(): Float {
|
||||
return pageRelativeFontSize
|
||||
?.let { SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(it) }
|
||||
?: SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize)
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.sharedPdfTextFontSizePx(canvasSize: IntSize): Float {
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
return (sharedPdfTextPageRelativeFontSize() * pageHeightPx).coerceAtLeast(1f)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.coercedToPage(): PdfPageBounds {
|
||||
val coercedLeft = left.coerceIn(0f, 1f)
|
||||
val coercedTop = top.coerceIn(0f, 1f)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ data class ReaderSessionState(
|
|||
val searchResults: List<ReaderSearchResult> = emptyList(),
|
||||
val activeSearchResultIndex: Int = -1,
|
||||
val navigationLocator: ReaderLocator? = null,
|
||||
val navigationRequestId: Long = 0L
|
||||
val navigationRequestId: Long = 0L,
|
||||
val jumpHistory: ReaderJumpHistory = ReaderJumpHistory()
|
||||
) {
|
||||
val currentBookmark: ReaderBookmark?
|
||||
get() = navigationLocator
|
||||
|
|
@ -84,7 +85,7 @@ class ReaderEngine(
|
|||
private data class PaginationCacheKey(
|
||||
val bookId: String,
|
||||
val chapterSignature: Int,
|
||||
val settings: ReaderSettings
|
||||
val layoutSignature: ReaderLayoutSignature
|
||||
)
|
||||
|
||||
private val paginationCache = object : LinkedHashMap<PaginationCacheKey, List<ReaderPage>>(8, 0.75f, true) {
|
||||
|
|
@ -97,11 +98,16 @@ class ReaderEngine(
|
|||
book: SharedEpubBook,
|
||||
settings: ReaderSettings = ReaderSettings(),
|
||||
initialPageIndex: Int = 0,
|
||||
initialLocator: ReaderLocator? = null,
|
||||
bookmarks: List<ReaderBookmark> = emptyList(),
|
||||
highlights: List<UserHighlight> = emptyList()
|
||||
): ReaderSessionState {
|
||||
val pages = pagesFor(book, settings)
|
||||
val initialIndex = initialPageIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0))
|
||||
val requestedInitialIndex = initialLocator
|
||||
?.let { pages.findPageIndexForLocator(it) }
|
||||
?.takeIf { it >= 0 }
|
||||
?: initialPageIndex
|
||||
val initialIndex = ReaderSpreadLayout.normalizePageIndex(requestedInitialIndex, pages.size, settings)
|
||||
val reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = pages,
|
||||
|
|
@ -118,22 +124,30 @@ class ReaderEngine(
|
|||
.map { it.withNormalizedLocator() }
|
||||
.filter { (it.locator.chapterIndex ?: it.chapterIndex) in book.chapters.indices }
|
||||
.distinctBy { it.id },
|
||||
navigationLocator = reader.currentPage?.toLocator(book)
|
||||
navigationLocator = initialLocator
|
||||
?.normalizedForResolvedPage(book, pages, requestedInitialIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)))
|
||||
?: reader.currentPage?.toLocator(book)
|
||||
)
|
||||
}
|
||||
|
||||
fun next(state: ReaderSessionState): ReaderSessionState {
|
||||
if (!state.reader.canGoNext) return state
|
||||
return goToPage(state, state.reader.currentPageIndex + 1)
|
||||
return goToPage(
|
||||
state,
|
||||
ReaderSpreadLayout.nextPageIndex(state.reader.currentPageIndex, state.reader.pages.size, state.reader.settings)
|
||||
)
|
||||
}
|
||||
|
||||
fun previous(state: ReaderSessionState): ReaderSessionState {
|
||||
if (!state.reader.canGoPrevious) return state
|
||||
return goToPage(state, state.reader.currentPageIndex - 1)
|
||||
return goToPage(
|
||||
state,
|
||||
ReaderSpreadLayout.previousPageIndex(state.reader.currentPageIndex, state.reader.pages.size, state.reader.settings)
|
||||
)
|
||||
}
|
||||
|
||||
fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
|
||||
val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(target)
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = target),
|
||||
|
|
@ -159,31 +173,85 @@ class ReaderEngine(
|
|||
}
|
||||
|
||||
fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState {
|
||||
val pageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
val requestedPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex
|
||||
?.takeIf { it in state.reader.pages.indices }
|
||||
?: return state
|
||||
val pageIndex = ReaderSpreadLayout.normalizePageIndex(requestedPageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(pageIndex) ?: return state
|
||||
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
|
||||
val normalizedLocator = locator.copy(pageIndex = pageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
textQuote = locator.textQuote ?: page.text.preview(),
|
||||
cfi = locator.cfi ?: page.toDesktopCfi()
|
||||
val requestedPage = state.reader.pages.getOrNull(requestedPageIndex) ?: page
|
||||
val requestedChapter = state.reader.book.chapters.getOrNull(requestedPage.chapterIndex)
|
||||
val normalizedLocator = locator.copy(pageIndex = requestedPageIndex).withFallbacks(
|
||||
chapterIndex = requestedPage.chapterIndex,
|
||||
chapterId = requestedChapter?.id,
|
||||
href = requestedChapter?.baseHref,
|
||||
pageIndex = requestedPageIndex,
|
||||
startOffset = requestedPage.startOffset,
|
||||
endOffset = requestedPage.endOffset,
|
||||
textQuote = locator.textQuote ?: requestedPage.text.preview(),
|
||||
cfi = locator.cfi ?: requestedPage.toDesktopCfi()
|
||||
)
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = pageIndex),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex },
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == requestedPageIndex },
|
||||
navigationLocator = normalizedLocator,
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
|
||||
fun jumpToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
|
||||
return goToPage(state, pageIndex).withRecordedJumpFrom(state)
|
||||
}
|
||||
|
||||
fun jumpToPageNumber(state: ReaderSessionState, pageNumber: Int): ReaderSessionState {
|
||||
return jumpToPage(state, pageNumber - 1)
|
||||
}
|
||||
|
||||
fun jumpToChapter(state: ReaderSessionState, chapterIndex: Int): ReaderSessionState {
|
||||
return goToChapter(state, chapterIndex).withRecordedJumpFrom(state)
|
||||
}
|
||||
|
||||
fun jumpToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState {
|
||||
return goToLocator(state, locator).withRecordedJumpFrom(state, requestedTarget = locator)
|
||||
}
|
||||
|
||||
fun jumpToSearchResult(state: ReaderSessionState, resultIndex: Int): ReaderSessionState {
|
||||
return goToSearchResult(state, resultIndex).withRecordedJumpFrom(state)
|
||||
}
|
||||
|
||||
fun jumpToNextSearchResult(state: ReaderSessionState): ReaderSessionState {
|
||||
return nextSearchResult(state).withRecordedJumpFrom(state)
|
||||
}
|
||||
|
||||
fun jumpToPreviousSearchResult(state: ReaderSessionState): ReaderSessionState {
|
||||
return previousSearchResult(state).withRecordedJumpFrom(state)
|
||||
}
|
||||
|
||||
fun jumpBack(state: ReaderSessionState): ReaderSessionState {
|
||||
if (state.reader.settings.readingMode == ReaderReadingMode.PAGINATED) {
|
||||
return state.copy(jumpHistory = state.jumpHistory.clear())
|
||||
}
|
||||
val history = state.jumpHistory.pruned(state.reader.book.chapters.size)
|
||||
val target = history.backLocator ?: return state.copy(jumpHistory = history)
|
||||
return goToLocator(state.copy(jumpHistory = history), target)
|
||||
.copy(jumpHistory = history.stepBack())
|
||||
}
|
||||
|
||||
fun jumpForward(state: ReaderSessionState): ReaderSessionState {
|
||||
if (state.reader.settings.readingMode == ReaderReadingMode.PAGINATED) {
|
||||
return state.copy(jumpHistory = state.jumpHistory.clear())
|
||||
}
|
||||
val history = state.jumpHistory.pruned(state.reader.book.chapters.size)
|
||||
val target = history.forwardLocator ?: return state.copy(jumpHistory = history)
|
||||
return goToLocator(state.copy(jumpHistory = history), target)
|
||||
.copy(jumpHistory = history.stepForward())
|
||||
}
|
||||
|
||||
fun clearJumpHistory(state: ReaderSessionState): ReaderSessionState {
|
||||
return state.copy(jumpHistory = state.jumpHistory.clear())
|
||||
}
|
||||
|
||||
fun resolveLink(
|
||||
state: ReaderSessionState,
|
||||
href: String,
|
||||
|
|
@ -275,43 +343,112 @@ class ReaderEngine(
|
|||
}
|
||||
|
||||
fun syncVisiblePage(state: ReaderSessionState, pageIndex: Int, locator: ReaderLocator? = null): ReaderSessionState {
|
||||
val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val normalizedLocator = locator?.normalizedForPage(state, target)
|
||||
val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val normalizedLocator = locator?.normalizedForPage(state, pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)))
|
||||
if (target == state.reader.currentPageIndex && normalizedLocator == null) return state
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = target),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target },
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex },
|
||||
navigationLocator = normalizedLocator ?: state.navigationLocator
|
||||
)
|
||||
}
|
||||
|
||||
fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState {
|
||||
val current = state.reader.currentPage
|
||||
val pages = pagesFor(state.reader.book, settings)
|
||||
val newIndex = if (current == null) {
|
||||
0
|
||||
val layoutChanged = state.reader.settings.layoutSignature() != settings.layoutSignature()
|
||||
val nextJumpHistory = if (settings.readingMode == ReaderReadingMode.PAGINATED) {
|
||||
state.jumpHistory.clear()
|
||||
} else {
|
||||
pages.indexOfFirst {
|
||||
it.chapterIndex == current.chapterIndex && it.startOffset <= current.startOffset && it.endOffset >= current.startOffset
|
||||
}.takeIf { it >= 0 } ?: 0
|
||||
state.jumpHistory
|
||||
}
|
||||
if (!layoutChanged) {
|
||||
return state.copy(
|
||||
reader = state.reader.copy(settings = settings),
|
||||
jumpHistory = nextJumpHistory
|
||||
)
|
||||
}
|
||||
val anchor = state.navigationLocator ?: state.reader.currentPage?.toLocator(state.reader.book)
|
||||
val pages = pagesFor(state.reader.book, settings)
|
||||
val requestedIndex = anchor
|
||||
?.let { pages.findPageIndexForLocator(it) }
|
||||
?.takeIf { it >= 0 }
|
||||
?: 0
|
||||
val newIndex = ReaderSpreadLayout.normalizePageIndex(requestedIndex, pages.size, settings)
|
||||
val normalizedLocator = anchor
|
||||
?.normalizedForResolvedPage(state.reader.book, pages, requestedIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)))
|
||||
?: pages.getOrNull(newIndex)?.toLocator(state.reader.book)
|
||||
val updated = state.copy(
|
||||
reader = state.reader.copy(
|
||||
pages = pages,
|
||||
currentPageIndex = newIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)),
|
||||
currentPageIndex = newIndex,
|
||||
settings = settings
|
||||
)
|
||||
),
|
||||
navigationLocator = normalizedLocator,
|
||||
jumpHistory = nextJumpHistory
|
||||
)
|
||||
return if (updated.searchQuery.isNotBlank()) search(updated, updated.searchQuery) else updated
|
||||
}
|
||||
|
||||
fun reflowAnchorFor(state: ReaderSessionState): ReaderLocator? {
|
||||
return state.navigationLocator ?: state.reader.currentPage?.toLocator(state.reader.book)
|
||||
}
|
||||
|
||||
fun replacePages(
|
||||
state: ReaderSessionState,
|
||||
pages: List<ReaderPage>,
|
||||
reflowAnchor: ReaderLocator? = null,
|
||||
navigationRequestIdAtReflowStart: Long? = null
|
||||
): ReaderSessionState {
|
||||
if (pages.isEmpty()) return state
|
||||
val explicitNavigationAfterReflowStarted = navigationRequestIdAtReflowStart != null &&
|
||||
state.navigationRequestId != navigationRequestIdAtReflowStart
|
||||
val anchor = when {
|
||||
explicitNavigationAfterReflowStarted ->
|
||||
state.navigationLocator ?: state.activeSearchResult?.locator ?: state.reader.currentPage?.toLocator(state.reader.book)
|
||||
reflowAnchor != null -> reflowAnchor
|
||||
else -> state.navigationLocator ?: state.reader.currentPage?.toLocator(state.reader.book)
|
||||
}
|
||||
val targetIndex = anchor
|
||||
?.let { locator -> pages.findPageIndexForLocator(locator) }
|
||||
?.takeIf { it >= 0 }
|
||||
?: state.reader.currentPage?.let { current ->
|
||||
pages.indexOfFirst {
|
||||
it.chapterIndex == current.chapterIndex &&
|
||||
it.startOffset <= current.startOffset &&
|
||||
it.endOffset >= current.startOffset
|
||||
}.takeIf { it >= 0 }
|
||||
}
|
||||
?: state.reader.currentPageIndex
|
||||
val requestedIndex = targetIndex.coerceIn(0, pages.lastIndex)
|
||||
val normalizedIndex = ReaderSpreadLayout.normalizePageIndex(requestedIndex, pages.size, state.reader.settings)
|
||||
val normalizedLocator = anchor
|
||||
?.normalizedForResolvedPage(state.reader.book, pages, requestedIndex)
|
||||
?: pages.getOrNull(normalizedIndex)?.toLocator(state.reader.book)
|
||||
val activeSearchIndex = normalizedLocator
|
||||
?.let { locator -> state.searchResults.indexOfFirst { it.locator.sameLocation(locator) } }
|
||||
?: -1
|
||||
val updated = state.copy(
|
||||
reader = state.reader.copy(
|
||||
pages = pages,
|
||||
currentPageIndex = normalizedIndex
|
||||
),
|
||||
activeSearchResultIndex = activeSearchIndex,
|
||||
navigationLocator = normalizedLocator,
|
||||
jumpHistory = if (state.reader.settings.readingMode == ReaderReadingMode.PAGINATED) {
|
||||
state.jumpHistory.clear()
|
||||
} else {
|
||||
state.jumpHistory
|
||||
}
|
||||
)
|
||||
return if (updated.searchQuery.isNotBlank()) refreshSearchResults(updated) else updated
|
||||
}
|
||||
|
||||
private fun pagesFor(book: SharedEpubBook, settings: ReaderSettings): List<ReaderPage> {
|
||||
val key = PaginationCacheKey(
|
||||
bookId = book.id,
|
||||
chapterSignature = book.chapters.fold(1) { acc, chapter ->
|
||||
31 * acc + chapter.id.hashCode() + chapter.plainText.length + chapter.plainText.hashCode()
|
||||
},
|
||||
settings = settings
|
||||
layoutSignature = settings.layoutSignature()
|
||||
)
|
||||
return synchronized(paginationCache) {
|
||||
paginationCache.getOrPut(key) {
|
||||
|
|
@ -463,47 +600,59 @@ class ReaderEngine(
|
|||
|
||||
fun search(state: ReaderSessionState, query: String): ReaderSessionState {
|
||||
val normalized = query.trim()
|
||||
val results = if (normalized.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
state.reader.pages.flatMap { page ->
|
||||
val matches = mutableListOf<ReaderSearchResult>()
|
||||
var startIndex = 0
|
||||
while (startIndex < page.text.length) {
|
||||
val index = page.text.indexOfSearch(normalized, startIndex, state.searchOptions)
|
||||
if (index < 0) break
|
||||
val endIndex = (index + normalized.length).coerceAtMost(page.text.length)
|
||||
matches +=
|
||||
ReaderSearchResult(
|
||||
pageIndex = page.pageIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
preview = page.text.previewAround(index, normalized.length),
|
||||
matchIndex = index,
|
||||
chapterIndex = page.chapterIndex,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset + index,
|
||||
endOffset = page.startOffset + endIndex,
|
||||
textQuote = page.text.substring(index, endIndex)
|
||||
)
|
||||
)
|
||||
startIndex = index + normalized.length.coerceAtLeast(1)
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
val activeIndex = results.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex }
|
||||
.takeIf { it >= 0 }
|
||||
?: if (results.isNotEmpty()) 0 else -1
|
||||
val updated = state.copy(
|
||||
val results = searchResultsFor(state, normalized)
|
||||
return state.copy(
|
||||
isSearchActive = state.isSearchActive || normalized.isNotBlank(),
|
||||
showSearchResultsPanel = state.showSearchResultsPanel || normalized.isNotBlank(),
|
||||
searchQuery = query,
|
||||
searchResults = results,
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshSearchResults(state: ReaderSessionState): ReaderSessionState {
|
||||
val normalized = state.searchQuery.trim()
|
||||
val results = searchResultsFor(state, normalized)
|
||||
val previousLocator = state.activeSearchResult?.locator
|
||||
val activeIndex = previousLocator
|
||||
?.let { locator -> results.indexOfFirst { it.locator.sameLocation(locator) } }
|
||||
?.takeIf { it >= 0 }
|
||||
?: results.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex }.takeIf { it >= 0 }
|
||||
?: if (results.isNotEmpty()) 0 else -1
|
||||
return state.copy(
|
||||
searchResults = results,
|
||||
activeSearchResultIndex = activeIndex
|
||||
)
|
||||
return updated.activeSearchResult?.let { goToSearchResult(updated, activeIndex) } ?: updated
|
||||
}
|
||||
|
||||
private fun searchResultsFor(state: ReaderSessionState, normalized: String): List<ReaderSearchResult> {
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
return state.reader.pages.flatMap { page ->
|
||||
val matches = mutableListOf<ReaderSearchResult>()
|
||||
var startIndex = 0
|
||||
while (startIndex < page.text.length) {
|
||||
val index = page.text.indexOfSearch(normalized, startIndex, state.searchOptions)
|
||||
if (index < 0) break
|
||||
val endIndex = (index + normalized.length).coerceAtMost(page.text.length)
|
||||
matches +=
|
||||
ReaderSearchResult(
|
||||
pageIndex = page.pageIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
preview = page.text.previewAround(index, normalized.length),
|
||||
matchIndex = index,
|
||||
chapterIndex = page.chapterIndex,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset + index,
|
||||
endOffset = page.startOffset + endIndex,
|
||||
textQuote = page.text.substring(index, endIndex)
|
||||
)
|
||||
)
|
||||
startIndex = index + normalized.length.coerceAtLeast(1)
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
|
||||
fun nextSearchResult(state: ReaderSessionState): ReaderSessionState {
|
||||
|
|
@ -530,25 +679,51 @@ class ReaderEngine(
|
|||
if (state.searchResults.isEmpty()) return state
|
||||
val targetIndex = resultIndex.coerceIn(0, state.searchResults.lastIndex)
|
||||
val result = state.searchResults[targetIndex]
|
||||
val targetPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) }
|
||||
val requestedPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: result.pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val targetPage = ReaderSpreadLayout.normalizePageIndex(requestedPage, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(targetPage)
|
||||
val chapter = page?.let { state.reader.book.chapters.getOrNull(it.chapterIndex) }
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = targetPage),
|
||||
activeSearchResultIndex = targetIndex,
|
||||
navigationLocator = result.locator.copy(pageIndex = targetPage).withFallbacks(
|
||||
navigationLocator = result.locator.copy(pageIndex = requestedPage).withFallbacks(
|
||||
chapterIndex = page?.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = targetPage
|
||||
pageIndex = requestedPage
|
||||
),
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderSessionState.withRecordedJumpFrom(
|
||||
previous: ReaderSessionState,
|
||||
requestedTarget: ReaderLocator? = null
|
||||
): ReaderSessionState {
|
||||
if (
|
||||
previous.reader.settings.readingMode == ReaderReadingMode.PAGINATED ||
|
||||
reader.settings.readingMode == ReaderReadingMode.PAGINATED
|
||||
) {
|
||||
return copy(jumpHistory = previous.jumpHistory.clear())
|
||||
}
|
||||
val current = previous.currentJumpLocator()
|
||||
val target = navigationLocator ?: requestedTarget ?: currentJumpLocator()
|
||||
return copy(
|
||||
jumpHistory = previous.jumpHistory.record(
|
||||
currentLocator = current,
|
||||
targetLocator = target,
|
||||
chapterCount = reader.book.chapters.size
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderSessionState.currentJumpLocator(): ReaderLocator? {
|
||||
return navigationLocator ?: reader.currentPage?.toLocator(reader.book)
|
||||
}
|
||||
|
||||
private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
|
||||
val targetChapter = locator.chapterIndex
|
||||
if (targetChapter != null && targetChapter != chapterIndex) return false
|
||||
|
|
@ -565,6 +740,34 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
|
|||
return targetPage != null && targetPage == pageIndex
|
||||
}
|
||||
|
||||
private fun List<ReaderPage>.findPageIndexForLocator(locator: ReaderLocator): Int {
|
||||
return indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex?.takeIf { it in indices }
|
||||
?: -1
|
||||
}
|
||||
|
||||
private fun ReaderLocator.normalizedForResolvedPage(
|
||||
book: SharedEpubBook,
|
||||
pages: List<ReaderPage>,
|
||||
pageIndex: Int
|
||||
): ReaderLocator? {
|
||||
val page = pages.getOrNull(pageIndex) ?: return null
|
||||
val chapter = book.chapters.getOrNull(page.chapterIndex)
|
||||
val start = startOffset ?: page.startOffset
|
||||
val end = (endOffset ?: start).coerceAtLeast(start)
|
||||
return copy(pageIndex = page.pageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = start,
|
||||
endOffset = end,
|
||||
textQuote = textQuote ?: page.text.preview(),
|
||||
cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end"
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List<ReaderPage>): ReaderBookmark? {
|
||||
val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
|
|
@ -796,5 +999,5 @@ private fun Char?.isWordChar(): Boolean {
|
|||
}
|
||||
|
||||
private fun logReaderLink(message: String) {
|
||||
println("ReaderLinkResolve $message")
|
||||
logSharedReaderDiagnostic("ReaderLinkResolve") { message }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,125 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
data class ReaderJumpHistory(
|
||||
val locators: List<ReaderLocator> = emptyList(),
|
||||
val cursor: Int = -1,
|
||||
val maxEntries: Int = 21
|
||||
) {
|
||||
val backLocator: ReaderLocator? get() = locators.getOrNull(cursor - 1)
|
||||
val forwardLocator: ReaderLocator? get() = locators.getOrNull(cursor + 1)
|
||||
val hasJumpTargets: Boolean get() = backLocator != null || forwardLocator != null
|
||||
|
||||
fun record(
|
||||
currentLocator: ReaderLocator?,
|
||||
targetLocator: ReaderLocator?,
|
||||
chapterCount: Int
|
||||
): ReaderJumpHistory {
|
||||
val current = currentLocator?.takeIf { it.isValidJumpLocator(chapterCount) } ?: return this
|
||||
val target = targetLocator?.takeIf { it.isValidJumpLocator(chapterCount) } ?: return this
|
||||
if (current.hasSameJumpLocation(target)) return this
|
||||
|
||||
val pruned = pruned(chapterCount)
|
||||
val nextLocators = pruned.locators.toMutableList()
|
||||
var nextCursor = pruned.cursor
|
||||
|
||||
while (nextLocators.lastIndex > nextCursor) {
|
||||
nextLocators.removeAt(nextLocators.lastIndex)
|
||||
}
|
||||
|
||||
if (nextCursor > 0 && nextLocators.getOrNull(nextCursor - 1)?.hasSameJumpLocation(current) == true) {
|
||||
nextLocators[nextCursor] = target
|
||||
return copy(
|
||||
locators = nextLocators,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
if (nextCursor == -1 || nextLocators.getOrNull(nextCursor)?.hasSameJumpLocation(current) != true) {
|
||||
nextLocators += current
|
||||
nextCursor = nextLocators.lastIndex
|
||||
}
|
||||
|
||||
if (nextLocators.lastOrNull()?.hasSameJumpLocation(target) != true) {
|
||||
nextLocators += target
|
||||
nextCursor = nextLocators.lastIndex
|
||||
}
|
||||
|
||||
return copy(
|
||||
locators = nextLocators,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun pruned(chapterCount: Int): ReaderJumpHistory {
|
||||
if (chapterCount <= 0) return clear()
|
||||
val nextLocators = locators.toMutableList()
|
||||
var nextCursor = cursor
|
||||
var index = nextLocators.lastIndex
|
||||
while (index >= 0) {
|
||||
if (!nextLocators[index].isValidJumpLocator(chapterCount)) {
|
||||
nextLocators.removeAt(index)
|
||||
if (nextCursor >= index) nextCursor--
|
||||
}
|
||||
index--
|
||||
}
|
||||
return copy(
|
||||
locators = nextLocators,
|
||||
cursor = nextCursor.coerceIn(-1, nextLocators.lastIndex)
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun stepBack(): ReaderJumpHistory {
|
||||
return if (backLocator == null) this else copy(cursor = (cursor - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun stepForward(): ReaderJumpHistory {
|
||||
return if (forwardLocator == null) this else copy(cursor = (cursor + 1).coerceAtMost(locators.lastIndex))
|
||||
}
|
||||
|
||||
fun clear(): ReaderJumpHistory = copy(locators = emptyList(), cursor = -1)
|
||||
|
||||
private fun bounded(): ReaderJumpHistory {
|
||||
val safeMaxEntries = maxEntries.coerceAtLeast(2)
|
||||
if (locators.size <= safeMaxEntries) {
|
||||
return copy(cursor = cursor.coerceIn(-1, locators.lastIndex))
|
||||
}
|
||||
val overflow = locators.size - safeMaxEntries
|
||||
return copy(
|
||||
locators = locators.drop(overflow),
|
||||
cursor = (cursor - overflow).coerceIn(-1, locators.size - overflow - 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderLocator.hasSameJumpLocation(other: ReaderLocator): Boolean {
|
||||
return jumpLocationKey() == other.jumpLocationKey()
|
||||
}
|
||||
|
||||
private fun ReaderLocator.isValidJumpLocator(chapterCount: Int): Boolean {
|
||||
if (chapterCount <= 0) return false
|
||||
val chapter = chapterIndex
|
||||
return chapter == null || chapter in 0 until chapterCount
|
||||
}
|
||||
|
||||
private fun ReaderLocator.jumpLocationKey(): String {
|
||||
val stableCfi = cfi?.takeIf { it.isNotBlank() }
|
||||
if (stableCfi != null) {
|
||||
return listOf(
|
||||
chapterIndex?.toString().orEmpty(),
|
||||
chapterId.orEmpty(),
|
||||
href.orEmpty(),
|
||||
startOffset?.toString().orEmpty(),
|
||||
endOffset?.toString().orEmpty(),
|
||||
stableCfi
|
||||
).joinToString("|")
|
||||
}
|
||||
return listOf(
|
||||
chapterIndex?.toString().orEmpty(),
|
||||
chapterId.orEmpty(),
|
||||
href.orEmpty(),
|
||||
pageIndex?.toString().orEmpty(),
|
||||
startOffset?.toString().orEmpty(),
|
||||
endOffset?.toString().orEmpty(),
|
||||
cfi.orEmpty()
|
||||
).joinToString("|")
|
||||
}
|
||||
|
|
@ -11,7 +11,15 @@ data class SharedEpubBook(
|
|||
val title: String,
|
||||
val author: String? = null,
|
||||
val chapters: List<SharedEpubChapter>,
|
||||
val css: Map<String, String> = emptyMap()
|
||||
val css: Map<String, String> = emptyMap(),
|
||||
val tableOfContents: List<SharedEpubTocEntry> = emptyList()
|
||||
)
|
||||
|
||||
data class SharedEpubTocEntry(
|
||||
val label: String,
|
||||
val href: String,
|
||||
val fragmentId: String? = null,
|
||||
val depth: Int = 0
|
||||
)
|
||||
|
||||
data class SharedEpubChapter(
|
||||
|
|
@ -30,6 +38,11 @@ enum class ReaderReadingMode {
|
|||
VERTICAL
|
||||
}
|
||||
|
||||
enum class ReaderPageSpreadMode {
|
||||
SINGLE,
|
||||
TWO_PAGE
|
||||
}
|
||||
|
||||
enum class SharedReaderTextAlign {
|
||||
START,
|
||||
JUSTIFY,
|
||||
|
|
@ -41,7 +54,7 @@ data class ReaderSettings(
|
|||
val lineSpacing: Float = 1.45f,
|
||||
val margin: Int = 48,
|
||||
val darkMode: Boolean = false,
|
||||
val readingMode: ReaderReadingMode = ReaderReadingMode.PAGINATED,
|
||||
val readingMode: ReaderReadingMode = ReaderReadingMode.VERTICAL,
|
||||
val textAlign: SharedReaderTextAlign = SharedReaderTextAlign.START,
|
||||
val pageWidth: Int = 760,
|
||||
val fontFamily: String = "Default",
|
||||
|
|
@ -58,6 +71,9 @@ data class ReaderSettings(
|
|||
val systemUiMode: SystemUiMode = SystemUiMode.DEFAULT,
|
||||
val pageInfoMode: PageInfoMode = PageInfoMode.DEFAULT,
|
||||
val pageInfoPosition: PageInfoPosition = PageInfoPosition.BOTTOM,
|
||||
val pageSpreadMode: ReaderPageSpreadMode = ReaderPageSpreadMode.SINGLE,
|
||||
val pdfVerticalPageGapVisible: Boolean = true,
|
||||
val pdfPageNumberOverlayVisible: Boolean = true,
|
||||
val seamlessChapterNavigation: Boolean = true,
|
||||
val chapterTurnDragMultiplier: Float = 1.0f
|
||||
) {
|
||||
|
|
@ -65,13 +81,73 @@ data class ReaderSettings(
|
|||
val resolvedVerticalMargin: Int get() = verticalMargin ?: margin
|
||||
}
|
||||
|
||||
data class ReaderLayoutSignature(
|
||||
val fontSize: Int,
|
||||
val lineSpacing: Float,
|
||||
val horizontalMargin: Int,
|
||||
val verticalMargin: Int,
|
||||
val readingMode: ReaderReadingMode,
|
||||
val textAlign: SharedReaderTextAlign,
|
||||
val pageWidth: Int,
|
||||
val fontFamily: String,
|
||||
val paragraphSpacing: Float,
|
||||
val imageScale: Float,
|
||||
val pageSpreadMode: ReaderPageSpreadMode,
|
||||
val customFontPath: String?
|
||||
)
|
||||
|
||||
data class ReaderAppearanceSignature(
|
||||
val darkMode: Boolean,
|
||||
val themeId: String?,
|
||||
val textureId: String?,
|
||||
val textureAlpha: Float,
|
||||
val backgroundColorArgb: Long?,
|
||||
val textColorArgb: Long?
|
||||
)
|
||||
|
||||
fun ReaderSettings.layoutSignature(): ReaderLayoutSignature {
|
||||
return ReaderLayoutSignature(
|
||||
fontSize = fontSize,
|
||||
lineSpacing = lineSpacing,
|
||||
horizontalMargin = resolvedHorizontalMargin,
|
||||
verticalMargin = resolvedVerticalMargin,
|
||||
readingMode = readingMode,
|
||||
textAlign = textAlign,
|
||||
pageWidth = pageWidth,
|
||||
fontFamily = fontFamily,
|
||||
paragraphSpacing = paragraphSpacing,
|
||||
imageScale = imageScale,
|
||||
pageSpreadMode = pageSpreadMode,
|
||||
customFontPath = customFontPath
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.appearanceSignature(): ReaderAppearanceSignature {
|
||||
return ReaderAppearanceSignature(
|
||||
darkMode = darkMode,
|
||||
themeId = themeId,
|
||||
textureId = textureId,
|
||||
textureAlpha = textureAlpha,
|
||||
backgroundColorArgb = backgroundColorArgb,
|
||||
textColorArgb = textColorArgb
|
||||
)
|
||||
}
|
||||
|
||||
data class ReaderViewportSpec(
|
||||
val widthPx: Int,
|
||||
val heightPx: Int
|
||||
) {
|
||||
val isSpecified: Boolean get() = widthPx > 0 && heightPx > 0
|
||||
}
|
||||
|
||||
data class ReaderPage(
|
||||
val pageIndex: Int,
|
||||
val chapterIndex: Int,
|
||||
val chapterTitle: String,
|
||||
val text: String,
|
||||
val startOffset: Int,
|
||||
val endOffset: Int
|
||||
val endOffset: Int,
|
||||
val semanticBlocks: List<SemanticBlock> = emptyList()
|
||||
)
|
||||
|
||||
data class PaginatedReaderState(
|
||||
|
|
@ -81,40 +157,96 @@ data class PaginatedReaderState(
|
|||
val settings: ReaderSettings = ReaderSettings()
|
||||
) {
|
||||
val currentPage: ReaderPage? get() = pages.getOrNull(currentPageIndex)
|
||||
val progress: Float get() = if (pages.isEmpty()) 0f else ((currentPageIndex + 1).toFloat() / pages.size) * 100f
|
||||
val progress: Float
|
||||
get() {
|
||||
if (pages.isEmpty()) return 0f
|
||||
val visibleEnd = ReaderSpreadLayout.visiblePageIndices(currentPageIndex, pages.size, settings)
|
||||
.lastOrNull()
|
||||
?: currentPageIndex
|
||||
return ((visibleEnd + 1).toFloat() / pages.size) * 100f
|
||||
}
|
||||
val canGoPrevious: Boolean get() = currentPageIndex > 0
|
||||
val canGoNext: Boolean get() = currentPageIndex < pages.lastIndex
|
||||
val canGoNext: Boolean get() = ReaderSpreadLayout.canGoNext(currentPageIndex, pages.size, settings)
|
||||
val currentSpreadStartIndex: Int get() = ReaderSpreadLayout.normalizePageIndex(currentPageIndex, pages.size, settings)
|
||||
val visiblePages: List<ReaderPage>
|
||||
get() = ReaderSpreadLayout.visiblePageIndices(currentPageIndex, pages.size, settings)
|
||||
.mapNotNull { pages.getOrNull(it) }
|
||||
}
|
||||
|
||||
object SampleReaderBooks {
|
||||
fun desktopWelcomeBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "desktop_welcome",
|
||||
fileName = "Desktop Welcome.epub",
|
||||
title = "Episteme Desktop Reader",
|
||||
author = "Episteme",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "intro",
|
||||
title = "A Careful First Page",
|
||||
plainText = """
|
||||
This is the first desktop paginated reader milestone.
|
||||
object ReaderSpreadLayout {
|
||||
fun pageStep(settings: ReaderSettings): Int {
|
||||
return if (settings.isTwoPageSpreadEnabled()) 2 else 1
|
||||
}
|
||||
|
||||
It intentionally starts with the quiet parts: page state, chapter navigation, font sizing, margins, light and dark reading surfaces, progress, and a JVM EPUB loader. The Android reader remains where it is, which keeps the mobile app protected while Windows grows its own platform layer.
|
||||
fun normalizePageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
if (pageCount <= 0) return 0
|
||||
val clamped = pageIndex.coerceIn(0, pageCount - 1)
|
||||
return if (settings.isTwoPageSpreadEnabled()) {
|
||||
(clamped - (clamped % 2)).coerceIn(0, pageCount - 1)
|
||||
} else {
|
||||
clamped
|
||||
}
|
||||
}
|
||||
|
||||
The next pieces can be added one by one: persisted locations, bookmarks, highlights, table of contents polish, keyboard shortcuts, and eventually the richer pagination engine from Android once its platform-specific parts are behind interfaces.
|
||||
""".trimIndent()
|
||||
),
|
||||
SharedEpubChapter(
|
||||
id = "scope",
|
||||
title = "What Works Here",
|
||||
plainText = """
|
||||
The desktop shell can import EPUB files and extract readable spine text using the JDK zip APIs. It does not try to render complex CSS, images, MathML, or annotations yet.
|
||||
fun canGoNext(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Boolean {
|
||||
if (pageCount <= 1) return false
|
||||
val current = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
return current + pageStep(settings) < pageCount
|
||||
}
|
||||
|
||||
That limitation is deliberate. A plain paginated reader gives us a working Windows loop without pulling Android WebView, SAF, Room, PDF, or existing reader screens into the first KMP step.
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
)
|
||||
fun nextPageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
return normalizePageIndex(pageIndex + pageStep(settings), pageCount, settings)
|
||||
}
|
||||
|
||||
fun previousPageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
return normalizePageIndex(pageIndex - pageStep(settings), pageCount, settings)
|
||||
}
|
||||
|
||||
fun visiblePageIndices(pageIndex: Int, pageCount: Int, settings: ReaderSettings): List<Int> {
|
||||
if (pageCount <= 0) return emptyList()
|
||||
val start = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
if (!settings.isTwoPageSpreadEnabled()) return listOf(start)
|
||||
return listOf(start, start + 1).filter { it in 0 until pageCount }
|
||||
}
|
||||
|
||||
fun pageRangeLabel(pageIndex: Int, pageCount: Int, settings: ReaderSettings): String {
|
||||
val total = pageCount.coerceAtLeast(1)
|
||||
val pages = visiblePageIndices(pageIndex, total, settings).ifEmpty { listOf(0) }
|
||||
val first = pages.first() + 1
|
||||
val last = pages.last() + 1
|
||||
return if (first == last) "$first" else "$first-$last"
|
||||
}
|
||||
|
||||
fun sliderStepCount(pageCount: Int, settings: ReaderSettings): Int {
|
||||
val total = pageCount.coerceAtLeast(1)
|
||||
return if (settings.isTwoPageSpreadEnabled()) {
|
||||
(total + 1) / 2
|
||||
} else {
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
fun sliderPositionForPage(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
val normalized = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
val position = if (settings.isTwoPageSpreadEnabled()) {
|
||||
(normalized / 2) + 1
|
||||
} else {
|
||||
normalized + 1
|
||||
}
|
||||
return position.coerceIn(1, sliderStepCount(pageCount, settings))
|
||||
}
|
||||
|
||||
fun pageNumberForSliderPosition(position: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
val clamped = position.coerceIn(1, sliderStepCount(pageCount, settings))
|
||||
val pageIndex = if (settings.isTwoPageSpreadEnabled()) {
|
||||
(clamped - 1) * 2
|
||||
} else {
|
||||
clamped - 1
|
||||
}
|
||||
return normalizePageIndex(pageIndex, pageCount, settings) + 1
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderSettings.isTwoPageSpreadEnabled(): Boolean {
|
||||
return readingMode == ReaderReadingMode.PAGINATED && pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
internal const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics"
|
||||
internal const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
|
||||
|
||||
internal expect val SharedReaderDiagnosticsEnabled: Boolean
|
||||
internal expect fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean
|
||||
|
||||
internal inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) {
|
||||
if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) {
|
||||
println("$tag ${message()}")
|
||||
}
|
||||
}
|
||||
|
|
@ -7,5 +7,5 @@ import androidx.compose.ui.Modifier
|
|||
internal expect fun LocalBookCoverImage(
|
||||
path: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import com.aryan.reader.shared.BookItem
|
|||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.LibraryFilters
|
||||
import com.aryan.reader.shared.ReadStatusFilter
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.ShelfType
|
||||
import com.aryan.reader.shared.isOpdsStream
|
||||
|
|
@ -26,43 +29,48 @@ enum class SharedAppToolAction {
|
|||
data class SharedAppShellModel(
|
||||
val primaryTabs: List<SharedAppTab>,
|
||||
val selectedPrimaryTab: SharedAppTab,
|
||||
val toolActions: List<SharedAppToolAction>
|
||||
val toolActions: List<SharedAppToolAction>,
|
||||
val showPrimaryNavigation: Boolean
|
||||
)
|
||||
|
||||
fun sharedAppShellModel(
|
||||
selectedTab: SharedAppTab,
|
||||
aiSettingsAvailable: Boolean
|
||||
aiSettingsAvailable: Boolean,
|
||||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard
|
||||
): SharedAppShellModel {
|
||||
val primaryTabs = listOf(
|
||||
SharedAppTab.HOME,
|
||||
SharedAppTab.LIBRARY,
|
||||
SharedAppTab.CATALOGS,
|
||||
SharedAppTab.READER
|
||||
)
|
||||
val primaryTabs = buildList {
|
||||
add(SharedAppTab.HOME)
|
||||
add(SharedAppTab.LIBRARY)
|
||||
if (featurePolicy.opdsCatalogs) add(SharedAppTab.CATALOGS)
|
||||
}
|
||||
val selectedPrimaryTab = when (selectedTab) {
|
||||
SharedAppTab.SHELVES -> SharedAppTab.LIBRARY
|
||||
SharedAppTab.SETTINGS,
|
||||
SharedAppTab.CUSTOM_FONTS,
|
||||
SharedAppTab.SUPPORT,
|
||||
SharedAppTab.FEEDBACK,
|
||||
SharedAppTab.ABOUT -> SharedAppTab.HOME
|
||||
else -> selectedTab
|
||||
}
|
||||
}.takeIf { it in primaryTabs } ?: SharedAppTab.HOME
|
||||
val toolActions = buildList {
|
||||
add(SharedAppToolAction.IMPORT_FILES)
|
||||
add(SharedAppToolAction.IMPORT_FOLDER)
|
||||
add(SharedAppToolAction.SYNC)
|
||||
add(SharedAppToolAction.APP_THEME)
|
||||
if (aiSettingsAvailable) add(SharedAppToolAction.AI_SETTINGS)
|
||||
if (aiSettingsAvailable && featurePolicy.aiAndCloud) add(SharedAppToolAction.AI_SETTINGS)
|
||||
add(SharedAppToolAction.CUSTOM_FONTS)
|
||||
add(SharedAppToolAction.HELP_FEEDBACK)
|
||||
add(SharedAppToolAction.SUPPORT)
|
||||
if (featurePolicy.projectLinks) {
|
||||
add(SharedAppToolAction.HELP_FEEDBACK)
|
||||
add(SharedAppToolAction.SUPPORT)
|
||||
}
|
||||
add(SharedAppToolAction.ABOUT)
|
||||
add(SharedAppToolAction.TABS_TOGGLE)
|
||||
}
|
||||
return SharedAppShellModel(
|
||||
primaryTabs = primaryTabs,
|
||||
selectedPrimaryTab = selectedPrimaryTab,
|
||||
toolActions = toolActions
|
||||
toolActions = toolActions,
|
||||
showPrimaryNavigation = selectedTab != SharedAppTab.READER
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +123,50 @@ data class NonReaderLibraryOrganizationModel(
|
|||
val hasOpdsStreams: Boolean
|
||||
)
|
||||
|
||||
internal data class NonReaderLibraryFileTypeGroup(
|
||||
val title: String,
|
||||
val fileTypes: List<FileType>
|
||||
)
|
||||
|
||||
private val LibraryFileTypeGroupTemplates = listOf(
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Books",
|
||||
fileTypes = listOf(FileType.EPUB, FileType.MOBI, FileType.FB2)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Documents",
|
||||
fileTypes = listOf(FileType.PDF, FileType.PPTX, FileType.DOCX, FileType.ODT, FileType.FODT)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Text and web",
|
||||
fileTypes = listOf(FileType.MD, FileType.TXT, FileType.HTML)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Comics",
|
||||
fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||
)
|
||||
)
|
||||
|
||||
internal fun nonReaderLibraryFileTypeGroups(
|
||||
platform: ReaderPlatform = ReaderPlatform.DESKTOP
|
||||
): List<NonReaderLibraryFileTypeGroup> {
|
||||
val readableTypes = SharedFileCapabilities.readableTypesFor(platform)
|
||||
val knownGroupedTypes = LibraryFileTypeGroupTemplates.flatMapTo(mutableSetOf()) { it.fileTypes }
|
||||
val grouped = LibraryFileTypeGroupTemplates.mapNotNull { group ->
|
||||
val visibleTypes = group.fileTypes.filter { it in readableTypes }
|
||||
group.copy(fileTypes = visibleTypes).takeIf { visibleTypes.isNotEmpty() }
|
||||
}
|
||||
val otherTypes = readableTypes
|
||||
.filterNot { it in knownGroupedTypes }
|
||||
.sortedBy { it.ordinal }
|
||||
|
||||
return if (otherTypes.isEmpty()) {
|
||||
grouped
|
||||
} else {
|
||||
grouped + NonReaderLibraryFileTypeGroup("Other", otherTypes)
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibraryOrganizationModel {
|
||||
val books = rawLibraryBooks
|
||||
val rootFolderCount = shelves.count { it.type == ShelfType.FOLDER && it.parentShelfId == null }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,48 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.ReaderAutoScrollState
|
||||
import com.aryan.reader.shared.ReaderHighlightPalette
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.UserHighlight
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSearchOptions
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
data class ReaderContentNavigationTarget(
|
||||
val locator: ReaderLocator?,
|
||||
val requestId: Long,
|
||||
val readingMode: ReaderReadingMode,
|
||||
val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(),
|
||||
val ttsLocator: ReaderLocator? = null,
|
||||
val ttsRequestId: Long = 0L
|
||||
)
|
||||
|
||||
sealed interface ReaderContentRenderPlan {
|
||||
val background: Color
|
||||
val foreground: Color
|
||||
val navigationTarget: ReaderContentNavigationTarget
|
||||
val highlights: List<UserHighlight>
|
||||
|
||||
data class WebDocument(
|
||||
val html: String,
|
||||
val appearanceScript: String,
|
||||
override val background: Color,
|
||||
override val foreground: Color,
|
||||
override val navigationTarget: ReaderContentNavigationTarget,
|
||||
override val highlights: List<UserHighlight>
|
||||
) : ReaderContentRenderPlan
|
||||
|
||||
data class NativePaginatedPages(
|
||||
val visiblePages: List<ReaderPage>,
|
||||
val settings: ReaderSettings,
|
||||
val searchQuery: String,
|
||||
val searchOptions: ReaderSearchOptions,
|
||||
val highlightPalette: ReaderHighlightPalette,
|
||||
override val background: Color,
|
||||
override val foreground: Color,
|
||||
override val navigationTarget: ReaderContentNavigationTarget,
|
||||
override val highlights: List<UserHighlight>
|
||||
) : ReaderContentRenderPlan
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ReaderMinimalSlider(
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onValueChangeStarted: (() -> Unit)? = null,
|
||||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
activeColor: Color? = null,
|
||||
inactiveColor: Color? = null,
|
||||
thumbColor: Color? = null
|
||||
) {
|
||||
var widthPx by remember { mutableFloatStateOf(0f) }
|
||||
val rangeStart = valueRange.start
|
||||
val rangeEnd = valueRange.endInclusive
|
||||
|
||||
fun valueForOffset(offsetX: Float): Float {
|
||||
if (widthPx <= 0f || rangeEnd <= rangeStart) return value.coerceIn(rangeStart, rangeEnd)
|
||||
val fraction = (offsetX / widthPx).coerceIn(0f, 1f)
|
||||
return rangeStart + (rangeEnd - rangeStart) * fraction
|
||||
}
|
||||
|
||||
val inputModifier = if (enabled) {
|
||||
Modifier.pointerInput(rangeStart, rangeEnd, widthPx) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
onValueChangeStarted?.invoke()
|
||||
onValueChange(valueForOffset(down.position.x))
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
onValueChange(valueForOffset(change.position.x))
|
||||
change.consume()
|
||||
}
|
||||
|
||||
onValueChangeFinished?.invoke()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val effectiveActiveColor = activeColor ?: MaterialTheme.colorScheme.primary
|
||||
val effectiveInactiveColor = inactiveColor ?: MaterialTheme.colorScheme.surfaceVariant
|
||||
val effectiveThumbColor = thumbColor ?: MaterialTheme.colorScheme.primary
|
||||
val disabledAlpha = if (enabled) 1f else 0.38f
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(24.dp)
|
||||
.onSizeChanged { widthPx = it.width.toFloat() }
|
||||
.then(inputModifier)
|
||||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val range = rangeEnd - rangeStart
|
||||
val fraction = if (range > 0f) {
|
||||
((value.coerceIn(rangeStart, rangeEnd) - rangeStart) / range).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val trackHeight = 4.dp.toPx()
|
||||
val thumbRadius = 7.dp.toPx()
|
||||
val centerY = size.height / 2f
|
||||
val cornerRadius = CornerRadius(trackHeight / 2f, trackHeight / 2f)
|
||||
val activeWidth = size.width * fraction
|
||||
|
||||
drawRoundRect(
|
||||
color = effectiveInactiveColor.copy(alpha = effectiveInactiveColor.alpha * disabledAlpha),
|
||||
topLeft = Offset(0f, centerY - trackHeight / 2f),
|
||||
size = Size(size.width, trackHeight),
|
||||
cornerRadius = cornerRadius
|
||||
)
|
||||
drawRoundRect(
|
||||
color = effectiveActiveColor.copy(alpha = effectiveActiveColor.alpha * disabledAlpha),
|
||||
topLeft = Offset(0f, centerY - trackHeight / 2f),
|
||||
size = Size(activeWidth, trackHeight),
|
||||
cornerRadius = cornerRadius
|
||||
)
|
||||
|
||||
val thumbCenterX = if (size.width <= thumbRadius * 2f) {
|
||||
size.width / 2f
|
||||
} else {
|
||||
activeWidth.coerceIn(thumbRadius, size.width - thumbRadius)
|
||||
}
|
||||
drawCircle(
|
||||
color = effectiveThumbColor.copy(alpha = effectiveThumbColor.alpha * disabledAlpha),
|
||||
radius = thumbRadius,
|
||||
center = Offset(thumbCenterX, centerY)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,8 @@ enum class ReaderWorkspaceLeftSection(val title: String) {
|
|||
CONTENTS("Contents"),
|
||||
SEARCH("Search"),
|
||||
BOOKMARKS("Bookmarks"),
|
||||
NOTES("Notes")
|
||||
NOTES("Annotations"),
|
||||
PAGES("Pages")
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceInspectorSection(val title: String) {
|
||||
|
|
@ -32,6 +33,7 @@ enum class ReaderWorkspaceTopAction {
|
|||
CONTENTS,
|
||||
SEARCH,
|
||||
BOOKMARK,
|
||||
FULL_SCREEN,
|
||||
APPEARANCE,
|
||||
READ_ALOUD,
|
||||
AI,
|
||||
|
|
@ -51,6 +53,11 @@ data class ReaderWorkspaceChromeModel(
|
|||
val forceVisibleReasons: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
data class ReaderWorkspacePanelDefaults(
|
||||
val leftOpen: Boolean = false,
|
||||
val inspectorOpen: Boolean = false
|
||||
)
|
||||
|
||||
data class ReaderWorkspaceModel(
|
||||
val kind: ReaderWorkspaceKind,
|
||||
val leftSections: List<ReaderWorkspaceLeftSection>,
|
||||
|
|
@ -58,6 +65,7 @@ data class ReaderWorkspaceModel(
|
|||
val topActions: List<ReaderWorkspaceTopAction>,
|
||||
val bottomActions: List<ReaderWorkspaceBottomAction>,
|
||||
val defaultPdfInteractionMode: PdfInkTool? = null,
|
||||
val panelDefaults: ReaderWorkspacePanelDefaults = ReaderWorkspacePanelDefaults(),
|
||||
val chrome: ReaderWorkspaceChromeModel
|
||||
)
|
||||
|
||||
|
|
@ -65,38 +73,38 @@ fun epubReaderWorkspaceModel(
|
|||
session: ReaderSessionState,
|
||||
toolbarPreferences: ReaderToolbarPreferences,
|
||||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean
|
||||
aiAvailable: Boolean,
|
||||
cloudTtsAvailable: Boolean = true,
|
||||
externalLookupAvailable: Boolean = true
|
||||
): ReaderWorkspaceModel {
|
||||
val preferences = toolbarPreferences.sanitized()
|
||||
val leftSections = buildList {
|
||||
if (preferences.isVisible(ReaderTool.TOC)) add(ReaderWorkspaceLeftSection.CONTENTS)
|
||||
if (preferences.isVisible(ReaderTool.SEARCH)) add(ReaderWorkspaceLeftSection.SEARCH)
|
||||
if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceLeftSection.BOOKMARKS)
|
||||
if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceLeftSection.NOTES)
|
||||
}
|
||||
val leftSections = listOf(
|
||||
ReaderWorkspaceLeftSection.CONTENTS,
|
||||
ReaderWorkspaceLeftSection.NOTES,
|
||||
ReaderWorkspaceLeftSection.BOOKMARKS
|
||||
)
|
||||
val inspectorSections = buildList {
|
||||
if (preferences.isVisible(ReaderTool.THEME) || preferences.isVisible(ReaderTool.FORMAT)) {
|
||||
add(ReaderWorkspaceInspectorSection.APPEARANCE)
|
||||
}
|
||||
if (preferences.isVisible(ReaderTool.READING_MODE) || preferences.isVisible(ReaderTool.VISUAL_OPTIONS)) {
|
||||
if (preferences.isVisible(ReaderTool.READING_MODE)) {
|
||||
add(ReaderWorkspaceInspectorSection.TOOLS)
|
||||
}
|
||||
if (
|
||||
preferences.isVisible(ReaderTool.DICTIONARY) ||
|
||||
preferences.isVisible(ReaderTool.AI_FEATURES) ||
|
||||
preferences.isVisible(ReaderTool.TTS_CONTROLS) ||
|
||||
(aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) ||
|
||||
(cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) ||
|
||||
preferences.isVisible(ReaderTool.AUTO_SCROLL)
|
||||
) {
|
||||
add(ReaderWorkspaceInspectorSection.AI_TTS)
|
||||
}
|
||||
add(ReaderWorkspaceInspectorSection.TOOLBAR)
|
||||
}.distinct()
|
||||
val topActions = buildList {
|
||||
if (ReaderWorkspaceLeftSection.CONTENTS in leftSections) add(ReaderWorkspaceTopAction.CONTENTS)
|
||||
if (preferences.isVisible(ReaderTool.SEARCH)) add(ReaderWorkspaceTopAction.SEARCH)
|
||||
if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceTopAction.BOOKMARK)
|
||||
add(ReaderWorkspaceTopAction.FULL_SCREEN)
|
||||
if (ReaderWorkspaceInspectorSection.APPEARANCE in inspectorSections) add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
if (preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) add(ReaderWorkspaceTopAction.AI)
|
||||
if (preferences.isVisible(ReaderTool.AUTO_SCROLL)) add(ReaderWorkspaceTopAction.AUTO_SCROLL)
|
||||
if (inspectorSections.isNotEmpty()) add(ReaderWorkspaceTopAction.TOOLS)
|
||||
|
|
@ -113,7 +121,7 @@ fun epubReaderWorkspaceModel(
|
|||
topActions = topActions,
|
||||
bottomActions = bottomActions,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
preferAutoHide = false,
|
||||
searchActive = session.isSearchActive,
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
|
|
@ -130,14 +138,18 @@ fun epubReaderWorkspaceModel(
|
|||
fun readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences: ReaderToolbarPreferences,
|
||||
bottom: Boolean,
|
||||
aiAvailable: Boolean
|
||||
aiAvailable: Boolean,
|
||||
cloudTtsAvailable: Boolean = true,
|
||||
externalLookupAvailable: Boolean = true
|
||||
): List<ReaderTool> {
|
||||
val preferences = toolbarPreferences.sanitized()
|
||||
return preferences.orderedVisibleTools()
|
||||
.filter { tool ->
|
||||
tool.supportsDesktopQuickAction &&
|
||||
preferences.isBottom(tool) == bottom &&
|
||||
(tool != ReaderTool.AI_FEATURES || aiAvailable)
|
||||
(tool != ReaderTool.AI_FEATURES || aiAvailable) &&
|
||||
(tool != ReaderTool.TTS_CONTROLS || cloudTtsAvailable) &&
|
||||
(tool != ReaderTool.DICTIONARY || externalLookupAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,13 +166,15 @@ fun pdfReaderWorkspaceModel(
|
|||
loading: Boolean,
|
||||
errorMessage: String?,
|
||||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean
|
||||
aiAvailable: Boolean,
|
||||
cloudTtsAvailable: Boolean = true,
|
||||
externalLookupAvailable: Boolean = true
|
||||
): ReaderWorkspaceModel {
|
||||
val leftSections = buildList {
|
||||
add(ReaderWorkspaceLeftSection.CONTENTS)
|
||||
add(ReaderWorkspaceLeftSection.SEARCH)
|
||||
if (hasBookmarks) add(ReaderWorkspaceLeftSection.BOOKMARKS)
|
||||
if (hasContents || hasAnnotations || hasEmbeddedComments) add(ReaderWorkspaceLeftSection.NOTES)
|
||||
add(ReaderWorkspaceLeftSection.NOTES)
|
||||
add(ReaderWorkspaceLeftSection.BOOKMARKS)
|
||||
add(ReaderWorkspaceLeftSection.PAGES)
|
||||
}.distinct()
|
||||
val inspectorSections = listOf(
|
||||
ReaderWorkspaceInspectorSection.APPEARANCE,
|
||||
|
|
@ -172,8 +186,9 @@ fun pdfReaderWorkspaceModel(
|
|||
add(ReaderWorkspaceTopAction.CONTENTS)
|
||||
add(ReaderWorkspaceTopAction.SEARCH)
|
||||
add(ReaderWorkspaceTopAction.BOOKMARK)
|
||||
add(ReaderWorkspaceTopAction.FULL_SCREEN)
|
||||
add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (cloudTtsAvailable) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable) add(ReaderWorkspaceTopAction.AI)
|
||||
add(ReaderWorkspaceTopAction.AUTO_SCROLL)
|
||||
add(ReaderWorkspaceTopAction.TOOLS)
|
||||
|
|
@ -190,11 +205,11 @@ fun pdfReaderWorkspaceModel(
|
|||
),
|
||||
defaultPdfInteractionMode = null,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
preferAutoHide = false,
|
||||
searchActive = searchActive || state.searchQuery.isNotBlank(),
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
annotationEditing = annotationEditing || state.selectedAnnotationId != null || state.selectedTool != PdfInkTool.PEN,
|
||||
annotationEditing = annotationEditing || state.selectedAnnotationId != null || state.selectedTool != PdfInkTool.NONE,
|
||||
richTextEditing = richTextEditing,
|
||||
loading = loading,
|
||||
errorMessage = errorMessage,
|
||||
|
|
|
|||
|
|
@ -1,44 +1,59 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
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.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.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.BookmarkBorder
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Fullscreen
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
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.BannerMessage
|
||||
import com.aryan.reader.shared.reader.logSharedReaderDiagnostic
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ReaderWorkspaceShell(
|
||||
|
|
@ -47,22 +62,46 @@ fun ReaderWorkspaceShell(
|
|||
subtitle: String,
|
||||
progressLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
topActions: @Composable RowScope.() -> Unit = {},
|
||||
leftSidebar: @Composable () -> Unit,
|
||||
onReturnToLibrary: (() -> Unit)? = null,
|
||||
isFullscreen: Boolean = false,
|
||||
onFullscreenChange: ((Boolean) -> Unit)? = null,
|
||||
fullscreenExitMessage: String = "Esc to exit",
|
||||
isBookmarked: Boolean = false,
|
||||
onToggleBookmark: (() -> Unit)? = null,
|
||||
onSearchAction: (() -> Unit)? = null,
|
||||
topSearchBar: (@Composable () -> Unit)? = null,
|
||||
leftSidebar: @Composable (closePanel: () -> Unit) -> Unit,
|
||||
rightInspector: @Composable () -> Unit,
|
||||
bottomBar: @Composable () -> Unit,
|
||||
fullscreenBottomBar: (@Composable () -> Unit)? = null,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
var leftPanelOpen by remember(model.kind) { mutableStateOf(true) }
|
||||
var rightPanelOpen by remember(model.kind) { mutableStateOf(true) }
|
||||
var chromeVisible by remember(model.kind) { mutableStateOf(true) }
|
||||
val forceChrome = model.chrome.forceVisible || leftPanelOpen || rightPanelOpen
|
||||
var leftPanelOpen by remember(model.kind, model.panelDefaults.leftOpen) {
|
||||
mutableStateOf(model.panelDefaults.leftOpen)
|
||||
}
|
||||
var rightPanelOpen by remember(model.kind, model.panelDefaults.inspectorOpen) {
|
||||
mutableStateOf(model.panelDefaults.inspectorOpen)
|
||||
}
|
||||
var modalAnchorBounds by remember { mutableStateOf<SharedReaderModalAnchorBounds?>(null) }
|
||||
var fullscreenBannerVisible by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(forceChrome, model.chrome.preferAutoHide, model.chrome.forceVisibleReasons) {
|
||||
chromeVisible = true
|
||||
if (model.chrome.preferAutoHide && !forceChrome) {
|
||||
delay(3_200)
|
||||
chromeVisible = false
|
||||
LaunchedEffect(isFullscreen) {
|
||||
if (isFullscreen) {
|
||||
fullscreenBannerVisible = true
|
||||
delay(2_600)
|
||||
fullscreenBannerVisible = false
|
||||
} else {
|
||||
fullscreenBannerVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(model.kind, model.chrome.forceVisibleReasons) {
|
||||
val reasons = model.chrome.forceVisibleReasons
|
||||
if (reasons.any { it == "search" }) {
|
||||
leftPanelOpen = false
|
||||
rightPanelOpen = false
|
||||
} else if (reasons.any { it == "rich-text" } && model.inspectorSections.isNotEmpty()) {
|
||||
rightPanelOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,82 +109,207 @@ fun ReaderWorkspaceShell(
|
|||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
val wide = maxWidth >= 1120.dp
|
||||
val showChrome = chromeVisible || forceChrome || !model.chrome.preferAutoHide
|
||||
) shellConstraints@ {
|
||||
val wide = this@shellConstraints.maxWidth >= 1120.dp
|
||||
LaunchedEffect(wide, leftPanelOpen, rightPanelOpen) {
|
||||
if (!wide && leftPanelOpen && rightPanelOpen) {
|
||||
rightPanelOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (showChrome) {
|
||||
ReaderWorkspaceTopChrome(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
progressLabel = progressLabel,
|
||||
wide = wide,
|
||||
leftPanelOpen = leftPanelOpen,
|
||||
rightPanelOpen = rightPanelOpen,
|
||||
onToggleLeftPanel = { leftPanelOpen = !leftPanelOpen },
|
||||
onToggleRightPanel = { rightPanelOpen = !rightPanelOpen },
|
||||
topActions = topActions
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (wide && leftPanelOpen && model.leftSections.isNotEmpty()) {
|
||||
leftSidebar()
|
||||
}
|
||||
CompositionLocalProvider(LocalSharedReaderModalAnchorBounds provides modalAnchorBounds) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = if (isFullscreen) 0.dp else 8.dp,
|
||||
top = if (isFullscreen) 0.dp else 8.dp,
|
||||
end = if (isFullscreen) 0.dp else 8.dp
|
||||
)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
logReaderGapLayout(
|
||||
layer = "shell_column",
|
||||
bounds = coordinates.boundsInWindow(),
|
||||
details = if (isFullscreen) {
|
||||
"fullscreen=true padding=0 verticalGap=0"
|
||||
} else {
|
||||
"fullscreen=false padding=start8 top8 end8 bottom0 verticalGap=6"
|
||||
}
|
||||
)
|
||||
},
|
||||
verticalArrangement = Arrangement.spacedBy(if (isFullscreen) 0.dp else 6.dp)
|
||||
) {
|
||||
if (!isFullscreen || topSearchBar != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
logReaderGapLayout("top_chrome_slot", coordinates.boundsInWindow())
|
||||
}
|
||||
) {
|
||||
if (topSearchBar != null) {
|
||||
topSearchBar()
|
||||
} else {
|
||||
ReaderWorkspaceTopChrome(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
progressLabel = progressLabel,
|
||||
topActions = model.topActions,
|
||||
hasLeftPanel = model.leftSections.isNotEmpty(),
|
||||
hasRightPanel = model.inspectorSections.isNotEmpty(),
|
||||
leftPanelOpen = leftPanelOpen,
|
||||
rightPanelOpen = rightPanelOpen,
|
||||
isBookmarked = isBookmarked,
|
||||
onReturnToLibrary = onReturnToLibrary,
|
||||
onToggleLeftPanel = { leftPanelOpen = !leftPanelOpen },
|
||||
onToggleRightPanel = { rightPanelOpen = !rightPanelOpen },
|
||||
onToggleBookmark = onToggleBookmark,
|
||||
onSearchAction = onSearchAction,
|
||||
onEnterFullscreen = onFullscreenChange?.let { change -> { change(true) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
logReaderGapLayout("content_slot", coordinates.boundsInWindow())
|
||||
}
|
||||
) {
|
||||
val showLeftPanel = !isFullscreen && leftPanelOpen && model.leftSections.isNotEmpty()
|
||||
val showRightPanel = !isFullscreen && rightPanelOpen && model.inspectorSections.isNotEmpty()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clipToBounds()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
val bounds = coordinates.boundsInWindow()
|
||||
val nextBounds = SharedReaderModalAnchorBounds(
|
||||
leftPx = bounds.left,
|
||||
topPx = bounds.top,
|
||||
widthPx = bounds.width,
|
||||
heightPx = bounds.height
|
||||
)
|
||||
if (modalAnchorBounds != nextBounds) {
|
||||
modalAnchorBounds = nextBounds
|
||||
}
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
if (wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) {
|
||||
rightInspector()
|
||||
}
|
||||
ReaderWorkspacePanelOverlays(
|
||||
showLeftPanel = showLeftPanel,
|
||||
showRightPanel = showRightPanel,
|
||||
wide = wide,
|
||||
onCloseLeftPanel = { leftPanelOpen = false },
|
||||
onCloseRightPanel = { rightPanelOpen = false },
|
||||
leftSidebar = leftSidebar,
|
||||
rightInspector = rightInspector
|
||||
)
|
||||
}
|
||||
|
||||
if (!wide && leftPanelOpen && model.leftSections.isNotEmpty()) {
|
||||
ReaderWorkspaceOverlayPanel(
|
||||
title = "Reader",
|
||||
onClose = { leftPanelOpen = false },
|
||||
modifier = Modifier.align(Alignment.CenterStart).width(320.dp)
|
||||
) {
|
||||
leftSidebar()
|
||||
}
|
||||
}
|
||||
if (!wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) {
|
||||
ReaderWorkspaceOverlayPanel(
|
||||
title = "Tools",
|
||||
onClose = { rightPanelOpen = false },
|
||||
modifier = Modifier.align(Alignment.CenterEnd).width(360.dp)
|
||||
) {
|
||||
rightInspector()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
logReaderGapLayout("bottom_bar_slot", coordinates.boundsInWindow())
|
||||
}
|
||||
) {
|
||||
key(isFullscreen) {
|
||||
val immersiveBottomBar = fullscreenBottomBar
|
||||
if (isFullscreen && immersiveBottomBar != null) {
|
||||
immersiveBottomBar()
|
||||
} else {
|
||||
bottomBar()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showChrome) {
|
||||
bottomBar()
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(20.dp)
|
||||
.clickable { chromeVisible = true }
|
||||
)
|
||||
ReaderWorkspaceTopBanner(
|
||||
bannerMessage = if (fullscreenBannerVisible) BannerMessage(fullscreenExitMessage) else null,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderWorkspacePanelOverlays(
|
||||
showLeftPanel: Boolean,
|
||||
showRightPanel: Boolean,
|
||||
wide: Boolean,
|
||||
onCloseLeftPanel: () -> Unit,
|
||||
onCloseRightPanel: () -> Unit,
|
||||
leftSidebar: @Composable (closePanel: () -> Unit) -> Unit,
|
||||
rightInspector: @Composable () -> Unit
|
||||
) {
|
||||
if (!showLeftPanel && !showRightPanel) return
|
||||
|
||||
SharedReaderModalLayer(
|
||||
level = SharedReaderModalLevel.Panel,
|
||||
onDismiss = {
|
||||
if (showLeftPanel) onCloseLeftPanel()
|
||||
if (showRightPanel) onCloseRightPanel()
|
||||
}
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) panelConstraints@ {
|
||||
val availableWidth = this@panelConstraints.maxWidth
|
||||
val leftPanelWidth = if (wide) 340.dp else minOf(320.dp, availableWidth * 0.92f)
|
||||
val rightPanelWidth = if (wide) 380.dp else minOf(360.dp, availableWidth * 0.92f)
|
||||
if (showLeftPanel) {
|
||||
ReaderWorkspaceOverlayPanel(
|
||||
title = "Reader",
|
||||
onClose = onCloseLeftPanel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.width(leftPanelWidth)
|
||||
) {
|
||||
leftSidebar(onCloseLeftPanel)
|
||||
}
|
||||
}
|
||||
if (showRightPanel) {
|
||||
ReaderWorkspaceOverlayPanel(
|
||||
title = "Tools",
|
||||
onClose = onCloseRightPanel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.width(rightPanelWidth)
|
||||
) {
|
||||
rightInspector()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ReaderGapLogTag = "EpistemeReaderGap"
|
||||
|
||||
private fun logReaderGapLayout(
|
||||
layer: String,
|
||||
bounds: Rect,
|
||||
details: String = ""
|
||||
) {
|
||||
logSharedReaderDiagnostic(ReaderGapLogTag) {
|
||||
buildString {
|
||||
append("compose_shell layer=")
|
||||
append(layer)
|
||||
append(" x=")
|
||||
append(bounds.left.roundToInt())
|
||||
append(" y=")
|
||||
append(bounds.top.roundToInt())
|
||||
append(" w=")
|
||||
append(bounds.width.roundToInt())
|
||||
append(" h=")
|
||||
append(bounds.height.roundToInt())
|
||||
append(" bottom=")
|
||||
append(bounds.bottom.roundToInt())
|
||||
if (details.isNotBlank()) {
|
||||
append(' ')
|
||||
append(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,43 +320,109 @@ private fun ReaderWorkspaceTopChrome(
|
|||
title: String,
|
||||
subtitle: String,
|
||||
progressLabel: String,
|
||||
wide: Boolean,
|
||||
topActions: List<ReaderWorkspaceTopAction>,
|
||||
hasLeftPanel: Boolean,
|
||||
hasRightPanel: Boolean,
|
||||
leftPanelOpen: Boolean,
|
||||
rightPanelOpen: Boolean,
|
||||
isBookmarked: Boolean,
|
||||
onReturnToLibrary: (() -> Unit)?,
|
||||
onToggleLeftPanel: () -> Unit,
|
||||
onToggleRightPanel: () -> Unit,
|
||||
topActions: @Composable RowScope.() -> Unit
|
||||
onToggleBookmark: (() -> Unit)?,
|
||||
onSearchAction: (() -> Unit)?,
|
||||
onEnterFullscreen: (() -> Unit)?
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 2.dp
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
IconButton(onClick = onToggleLeftPanel) {
|
||||
Icon(Icons.Default.Menu, contentDescription = if (leftPanelOpen) "Hide reader navigation" else "Show reader navigation")
|
||||
onReturnToLibrary?.let { returnToLibrary ->
|
||||
IconButton(onClick = returnToLibrary, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library")
|
||||
}
|
||||
}
|
||||
if (hasLeftPanel) {
|
||||
IconButton(onClick = onToggleLeftPanel, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Menu, contentDescription = if (leftPanelOpen) "Hide reader navigation" else "Show reader navigation")
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text(progressLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
topActions()
|
||||
}
|
||||
IconButton(onClick = onToggleRightPanel) {
|
||||
Icon(Icons.Default.Tune, contentDescription = if (rightPanelOpen) "Hide reader tools" else "Show reader tools")
|
||||
}
|
||||
if (!wide) {
|
||||
TextButton(onClick = onToggleRightPanel, contentPadding = PaddingValues(horizontal = 8.dp)) {
|
||||
Text("Tools")
|
||||
if (ReaderWorkspaceTopAction.SEARCH in topActions && onSearchAction != null) {
|
||||
IconButton(onClick = onSearchAction, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search in reader")
|
||||
}
|
||||
}
|
||||
if (ReaderWorkspaceTopAction.BOOKMARK in topActions && onToggleBookmark != null) {
|
||||
IconButton(onClick = onToggleBookmark, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
||||
contentDescription = if (isBookmarked) "Remove bookmark" else "Add bookmark"
|
||||
)
|
||||
}
|
||||
}
|
||||
if (ReaderWorkspaceTopAction.FULL_SCREEN in topActions && onEnterFullscreen != null) {
|
||||
IconButton(onClick = onEnterFullscreen, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Fullscreen, contentDescription = "Enter full screen")
|
||||
}
|
||||
}
|
||||
if (hasRightPanel) {
|
||||
IconButton(onClick = onToggleRightPanel, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Tune, contentDescription = if (rightPanelOpen) "Hide reader tools" else "Show reader tools")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderWorkspaceTopBanner(
|
||||
bannerMessage: BannerMessage?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = bannerMessage != null,
|
||||
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(),
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
color = if (bannerMessage?.isError == true) {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Text(
|
||||
text = bannerMessage?.message.orEmpty(),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
color = if (bannerMessage?.isError == true) {
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.LibraryBooks
|
|||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
|
|
@ -32,6 +33,7 @@ import androidx.compose.material.icons.filled.Home
|
|||
import androidx.compose.material.icons.filled.ImportExport
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
|
|
@ -66,6 +68,7 @@ import androidx.compose.ui.unit.dp
|
|||
import com.aryan.reader.shared.AppContrastOption
|
||||
import com.aryan.reader.shared.AppThemeMode
|
||||
import com.aryan.reader.shared.CustomAppTheme
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
|
||||
enum class SharedAppTab {
|
||||
HOME,
|
||||
|
|
@ -73,6 +76,7 @@ enum class SharedAppTab {
|
|||
SHELVES,
|
||||
CATALOGS,
|
||||
READER,
|
||||
SETTINGS,
|
||||
CUSTOM_FONTS,
|
||||
SUPPORT,
|
||||
FEEDBACK,
|
||||
|
|
@ -89,11 +93,13 @@ fun SharedAppShell(
|
|||
appTextDimFactorDark: Float = 1.0f,
|
||||
appSeedColor: Color? = null,
|
||||
customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
isTabsEnabled: Boolean = false,
|
||||
isTabsEnabled: Boolean = true,
|
||||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit = {},
|
||||
onSyncRequested: () -> Unit,
|
||||
onFolderMetadataSyncRequested: (() -> Unit)? = null,
|
||||
onAppThemeModeChange: (AppThemeMode) -> Unit = {},
|
||||
onAppContrastOptionChange: (AppContrastOption) -> Unit = {},
|
||||
onAppTextDimFactorLightChange: (Float) -> Unit = {},
|
||||
|
|
@ -105,10 +111,12 @@ fun SharedAppShell(
|
|||
onAiSettingsRequested: (() -> Unit)? = null,
|
||||
content: @Composable (SharedAppTab) -> Unit
|
||||
) {
|
||||
val shellModel = remember(selectedTab, onAiSettingsRequested != null) {
|
||||
val aiSettingsAvailable = onAiSettingsRequested != null && featurePolicy.aiAndCloud
|
||||
val shellModel = remember(selectedTab, aiSettingsAvailable, featurePolicy) {
|
||||
sharedAppShellModel(
|
||||
selectedTab = selectedTab,
|
||||
aiSettingsAvailable = onAiSettingsRequested != null
|
||||
aiSettingsAvailable = aiSettingsAvailable,
|
||||
featurePolicy = featurePolicy
|
||||
)
|
||||
}
|
||||
var showToolsPanel by remember { mutableStateOf(false) }
|
||||
|
|
@ -126,20 +134,22 @@ fun SharedAppShell(
|
|||
) {
|
||||
val useSidebar = maxWidth >= 900.dp
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
if (useSidebar) {
|
||||
SharedAppSidebar(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
)
|
||||
} else {
|
||||
SharedAppCompactRail(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
)
|
||||
if (shellModel.showPrimaryNavigation) {
|
||||
if (useSidebar) {
|
||||
SharedAppSidebar(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { onTabSelected(SharedAppTab.SETTINGS) }
|
||||
)
|
||||
} else {
|
||||
SharedAppCompactRail(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { onTabSelected(SharedAppTab.SETTINGS) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
|
|
@ -165,7 +175,7 @@ fun SharedAppShell(
|
|||
.fillMaxHeight()
|
||||
.widthIn(max = 390.dp),
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
aiSettingsAvailable = onAiSettingsRequested != null,
|
||||
toolActions = shellModel.toolActions,
|
||||
onClose = { showToolsPanel = false },
|
||||
onImportFiles = {
|
||||
showToolsPanel = false
|
||||
|
|
@ -179,6 +189,12 @@ fun SharedAppShell(
|
|||
showToolsPanel = false
|
||||
onSyncRequested()
|
||||
},
|
||||
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested?.let { syncMetadata ->
|
||||
{
|
||||
showToolsPanel = false
|
||||
syncMetadata()
|
||||
}
|
||||
},
|
||||
onAppThemeRequested = {
|
||||
showToolsPanel = false
|
||||
showAppThemeSettings = true
|
||||
|
|
@ -251,7 +267,7 @@ private fun SharedAppSidebar(
|
|||
Spacer(Modifier.weight(1f))
|
||||
HorizontalDivider()
|
||||
SharedSidebarButton(
|
||||
label = "Tools",
|
||||
label = "Settings",
|
||||
icon = Icons.Default.Settings,
|
||||
onClick = onToolsClick
|
||||
)
|
||||
|
|
@ -277,7 +293,7 @@ private fun SharedAppCompactRail(
|
|||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(onClick = onToolsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Tools")
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -344,16 +360,26 @@ private fun SharedSidebarButton(
|
|||
private fun SharedToolsPanel(
|
||||
modifier: Modifier,
|
||||
isTabsEnabled: Boolean,
|
||||
aiSettingsAvailable: Boolean,
|
||||
toolActions: List<SharedAppToolAction>,
|
||||
onClose: () -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onSyncRequested: () -> Unit,
|
||||
onFolderMetadataSyncRequested: (() -> Unit)?,
|
||||
onAppThemeRequested: () -> Unit,
|
||||
onAiSettingsRequested: () -> Unit,
|
||||
onOpenTab: (SharedAppTab) -> Unit,
|
||||
onTabsEnabledChange: (Boolean) -> Unit
|
||||
) {
|
||||
val hasLibraryActions = SharedAppToolAction.IMPORT_FILES in toolActions ||
|
||||
SharedAppToolAction.IMPORT_FOLDER in toolActions ||
|
||||
SharedAppToolAction.SYNC in toolActions
|
||||
val hasSettingsActions = SharedAppToolAction.AI_SETTINGS in toolActions ||
|
||||
SharedAppToolAction.CUSTOM_FONTS in toolActions
|
||||
val hasProjectActions = SharedAppToolAction.HELP_FEEDBACK in toolActions ||
|
||||
SharedAppToolAction.SUPPORT in toolActions ||
|
||||
SharedAppToolAction.ABOUT in toolActions
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
|
|
@ -377,60 +403,91 @@ private fun SharedToolsPanel(
|
|||
}
|
||||
}
|
||||
|
||||
SharedToolsSection("Library") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Files")
|
||||
if (hasLibraryActions) {
|
||||
SharedToolsSection("Library") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
if (SharedAppToolAction.IMPORT_FILES in toolActions) {
|
||||
Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Import files")
|
||||
}
|
||||
}
|
||||
if (SharedAppToolAction.IMPORT_FOLDER in toolActions) {
|
||||
OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Add folder")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Folder")
|
||||
if (SharedAppToolAction.SYNC in toolActions) {
|
||||
if (onFolderMetadataSyncRequested == null) {
|
||||
FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Sync folders")
|
||||
}
|
||||
} else {
|
||||
SharedToolRow(Icons.Default.Sync, "Sync metadata", onFolderMetadataSyncRequested)
|
||||
SharedToolRow(Icons.Default.Search, "Full scan") {
|
||||
onSyncRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Sync folders")
|
||||
}
|
||||
}
|
||||
|
||||
SharedToolsSection("Appearance") {
|
||||
SharedToolRow(
|
||||
icon = Icons.Default.Palette,
|
||||
title = "App theme",
|
||||
onClick = onAppThemeRequested
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Active reader tabs", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Text(if (isTabsEnabled) "Enabled" else "Disabled", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = isTabsEnabled,
|
||||
onCheckedChange = onTabsEnabledChange
|
||||
if (SharedAppToolAction.APP_THEME in toolActions) {
|
||||
SharedToolRow(
|
||||
icon = Icons.Default.Palette,
|
||||
title = "App theme",
|
||||
onClick = onAppThemeRequested
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SharedToolsSection("Settings") {
|
||||
if (aiSettingsAvailable) {
|
||||
SharedToolRow(Icons.Default.Settings, "AI keys and models", onAiSettingsRequested)
|
||||
if (SharedAppToolAction.TABS_TOGGLE in toolActions) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Active reader tabs", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Text(if (isTabsEnabled) "Enabled" else "Disabled", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = isTabsEnabled,
|
||||
onCheckedChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedToolRow(Icons.Default.TextFields, "Custom fonts") { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
|
||||
}
|
||||
|
||||
SharedToolsSection("Project") {
|
||||
SharedToolRow(Icons.Default.Feedback, "Help & feedback") { onOpenTab(SharedAppTab.FEEDBACK) }
|
||||
SharedToolRow(Icons.Default.Favorite, "Support project") { onOpenTab(SharedAppTab.SUPPORT) }
|
||||
SharedToolRow(Icons.Default.Info, "About Episteme") { onOpenTab(SharedAppTab.ABOUT) }
|
||||
if (hasSettingsActions) {
|
||||
SharedToolsSection("Settings") {
|
||||
if (SharedAppToolAction.AI_SETTINGS in toolActions) {
|
||||
SharedToolRow(Icons.Default.Settings, "AI keys and models", onAiSettingsRequested)
|
||||
}
|
||||
if (SharedAppToolAction.CUSTOM_FONTS in toolActions) {
|
||||
SharedToolRow(Icons.Default.TextFields, "Custom fonts") { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasProjectActions) {
|
||||
SharedToolsSection("Project") {
|
||||
if (SharedAppToolAction.HELP_FEEDBACK in toolActions) {
|
||||
SharedToolRow(Icons.Default.Feedback, "Help & feedback") { onOpenTab(SharedAppTab.FEEDBACK) }
|
||||
}
|
||||
if (SharedAppToolAction.SUPPORT in toolActions) {
|
||||
SharedToolRow(Icons.Default.Favorite, "Support project") { onOpenTab(SharedAppTab.SUPPORT) }
|
||||
}
|
||||
if (SharedAppToolAction.ABOUT in toolActions) {
|
||||
SharedToolRow(Icons.Default.Info, "About Episteme") { onOpenTab(SharedAppTab.ABOUT) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
|
@ -479,6 +536,7 @@ private val SharedAppTab.label: String
|
|||
SharedAppTab.SHELVES -> "Shelves"
|
||||
SharedAppTab.CATALOGS -> "OPDS"
|
||||
SharedAppTab.READER -> "Reader"
|
||||
SharedAppTab.SETTINGS -> "Settings"
|
||||
SharedAppTab.CUSTOM_FONTS -> "Custom fonts"
|
||||
SharedAppTab.SUPPORT -> "Support"
|
||||
SharedAppTab.FEEDBACK -> "Feedback"
|
||||
|
|
@ -492,6 +550,7 @@ private val SharedAppTab.icon: ImageVector
|
|||
SharedAppTab.SHELVES -> Icons.Default.Folder
|
||||
SharedAppTab.CATALOGS -> Icons.Default.Cloud
|
||||
SharedAppTab.READER -> Icons.AutoMirrored.Filled.MenuBook
|
||||
SharedAppTab.SETTINGS -> Icons.Default.Settings
|
||||
SharedAppTab.CUSTOM_FONTS -> Icons.Default.TextFields
|
||||
SharedAppTab.SUPPORT -> Icons.Default.Favorite
|
||||
SharedAppTab.FEEDBACK -> Icons.Default.Feedback
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.horizontalScroll
|
|||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.Spacer
|
||||
|
|
@ -40,8 +41,8 @@ import androidx.compose.material3.HorizontalDivider
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Typography
|
||||
|
|
@ -65,7 +66,9 @@ import androidx.compose.ui.graphics.luminance
|
|||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -75,7 +78,13 @@ import com.aryan.reader.shared.AppThemeMode
|
|||
import com.aryan.reader.shared.CustomAppTheme
|
||||
import com.materialkolor.PaletteStyle
|
||||
import com.materialkolor.dynamicColorScheme
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.random.Random
|
||||
|
||||
private val SharedLightColorScheme = lightColorScheme(
|
||||
|
|
@ -524,7 +533,7 @@ private fun SharedCreateAppThemeDialog(
|
|||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
SharedStableOutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Theme name") },
|
||||
|
|
@ -616,7 +625,248 @@ private fun SharedCreateAppThemeDialog(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSpectrumBox(
|
||||
fun SharedHsvColorPickerDialog(
|
||||
initialColor: Color,
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Color) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
preview: @Composable (Color) -> Unit = {}
|
||||
) {
|
||||
var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) }
|
||||
val color = hsv.toComposeColor()
|
||||
|
||||
fun updateFromColor(nextColor: Color) {
|
||||
hsv = nextColor.toSharedHsvColor()
|
||||
}
|
||||
|
||||
SharedReaderModalLayer(onDismiss = onDismiss) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val dialogHorizontalPadding = 24.dp
|
||||
val dialogAvailableWidth = (maxWidth - dialogHorizontalPadding - dialogHorizontalPadding).coerceAtLeast(0.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(dialogHorizontalPadding)
|
||||
.width(sharedReaderPopupWidth(dialogAvailableWidth))
|
||||
.heightIn(max = 600.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 16.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
preview(color)
|
||||
|
||||
SharedHsvWheel(
|
||||
hue = hsv.hue,
|
||||
saturation = hsv.saturation,
|
||||
currentColor = color,
|
||||
onHueSatChanged = { hue, saturation ->
|
||||
hsv = hsv.copy(hue = hue, saturation = saturation)
|
||||
},
|
||||
modifier = Modifier.size(240.dp)
|
||||
)
|
||||
|
||||
SharedBrightnessSlider(
|
||||
hue = hsv.hue,
|
||||
saturation = hsv.saturation,
|
||||
value = hsv.value,
|
||||
onValueChanged = { hsv = hsv.copy(value = it) },
|
||||
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SharedColorComparePill(
|
||||
oldColor = initialColor,
|
||||
newColor = color,
|
||||
modifier = Modifier.width(64.dp).height(36.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1.6f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SharedHexInput(color = color, onHexChanged = { updateFromColor(it) })
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(2.4f),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
SharedRgbInputColumn(
|
||||
label = "R",
|
||||
value = color.red,
|
||||
onValueChange = { updateFromColor(color.copy(red = it)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SharedRgbInputColumn(
|
||||
label = "G",
|
||||
value = color.green,
|
||||
onValueChange = { updateFromColor(color.copy(green = it)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SharedRgbInputColumn(
|
||||
label = "B",
|
||||
value = color.blue,
|
||||
onValueChange = { updateFromColor(color.copy(blue = it)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(
|
||||
onClick = { onSave(color) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = color,
|
||||
contentColor = if (color.luminance() > 0.5f) Color.Black else Color.White
|
||||
)
|
||||
) {
|
||||
Text("Save", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedHsvWheel(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
currentColor: Color,
|
||||
onHueSatChanged: (Float, Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val touchPadding = 12.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
val paddingPx = touchPadding.toPx()
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val selection = sharedHsvWheelSelection(
|
||||
offsetX = offset.x,
|
||||
offsetY = offset.y,
|
||||
width = size.width.toFloat(),
|
||||
height = size.height.toFloat(),
|
||||
paddingPx = paddingPx
|
||||
)
|
||||
onHueSatChanged(selection.hue, selection.saturation)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val wheelRadius = ((min(size.width, size.height) - (paddingPx * 2f)) / 2f).coerceAtLeast(1f)
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val topLeft = Offset(center.x - wheelRadius, center.y - wheelRadius)
|
||||
val wheelSize = Size(wheelRadius * 2f, wheelRadius * 2f)
|
||||
val segments = 180
|
||||
val sweep = 360f / segments
|
||||
|
||||
repeat(segments) { index ->
|
||||
val segmentHue = index * sweep
|
||||
drawArc(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color.White, Color.hsv(segmentHue, 1f, 1f)),
|
||||
center = center,
|
||||
radius = wheelRadius
|
||||
),
|
||||
startAngle = segmentHue,
|
||||
sweepAngle = sweep + 0.8f,
|
||||
useCenter = true,
|
||||
topLeft = topLeft,
|
||||
size = wheelSize
|
||||
)
|
||||
}
|
||||
|
||||
drawCircle(
|
||||
color = Color.Black.copy(alpha = 0.16f),
|
||||
radius = wheelRadius,
|
||||
center = center,
|
||||
style = Stroke(width = 1.dp.toPx())
|
||||
)
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val wheelRadius = ((min(size.width, size.height) - (paddingPx * 2f)) / 2f).coerceAtLeast(1f)
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val angle = hue.normalizedHue().toDouble() * PI / 180.0
|
||||
val radius = saturation.coerceIn(0f, 1f) * wheelRadius
|
||||
val pointer = Offset(
|
||||
x = center.x + (cos(angle).toFloat() * radius),
|
||||
y = center.y + (sin(angle).toFloat() * radius)
|
||||
)
|
||||
val pointerRadius = 10.dp.toPx()
|
||||
val strokeWidth = 2.dp.toPx()
|
||||
|
||||
drawCircle(
|
||||
color = Color.Black.copy(alpha = 0.25f),
|
||||
radius = pointerRadius + 1.dp.toPx(),
|
||||
center = Offset(pointer.x, pointer.y + 1.dp.toPx())
|
||||
)
|
||||
drawCircle(
|
||||
color = currentColor.copy(alpha = 1f),
|
||||
radius = pointerRadius,
|
||||
center = pointer
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = pointerRadius,
|
||||
center = pointer,
|
||||
style = Stroke(width = strokeWidth)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedSpectrumBox(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
currentColor: Color,
|
||||
|
|
@ -702,7 +952,7 @@ private fun SharedSpectrumBox(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBrightnessSlider(
|
||||
fun SharedBrightnessSlider(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
value: Float,
|
||||
|
|
@ -747,7 +997,7 @@ private fun SharedBrightnessSlider(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedRgbInputColumn(
|
||||
fun SharedRgbInputColumn(
|
||||
label: String,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
|
|
@ -774,13 +1024,17 @@ private fun SharedRgbInput(
|
|||
value: Int,
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
var text by remember(value) { mutableStateOf(value.coerceIn(0, 255).toString()) }
|
||||
var textFieldValue by remember(value) {
|
||||
val text = value.coerceIn(0, 255).toString()
|
||||
mutableStateOf(TextFieldValue(text, TextRange(text.length)))
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
value = textFieldValue,
|
||||
onValueChange = { nextValue ->
|
||||
val newText = nextValue.text
|
||||
if (newText.length <= 3 && newText.all { it.isDigit() }) {
|
||||
text = newText
|
||||
textFieldValue = nextValue
|
||||
newText.toIntOrNull()?.let { channel ->
|
||||
onValueChange(channel.coerceIn(0, 255) / 255f)
|
||||
}
|
||||
|
|
@ -802,12 +1056,14 @@ private fun SharedRgbInput(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedHexInput(
|
||||
fun SharedHexInput(
|
||||
color: Color,
|
||||
onHexChanged: (Color) -> Unit
|
||||
) {
|
||||
val hexValue = color.toSharedHexString().removePrefix("#")
|
||||
var text by remember(hexValue) { mutableStateOf(hexValue) }
|
||||
var textFieldValue by remember(hexValue) {
|
||||
mutableStateOf(TextFieldValue(hexValue, TextRange(hexValue.length)))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -825,12 +1081,16 @@ private fun SharedHexInput(
|
|||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
value = textFieldValue,
|
||||
onValueChange = { nextValue ->
|
||||
val newText = nextValue.text
|
||||
if (newText.length <= 6) {
|
||||
val uppercased = newText.uppercase()
|
||||
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
|
||||
text = uppercased
|
||||
textFieldValue = nextValue.copy(
|
||||
text = uppercased,
|
||||
selection = TextRange(nextValue.selection.end.coerceIn(0, uppercased.length))
|
||||
)
|
||||
if (uppercased.length == 6) {
|
||||
uppercased.toSharedHexColorOrNull()?.let(onHexChanged)
|
||||
}
|
||||
|
|
@ -852,7 +1112,7 @@ private fun SharedHexInput(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedColorComparePill(
|
||||
fun SharedColorComparePill(
|
||||
oldColor: Color,
|
||||
newColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
|
|
@ -943,6 +1203,27 @@ internal data class SharedHsvColor(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun sharedHsvWheelSelection(
|
||||
offsetX: Float,
|
||||
offsetY: Float,
|
||||
width: Float,
|
||||
height: Float,
|
||||
paddingPx: Float = 0f
|
||||
): SharedHsvColor {
|
||||
val wheelRadius = ((min(width, height) - (paddingPx * 2f)) / 2f).coerceAtLeast(1f)
|
||||
val centerX = width / 2f
|
||||
val centerY = height / 2f
|
||||
val dx = offsetX - centerX
|
||||
val dy = offsetY - centerY
|
||||
val hue = (atan2(dy.toDouble(), dx.toDouble()) * 180.0 / PI).toFloat().normalizedHue()
|
||||
val saturation = (sqrt(((dx * dx) + (dy * dy)).toDouble()).toFloat() / wheelRadius).coerceIn(0f, 1f)
|
||||
return SharedHsvColor(
|
||||
hue = hue,
|
||||
saturation = saturation,
|
||||
value = 1f
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Color.toSharedHsvColor(): SharedHsvColor {
|
||||
val maximum = maxOf(red, green, blue)
|
||||
val minimum = minOf(red, green, blue)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,44 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.PaddingValues
|
||||
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.heightIn
|
||||
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.items
|
||||
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.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -28,9 +49,17 @@ 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.clipToBounds
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.Shelf
|
||||
import com.aryan.reader.shared.Tag
|
||||
import com.aryan.reader.shared.cardTitle
|
||||
|
|
@ -51,7 +80,7 @@ fun SharedTextInputDialog(
|
|||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
SharedStableOutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
label = { Text(label) },
|
||||
|
|
@ -121,7 +150,12 @@ fun SharedAddToShelfDialog(
|
|||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(shelf.name, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
shelf.name,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text("${shelf.bookCount}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
|
@ -145,98 +179,719 @@ fun SharedAddToShelfDialog(
|
|||
@Composable
|
||||
fun SharedBookInfoDialog(
|
||||
book: BookItem,
|
||||
knownTags: List<Tag> = emptyList(),
|
||||
initiallyEditing: Boolean = false,
|
||||
canEditEmbeddedMetadata: Boolean = book.type == FileType.EPUB,
|
||||
canRenameDisplayName: Boolean = true,
|
||||
canRestoreEmbeddedMetadata: Boolean = canEditEmbeddedMetadata,
|
||||
onDismiss: () -> Unit,
|
||||
onEdit: () -> Unit
|
||||
onSave: (BookItem) -> Unit,
|
||||
onRestore: (BookItem) -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(book.cardTitle()) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SharedInfoRow("File", book.displayName)
|
||||
SharedInfoRow("Type", book.type.name)
|
||||
SharedInfoRow("Author", book.author.orEmpty().ifBlank { "Unknown" })
|
||||
SharedInfoRow("Path", book.path.orEmpty().ifBlank { "Not available" })
|
||||
SharedInfoRow("Size", formatFileSize(book.fileSize))
|
||||
SharedInfoRow("Progress", "${(book.progressPercentage ?: 0f).toInt()}%")
|
||||
if (!book.seriesName.isNullOrBlank()) {
|
||||
SharedInfoRow("Series", listOfNotNull(book.seriesName, book.seriesIndex?.toString()).joinToString(" #"))
|
||||
}
|
||||
if (book.tags.isNotEmpty()) {
|
||||
SharedInfoRow("Tags", book.tags.joinToString { it.name })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onEdit) {
|
||||
Text("Edit")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var isEditing by remember(book.id, initiallyEditing) { mutableStateOf(initiallyEditing) }
|
||||
var titleInput by remember(book.id, book.title) { mutableStateOf(book.title.orEmpty()) }
|
||||
var authorInput by remember(book.id, book.author) { mutableStateOf(book.author.orEmpty()) }
|
||||
var seriesInput by remember(book.id, book.seriesName) { mutableStateOf(book.seriesName.orEmpty()) }
|
||||
var seriesIndexInput by remember(book.id, book.seriesIndex) {
|
||||
mutableStateOf(book.seriesIndex?.formatMetadataNumber().orEmpty())
|
||||
}
|
||||
var descriptionInput by remember(book.id, book.description) { mutableStateOf(book.description.orEmpty()) }
|
||||
var displayNameInput by remember(book.id, book.displayName) { mutableStateOf(book.displayName) }
|
||||
var tagInput by remember(book.id, book.tags) { mutableStateOf(book.tags.joinToString(", ") { it.name }) }
|
||||
var showRestoreConfirmation by remember(book.id) { mutableStateOf(false) }
|
||||
|
||||
@Composable
|
||||
fun SharedBookEditDialog(
|
||||
book: BookItem,
|
||||
knownTags: List<Tag>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (BookItem) -> Unit
|
||||
) {
|
||||
var title by remember(book.id) { mutableStateOf(book.title.orEmpty()) }
|
||||
var author by remember(book.id) { mutableStateOf(book.author.orEmpty()) }
|
||||
var seriesName by remember(book.id) { mutableStateOf(book.seriesName.orEmpty()) }
|
||||
var seriesIndex by remember(book.id) { mutableStateOf(book.seriesIndex?.toString().orEmpty()) }
|
||||
var tagText by remember(book.id) { mutableStateOf(book.tags.joinToString(", ") { it.name }) }
|
||||
val hasOriginalMetadata = book.hasOriginalMetadata()
|
||||
val hasMetadataChanges = book.hasMetadataChanges()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit book") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = author, onValueChange = { author = it }, label = { Text("Author") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = seriesName, onValueChange = { seriesName = it }, label = { Text("Series") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = seriesIndex, onValueChange = { seriesIndex = it }, label = { Text("Series index") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = tagText, onValueChange = { tagText = it }, label = { Text("Tags, comma separated") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
if (knownTags.isNotEmpty()) {
|
||||
Text("Existing: ${knownTags.joinToString { it.name }}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
if (isEditing) {
|
||||
isEditing = false
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSave(
|
||||
book.copy(
|
||||
title = title.trim().ifBlank { null },
|
||||
author = author.trim().ifBlank { null },
|
||||
seriesName = seriesName.trim().ifBlank { null },
|
||||
seriesIndex = seriesIndex.toDoubleOrNull(),
|
||||
tags = parseTagList(tagText, knownTags)
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
SharedBookInfoTopBar(
|
||||
title = if (isEditing) {
|
||||
if (canEditEmbeddedMetadata) "Edit EPUB metadata" else "Rename in app"
|
||||
} else {
|
||||
"Book information"
|
||||
},
|
||||
subtitle = book.cardTitle(),
|
||||
onClose = {
|
||||
if (isEditing) {
|
||||
isEditing = false
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (isEditing) {
|
||||
if (canEditEmbeddedMetadata) {
|
||||
SharedBookMetadataEditContent(
|
||||
titleInput = titleInput,
|
||||
onTitleChange = { titleInput = it },
|
||||
authorInput = authorInput,
|
||||
onAuthorChange = { authorInput = it },
|
||||
seriesInput = seriesInput,
|
||||
onSeriesChange = { seriesInput = it },
|
||||
seriesIndexInput = seriesIndexInput,
|
||||
onSeriesIndexChange = { seriesIndexInput = it },
|
||||
descriptionInput = descriptionInput,
|
||||
onDescriptionChange = { descriptionInput = it },
|
||||
tagInput = tagInput,
|
||||
onTagChange = { tagInput = it },
|
||||
knownTags = knownTags
|
||||
)
|
||||
} else if (canRenameDisplayName) {
|
||||
SharedBookDisplayNameEditContent(
|
||||
displayNameInput = displayNameInput,
|
||||
onDisplayNameChange = { displayNameInput = it },
|
||||
tagInput = tagInput,
|
||||
onTagChange = { tagInput = it },
|
||||
knownTags = knownTags
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SharedBookMetadataInfoContent(
|
||||
book = book,
|
||||
hasMetadataChanges = hasMetadataChanges,
|
||||
onCopyPath = {
|
||||
book.path?.takeIf { it.isNotBlank() }?.let { clipboard.setText(AnnotatedString(it)) }
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SharedBookInfoBottomBar(
|
||||
isEditing = isEditing,
|
||||
canEdit = canEditEmbeddedMetadata || canRenameDisplayName,
|
||||
canRestore = canRestoreEmbeddedMetadata && hasOriginalMetadata && (hasMetadataChanges || isEditing),
|
||||
editLabel = if (canEditEmbeddedMetadata) "Edit metadata" else "Rename",
|
||||
onCancel = {
|
||||
if (isEditing) {
|
||||
isEditing = false
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onRestore = { showRestoreConfirmation = true },
|
||||
onSave = {
|
||||
val updated = if (canEditEmbeddedMetadata) {
|
||||
book.copy(
|
||||
title = titleInput.toMetadataValue()
|
||||
?: book.displayName.substringBeforeLast('.', book.displayName),
|
||||
author = authorInput.toMetadataValue(),
|
||||
seriesName = seriesInput.toMetadataValue(),
|
||||
seriesIndex = seriesIndexInput.toSeriesIndexOrNull(),
|
||||
description = descriptionInput.toMetadataValue(),
|
||||
originalTitle = book.originalTitle ?: book.title,
|
||||
originalAuthor = book.originalAuthor ?: book.author,
|
||||
originalSeriesName = book.originalSeriesName ?: book.seriesName,
|
||||
originalSeriesIndex = book.originalSeriesIndex ?: book.seriesIndex,
|
||||
originalDescription = book.originalDescription ?: book.description,
|
||||
tags = parseTagList(tagInput, knownTags)
|
||||
)
|
||||
} else {
|
||||
book.copy(
|
||||
displayName = displayNameInput.toMetadataValue() ?: book.displayName,
|
||||
tags = parseTagList(tagInput, knownTags)
|
||||
)
|
||||
}
|
||||
onSave(updated)
|
||||
onDismiss()
|
||||
},
|
||||
onEdit = { isEditing = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInfoRow(label: String, value: String) {
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium)
|
||||
if (showRestoreConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRestoreConfirmation = false },
|
||||
icon = { Icon(Icons.Default.Restore, contentDescription = null) },
|
||||
title = { Text("Restore original metadata?") },
|
||||
text = {
|
||||
Text(
|
||||
"This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
showRestoreConfirmation = false
|
||||
onRestore(book.restoredOriginalMetadata())
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text("Restore")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRestoreConfirmation = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBookInfoTopBar(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBookMetadataInfoContent(
|
||||
book: BookItem,
|
||||
hasMetadataChanges: Boolean,
|
||||
onCopyPath: () -> Unit
|
||||
) {
|
||||
SharedInfoCard {
|
||||
Text(
|
||||
book.cardTitle(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
book.author
|
||||
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
val provenance = when {
|
||||
book.type == FileType.EPUB && hasMetadataChanges -> "EPUB metadata edited"
|
||||
book.type == FileType.EPUB -> "Metadata from EPUB file"
|
||||
hasMetadataChanges -> "Display name changed in app"
|
||||
else -> "Metadata from file"
|
||||
}
|
||||
Text(
|
||||
provenance,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (hasMetadataChanges) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Metadata") {
|
||||
SharedInfoRowDetailed("Title", book.title?.takeIf { it.isNotBlank() } ?: book.displayName, maxLines = 3)
|
||||
book.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
|
||||
SharedInfoRowDetailed("Author", it, maxLines = 2)
|
||||
}
|
||||
book.seriesLabel()?.let {
|
||||
SharedInfoRowDetailed("Series", it, maxLines = 2)
|
||||
}
|
||||
SharedInfoRowDetailed("Format", book.type.name)
|
||||
SharedInfoRowDetailed("Size", formatFileSize(book.fileSize))
|
||||
SharedInfoRowDetailed("Reading", book.readingProgressText(), maxLines = 2)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "File") {
|
||||
SharedInfoRowDetailed("File name", book.displayName, maxLines = 2)
|
||||
SharedInfoRowDetailed("Location", book.path.orEmpty().ifBlank { "Not available" }, maxLines = 4, onCopy = onCopyPath)
|
||||
book.sourceFolder?.takeIf { it.isNotBlank() }?.let {
|
||||
SharedInfoRowDetailed("Source folder", it, maxLines = 3)
|
||||
}
|
||||
}
|
||||
|
||||
book.description?.takeIf { it.isNotBlank() }?.let { summary ->
|
||||
SharedInfoSection(title = "Summary") {
|
||||
SharedExpandableSummaryText(summary, collapsedMaxLines = 4)
|
||||
}
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Tags") {
|
||||
if (book.tags.isEmpty()) {
|
||||
Text(
|
||||
"No tags assigned",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
book.tags.forEach { tag ->
|
||||
AssistChip(onClick = {}, label = { Text(tag.name) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBookMetadataEditContent(
|
||||
titleInput: String,
|
||||
onTitleChange: (String) -> Unit,
|
||||
authorInput: String,
|
||||
onAuthorChange: (String) -> Unit,
|
||||
seriesInput: String,
|
||||
onSeriesChange: (String) -> Unit,
|
||||
seriesIndexInput: String,
|
||||
onSeriesIndexChange: (String) -> Unit,
|
||||
descriptionInput: String,
|
||||
onDescriptionChange: (String) -> Unit,
|
||||
tagInput: String,
|
||||
onTagChange: (String) -> Unit,
|
||||
knownTags: List<Tag>
|
||||
) {
|
||||
SharedInfoSection(title = "Editable metadata") {
|
||||
SharedStableOutlinedTextField(
|
||||
value = titleInput,
|
||||
onValueChange = onTitleChange,
|
||||
label = { Text("Title") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "title"
|
||||
)
|
||||
SharedStableOutlinedTextField(
|
||||
value = authorInput,
|
||||
onValueChange = onAuthorChange,
|
||||
label = { Text("Author") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 2,
|
||||
selectionKey = "author"
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = seriesInput,
|
||||
onValueChange = onSeriesChange,
|
||||
label = { Text("Series") },
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 2,
|
||||
selectionKey = "series"
|
||||
)
|
||||
SharedStableOutlinedTextField(
|
||||
value = seriesIndexInput,
|
||||
onValueChange = onSeriesIndexChange,
|
||||
label = { Text("#") },
|
||||
modifier = Modifier.width(96.dp),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
selectionKey = "seriesIndex"
|
||||
)
|
||||
}
|
||||
SharedStableOutlinedTextField(
|
||||
value = descriptionInput,
|
||||
onValueChange = onDescriptionChange,
|
||||
label = { Text("Summary") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp),
|
||||
minLines = 4,
|
||||
maxLines = 10,
|
||||
selectionKey = "description"
|
||||
)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Library tags") {
|
||||
SharedStableOutlinedTextField(
|
||||
value = tagInput,
|
||||
onValueChange = onTagChange,
|
||||
label = { Text("Tags, comma separated") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "tags"
|
||||
)
|
||||
if (knownTags.isNotEmpty()) {
|
||||
Text(
|
||||
"Existing: ${knownTags.joinToString { it.name }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBookDisplayNameEditContent(
|
||||
displayNameInput: String,
|
||||
onDisplayNameChange: (String) -> Unit,
|
||||
tagInput: String,
|
||||
onTagChange: (String) -> Unit,
|
||||
knownTags: List<Tag>
|
||||
) {
|
||||
SharedInfoSection(title = "Display name") {
|
||||
SharedStableOutlinedTextField(
|
||||
value = displayNameInput,
|
||||
onValueChange = onDisplayNameChange,
|
||||
label = { Text("Name shown in Reader") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "displayName"
|
||||
)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Library tags") {
|
||||
SharedStableOutlinedTextField(
|
||||
value = tagInput,
|
||||
onValueChange = onTagChange,
|
||||
label = { Text("Tags, comma separated") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "renameTags"
|
||||
)
|
||||
if (knownTags.isNotEmpty()) {
|
||||
Text(
|
||||
"Existing: ${knownTags.joinToString { it.name }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBookInfoBottomBar(
|
||||
isEditing: Boolean,
|
||||
canEdit: Boolean,
|
||||
canRestore: Boolean,
|
||||
editLabel: String,
|
||||
onCancel: () -> Unit,
|
||||
onRestore: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onEdit: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (canRestore) {
|
||||
OutlinedButton(
|
||||
onClick = onRestore,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Restore")
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(if (isEditing) "Cancel" else "Close")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (isEditing) {
|
||||
Button(onClick = onSave) {
|
||||
Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Save")
|
||||
}
|
||||
} else if (canEdit) {
|
||||
Button(onClick = onEdit) {
|
||||
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(editLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInfoSection(
|
||||
title: String,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedInfoCard {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInfoCard(content: @Composable ColumnScope.() -> Unit) {
|
||||
OutlinedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInfoRowDetailed(
|
||||
label: String,
|
||||
value: String,
|
||||
maxLines: Int = 1,
|
||||
onCopy: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.width(112.dp)
|
||||
.padding(top = 2.dp)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
SharedExpandableValueText(value, collapsedMaxLines = maxLines)
|
||||
}
|
||||
if (onCopy != null && value != "Not available") {
|
||||
TextButton(
|
||||
onClick = onCopy,
|
||||
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(30.dp)
|
||||
) {
|
||||
Text("Copy")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedExpandableValueText(
|
||||
value: String,
|
||||
collapsedMaxLines: Int
|
||||
) {
|
||||
var expanded by remember(value) { mutableStateOf(false) }
|
||||
val canExpand = collapsedMaxLines < Int.MAX_VALUE && (value.length > 120 || value.contains('\n'))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else collapsedMaxLines,
|
||||
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
if (canExpand) {
|
||||
SharedMoreButton(expanded = expanded, onClick = { expanded = !expanded })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedExpandableSummaryText(
|
||||
value: String,
|
||||
collapsedMaxLines: Int
|
||||
) {
|
||||
var expanded by remember(value) { mutableStateOf(false) }
|
||||
val renderableSummary = remember(value) {
|
||||
if (value.looksLikeHtml()) value.htmlToMarkdownSummary() else value
|
||||
}
|
||||
val canExpand = value.length > 220 || value.count { it == '\n' } >= collapsedMaxLines || value.looksLikeHtml()
|
||||
val contentModifier = if (expanded) {
|
||||
Modifier.fillMaxWidth()
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = (collapsedMaxLines * 26).dp)
|
||||
.clipToBounds()
|
||||
}
|
||||
|
||||
Box(modifier = contentModifier) {
|
||||
SharedMarkdownText(
|
||||
markdown = renderableSummary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
if (canExpand) {
|
||||
SharedMoreButton(expanded = expanded, onClick = { expanded = !expanded })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedMoreButton(
|
||||
expanded: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.height(32.dp)
|
||||
) {
|
||||
Text(if (expanded) "Less" else "...more")
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun BookItem.hasOriginalMetadata(): Boolean {
|
||||
return listOf(originalTitle, originalAuthor, originalSeriesName, originalDescription).any { !it.isNullOrBlank() } ||
|
||||
originalSeriesIndex != null
|
||||
}
|
||||
|
||||
private fun BookItem.hasMetadataChanges(): Boolean {
|
||||
return metadataValueChanged(title, originalTitle) ||
|
||||
metadataValueChanged(author, originalAuthor) ||
|
||||
metadataValueChanged(seriesName, originalSeriesName) ||
|
||||
seriesIndex != originalSeriesIndex ||
|
||||
metadataValueChanged(description, originalDescription)
|
||||
}
|
||||
|
||||
private fun BookItem.restoredOriginalMetadata(): BookItem {
|
||||
return copy(
|
||||
title = originalTitle?.takeIf { it.isNotBlank() } ?: displayName.substringBeforeLast('.', displayName),
|
||||
author = originalAuthor,
|
||||
seriesName = originalSeriesName,
|
||||
seriesIndex = originalSeriesIndex,
|
||||
description = originalDescription
|
||||
)
|
||||
}
|
||||
|
||||
private fun metadataValueChanged(current: String?, original: String?): Boolean {
|
||||
return current.orEmpty().trim() != original.orEmpty().trim()
|
||||
}
|
||||
|
||||
private fun BookItem.seriesLabel(): String? {
|
||||
val series = seriesName?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return seriesIndex?.takeIf { it > 0.0 }?.let { "$series #${it.formatMetadataNumber()}" } ?: series
|
||||
}
|
||||
|
||||
private fun BookItem.readingProgressText(): String {
|
||||
val progress = progressPercentage?.coerceIn(0f, 100f)
|
||||
val progressText = progress?.toDouble()?.formatMetadataNumber()?.let { "$it%" } ?: "Not started"
|
||||
val chapterIndex = readerPosition?.chapterIndex
|
||||
val locatorText = when {
|
||||
lastPageIndex != null -> "Last page ${lastPageIndex + 1}"
|
||||
chapterIndex != null -> "Chapter ${chapterIndex + 1}"
|
||||
else -> null
|
||||
}
|
||||
return listOfNotNull(progressText, locatorText).joinToString(" - ")
|
||||
}
|
||||
|
||||
private fun String.toMetadataValue(): String? {
|
||||
return trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun String.toSeriesIndexOrNull(): Double? {
|
||||
return trim()
|
||||
.replace(',', '.')
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.toDoubleOrNull()
|
||||
?.takeIf { it > 0.0 }
|
||||
}
|
||||
|
||||
private fun Double.formatMetadataNumber(): String {
|
||||
val whole = toLong()
|
||||
return if (this == whole.toDouble()) whole.toString() else toString().trimEnd('0').trimEnd('.')
|
||||
}
|
||||
|
||||
private fun String.looksLikeHtml(): Boolean {
|
||||
return contains(Regex("<\\s*/?\\s*(p|br|div|span|strong|em|ul|ol|li|h[1-6]|blockquote|a|b|i)\\b", RegexOption.IGNORE_CASE)) ||
|
||||
contains(Regex("&(#\\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);"))
|
||||
}
|
||||
|
||||
private fun String.htmlToMarkdownSummary(): String {
|
||||
var text = decodeHtmlEntities()
|
||||
.replace(Regex("(?is)<script\\b.*?</script>"), "")
|
||||
.replace(Regex("(?is)<style\\b.*?</style>"), "")
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("(?i)</(p|div|section|article)\\s*>"), "\n\n")
|
||||
.replace(Regex("(?i)<li\\b[^>]*>"), "\n- ")
|
||||
.replace(Regex("(?i)</li\\s*>"), "")
|
||||
.replace(Regex("(?i)</?(ul|ol)\\b[^>]*>"), "\n")
|
||||
.replace(Regex("(?is)<h1\\b[^>]*>(.*?)</h1>")) { "# ${it.groupValues[1].stripHtmlTags()}\n\n" }
|
||||
.replace(Regex("(?is)<h2\\b[^>]*>(.*?)</h2>")) { "## ${it.groupValues[1].stripHtmlTags()}\n\n" }
|
||||
.replace(Regex("(?is)<h[3-6]\\b[^>]*>(.*?)</h[3-6]>")) { "### ${it.groupValues[1].stripHtmlTags()}\n\n" }
|
||||
.replace(Regex("(?is)<(strong|b)\\b[^>]*>(.*?)</(strong|b)>")) { "**${it.groupValues[2].stripHtmlTags()}**" }
|
||||
.replace(Regex("(?is)<(em|i)\\b[^>]*>(.*?)</(em|i)>")) { "*${it.groupValues[2].stripHtmlTags()}*" }
|
||||
.replace(Regex("(?is)<blockquote\\b[^>]*>(.*?)</blockquote>")) {
|
||||
it.groupValues[1].stripHtmlTags().lines().joinToString("\n") { line -> "> $line" } + "\n\n"
|
||||
}
|
||||
.replace(Regex("(?is)<a\\b[^>]*>(.*?)</a>")) { it.groupValues[1].stripHtmlTags() }
|
||||
.stripHtmlTags()
|
||||
.decodeHtmlEntities()
|
||||
|
||||
text = text
|
||||
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
|
||||
.replace(Regex(" *\\n *"), "\n")
|
||||
.replace(Regex("\\n{3,}"), "\n\n")
|
||||
.trim()
|
||||
return text
|
||||
}
|
||||
|
||||
private fun String.stripHtmlTags(): String {
|
||||
return replace(Regex("<[^>]+>"), " ")
|
||||
}
|
||||
|
||||
private fun String.decodeHtmlEntities(): String {
|
||||
return replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
.replace(Regex("&#(\\d+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -47,12 +47,10 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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
|
||||
|
|
@ -88,6 +86,9 @@ fun SharedOpdsScreen(
|
|||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit = { entry, coverModifier ->
|
||||
SharedOpdsCoverPlaceholder(entry, coverModifier)
|
||||
},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
|
||||
|
|
@ -121,7 +122,8 @@ fun SharedOpdsScreen(
|
|||
onDownloadBook = onDownloadBook,
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = { entry -> onStreamBook(entry, state.currentCatalog) },
|
||||
onEntrySelected = { selectedEntry = it }
|
||||
onEntrySelected = { selectedEntry = it },
|
||||
coverContent = coverContent
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +215,8 @@ fun SharedOpdsScreen(
|
|||
onSearch = { query ->
|
||||
onSearch(query)
|
||||
selectedEntry = null
|
||||
}
|
||||
},
|
||||
coverContent = coverContent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -273,7 +276,8 @@ private fun SharedOpdsFeedView(
|
|||
onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: (OpdsEntry) -> Unit,
|
||||
onEntrySelected: (OpdsEntry) -> Unit
|
||||
onEntrySelected: (OpdsEntry) -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit
|
||||
) {
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
|
@ -298,7 +302,7 @@ private fun SharedOpdsFeedView(
|
|||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
if (showSearch) {
|
||||
OutlinedTextField(
|
||||
SharedStableOutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Search catalog") },
|
||||
|
|
@ -380,12 +384,6 @@ private fun SharedOpdsFeedView(
|
|||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
itemsIndexed(entries, key = { index, entry -> "${entry.id}_$index" }) { index, entry ->
|
||||
val nextUrl = state.currentFeed?.nextUrl
|
||||
if (index == entries.lastIndex && nextUrl != null) {
|
||||
LaunchedEffect(index, nextUrl) {
|
||||
onLoadNextPage()
|
||||
}
|
||||
}
|
||||
if (entry.isNavigation) {
|
||||
SharedOpdsNavigationCard(entry, onOpenFeedUrl)
|
||||
} else {
|
||||
|
|
@ -396,10 +394,23 @@ private fun SharedOpdsFeedView(
|
|||
onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) },
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = { onStreamBook(entry) },
|
||||
onClick = { onEntrySelected(entry) }
|
||||
onClick = { onEntrySelected(entry) },
|
||||
coverContent = coverContent
|
||||
)
|
||||
}
|
||||
}
|
||||
state.currentFeed?.nextUrl?.let { nextUrl ->
|
||||
item(key = "load_more_$nextUrl") {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
OutlinedButton(
|
||||
onClick = onLoadNextPage,
|
||||
enabled = !state.isLoading
|
||||
) {
|
||||
Text(if (state.isLoading) "Loading..." else "Load more")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -567,7 +578,8 @@ private fun SharedOpdsBookCard(
|
|||
onDownloadBook: (OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit
|
||||
) {
|
||||
val uniqueAcquisitions = remember(entry.acquisitions) {
|
||||
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
|
||||
|
|
@ -582,15 +594,7 @@ private fun SharedOpdsBookCard(
|
|||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 70.dp, height = 100.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(entry.title.take(1).uppercase(), style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
coverContent(entry, Modifier.size(width = 70.dp, height = 100.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
entry.author?.let {
|
||||
|
|
@ -687,7 +691,8 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: () -> Unit,
|
||||
onOpenFeedUrl: (String) -> Unit,
|
||||
onSearch: (String) -> Unit
|
||||
onSearch: (String) -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit
|
||||
) {
|
||||
val uniqueAcquisitions = remember(entry.acquisitions) {
|
||||
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
|
||||
|
|
@ -709,6 +714,24 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
|
||||
coverContent(entry, Modifier.size(width = 96.dp, height = 140.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
entry.series?.takeIf { it.isNotBlank() }?.let { series ->
|
||||
Text(
|
||||
text = if (entry.seriesIndex.isNullOrBlank()) series else "$series #${entry.seriesIndex}",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
val secondary = listOfNotNull(
|
||||
entry.publisher?.takeIf { it.isNotBlank() }?.let { "Publisher: $it" },
|
||||
entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let { "Published: $it" },
|
||||
entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let { "Language: $it" }
|
||||
)
|
||||
secondary.forEach { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
localLibraryBook?.let { book ->
|
||||
Button(onClick = { onReadBook(book) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
|
|
@ -737,14 +760,6 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
entry.series?.takeIf { it.isNotBlank() }?.let { series ->
|
||||
Text(
|
||||
text = if (entry.seriesIndex.isNullOrBlank()) series else "$series #${entry.seriesIndex}",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
if (entry.authors.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Authors", style = MaterialTheme.typography.labelLarge)
|
||||
|
|
@ -767,12 +782,6 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
val secondary = listOfNotNull(
|
||||
entry.publisher?.takeIf { it.isNotBlank() }?.let { "Publisher: $it" },
|
||||
entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let { "Published: $it" },
|
||||
entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let { "Language: $it" }
|
||||
)
|
||||
secondary.forEach { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
val summary = SharedOpdsText.cleanSummary(entry.summary)
|
||||
if (summary.isNotBlank()) {
|
||||
Text("Synopsis", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||
|
|
@ -805,16 +814,17 @@ private fun SharedOpdsCatalogDialog(
|
|||
title = { Text(if (isEditMode) "Edit catalog" else "Add OPDS catalog") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Catalog name") }, singleLine = true)
|
||||
OutlinedTextField(value = url, onValueChange = { url = it }, label = { Text("URL") }, singleLine = true)
|
||||
SharedStableOutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Catalog name") }, singleLine = true, selectionKey = catalog?.id ?: "new:title")
|
||||
SharedStableOutlinedTextField(value = url, onValueChange = { url = it }, label = { Text("URL") }, singleLine = true, selectionKey = catalog?.id ?: "new:url")
|
||||
Text("Authentication optional", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||
OutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
SharedStableOutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true, selectionKey = catalog?.id ?: "new:username")
|
||||
SharedStableOutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
selectionKey = catalog?.id ?: "new:password"
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -839,3 +849,15 @@ private fun OpdsEntry.findLocalBook(localLibraryBooks: List<BookItem>): BookItem
|
|||
it.title.equals(title, ignoreCase = true) || it.displayName.equals(title, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCoverPlaceholder(entry: OpdsEntry, modifier: Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(entry.title.take(1).uppercase(), style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -28,6 +29,7 @@ import androidx.compose.foundation.text.BasicTextField
|
|||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
|
|
@ -68,12 +70,16 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
|
|||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
|
|
@ -86,7 +92,9 @@ import com.aryan.reader.shared.pdf.PdfPageBounds
|
|||
import com.aryan.reader.shared.pdf.PdfPagePoint
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||
import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.pdf.SharedPdfInkRenderData
|
||||
import com.aryan.reader.shared.pdf.SharedPdfInkRenderer
|
||||
import com.aryan.reader.shared.pdf.SharedPdfTextAnnotationDefaults
|
||||
|
|
@ -96,8 +104,10 @@ import com.aryan.reader.shared.pdf.SharedPdfTextResizeHandle
|
|||
import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig
|
||||
import com.aryan.reader.shared.pdf.movedBy
|
||||
import com.aryan.reader.shared.pdf.resizedBy
|
||||
import com.aryan.reader.shared.pdf.sharedPdfTextFontSizePx
|
||||
import com.aryan.reader.shared.pdf.sharedPdfStrokePercent
|
||||
import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange
|
||||
import com.aryan.reader.shared.pdf.withSharedPdfTextFontSize
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
val SharedPdfAnnotationDefaultTools: List<PdfInkTool> = listOf(
|
||||
|
|
@ -110,12 +120,20 @@ val SharedPdfAnnotationDefaultTools: List<PdfInkTool> = listOf(
|
|||
PdfInkTool.ERASER
|
||||
)
|
||||
|
||||
private enum class SharedPdfAnnotationSettingsPanel {
|
||||
PEN,
|
||||
HIGHLIGHTER,
|
||||
ERASER
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPdfAnnotationToolDock(
|
||||
selectedTool: PdfInkTool,
|
||||
selectedColor: Int,
|
||||
strokeWidth: Float,
|
||||
tools: List<PdfInkTool> = SharedPdfAnnotationDefaultTools,
|
||||
penPalette: List<Int> = SharedPdfAnnotationDefaults.penPalette,
|
||||
highlighterPalette: List<Int> = SharedPdfHighlighterPalette.defaultColors,
|
||||
onToolSelected: (PdfInkTool) -> Unit,
|
||||
onColorSelected: (Int) -> Unit,
|
||||
onStrokeWidthChange: (Float) -> Unit,
|
||||
|
|
@ -124,45 +142,101 @@ fun SharedPdfAnnotationToolDock(
|
|||
isHighlighterSnapEnabled: Boolean = false,
|
||||
onHighlighterSnapChange: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val strokeRange = selectedTool.sharedPdfStrokeWidthRange()
|
||||
val sliderValue = strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive)
|
||||
val showColorPalette = selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER
|
||||
val showStrokeSettings = selectedTool != PdfInkTool.TEXT
|
||||
val palette = if (selectedTool.isHighlighter) {
|
||||
SharedPdfAnnotationDefaults.highlighterPalette
|
||||
} else {
|
||||
SharedPdfAnnotationDefaults.penPalette
|
||||
val availableTools = tools.distinct()
|
||||
val penTools = listOf(PdfInkTool.FOUNTAIN_PEN, PdfInkTool.PEN, PdfInkTool.PENCIL)
|
||||
.filter { it in availableTools }
|
||||
val highlighterTools = listOf(PdfInkTool.HIGHLIGHTER, PdfInkTool.HIGHLIGHTER_ROUND)
|
||||
.filter { it in availableTools }
|
||||
var lastPenTool by remember { mutableStateOf(PdfInkTool.PEN) }
|
||||
var lastHighlighterTool by remember { mutableStateOf(PdfInkTool.HIGHLIGHTER) }
|
||||
var activeSettingsPanel by remember { mutableStateOf<SharedPdfAnnotationSettingsPanel?>(null) }
|
||||
|
||||
LaunchedEffect(selectedTool) {
|
||||
when {
|
||||
selectedTool in penTools -> lastPenTool = selectedTool
|
||||
selectedTool in highlighterTools -> lastHighlighterTool = selectedTool
|
||||
selectedTool != PdfInkTool.ERASER -> activeSettingsPanel = null
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = Color(0xFF1E1E1E),
|
||||
contentColor = Color.White,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
shadowElevation = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Surface(
|
||||
color = Color(0xFF1E1E1E),
|
||||
contentColor = Color.White,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
shadowElevation = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
tools.distinct().chunked(4).forEach { rowTools ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
rowTools.forEach { tool ->
|
||||
SharedPdfToolButton(
|
||||
tool = tool,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = onToolSelected
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (penTools.isNotEmpty()) {
|
||||
val tool = selectedTool.takeIf { it in penTools } ?: lastPenTool.takeIf { it in penTools } ?: penTools.first()
|
||||
SharedPdfToolButton(
|
||||
tool = tool,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = {
|
||||
onToolSelected(tool)
|
||||
activeSettingsPanel = SharedPdfAnnotationSettingsPanel.PEN
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (highlighterTools.isNotEmpty()) {
|
||||
val tool = selectedTool.takeIf { it in highlighterTools }
|
||||
?: lastHighlighterTool.takeIf { it in highlighterTools }
|
||||
?: highlighterTools.first()
|
||||
SharedPdfToolButton(
|
||||
tool = tool,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = {
|
||||
onToolSelected(tool)
|
||||
activeSettingsPanel = SharedPdfAnnotationSettingsPanel.HIGHLIGHTER
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (PdfInkTool.TEXT in availableTools) {
|
||||
SharedPdfToolButton(
|
||||
tool = PdfInkTool.TEXT,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = {
|
||||
activeSettingsPanel = null
|
||||
onToolSelected(PdfInkTool.TEXT)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (PdfInkTool.ERASER in availableTools) {
|
||||
SharedPdfToolButton(
|
||||
tool = PdfInkTool.ERASER,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = {
|
||||
onToolSelected(PdfInkTool.ERASER)
|
||||
activeSettingsPanel = SharedPdfAnnotationSettingsPanel.ERASER
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(22.dp)
|
||||
.width(1.dp)
|
||||
.background(Color.White.copy(alpha = 0.18f))
|
||||
)
|
||||
|
||||
DockCircleButton(onClick = onUndo) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Undo,
|
||||
|
|
@ -180,48 +254,161 @@ fun SharedPdfAnnotationToolDock(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showColorPalette) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
palette.forEach { argb ->
|
||||
val selected = argb == selectedColor
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(argb).copy(alpha = 1f))
|
||||
.border(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) Color.White else Color.White.copy(alpha = 0.22f),
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable { onColorSelected(argb) }
|
||||
activeSettingsPanel?.let { panel ->
|
||||
val toolsForPanel = when (panel) {
|
||||
SharedPdfAnnotationSettingsPanel.PEN -> penTools
|
||||
SharedPdfAnnotationSettingsPanel.HIGHLIGHTER -> highlighterTools
|
||||
SharedPdfAnnotationSettingsPanel.ERASER -> listOf(PdfInkTool.ERASER).filter { it in availableTools }
|
||||
}
|
||||
if (toolsForPanel.isNotEmpty()) {
|
||||
val panelTool = when (panel) {
|
||||
SharedPdfAnnotationSettingsPanel.PEN -> selectedTool.takeIf { it in penTools } ?: lastPenTool
|
||||
SharedPdfAnnotationSettingsPanel.HIGHLIGHTER -> selectedTool.takeIf { it in highlighterTools } ?: lastHighlighterTool
|
||||
SharedPdfAnnotationSettingsPanel.ERASER -> PdfInkTool.ERASER
|
||||
}
|
||||
SharedPdfAnnotationToolSettingsPanel(
|
||||
panel = panel,
|
||||
tools = toolsForPanel,
|
||||
selectedTool = panelTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
penPalette = penPalette,
|
||||
highlighterPalette = highlighterPalette,
|
||||
onToolSelected = { tool ->
|
||||
when (panel) {
|
||||
SharedPdfAnnotationSettingsPanel.PEN -> lastPenTool = tool
|
||||
SharedPdfAnnotationSettingsPanel.HIGHLIGHTER -> lastHighlighterTool = tool
|
||||
SharedPdfAnnotationSettingsPanel.ERASER -> Unit
|
||||
}
|
||||
onToolSelected(tool)
|
||||
},
|
||||
onColorSelected = onColorSelected,
|
||||
onStrokeWidthChange = onStrokeWidthChange,
|
||||
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
|
||||
onHighlighterSnapChange = onHighlighterSnapChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedPdfAnnotationToolSettingsPanel(
|
||||
panel: SharedPdfAnnotationSettingsPanel,
|
||||
tools: List<PdfInkTool>,
|
||||
selectedTool: PdfInkTool,
|
||||
selectedColor: Int,
|
||||
strokeWidth: Float,
|
||||
penPalette: List<Int>,
|
||||
highlighterPalette: List<Int>,
|
||||
onToolSelected: (PdfInkTool) -> Unit,
|
||||
onColorSelected: (Int) -> Unit,
|
||||
onStrokeWidthChange: (Float) -> Unit,
|
||||
isHighlighterSnapEnabled: Boolean,
|
||||
onHighlighterSnapChange: (Boolean) -> Unit
|
||||
) {
|
||||
val isEraser = panel == SharedPdfAnnotationSettingsPanel.ERASER
|
||||
val isHighlighter = panel == SharedPdfAnnotationSettingsPanel.HIGHLIGHTER
|
||||
val effectiveTool = if (isEraser) PdfInkTool.ERASER else selectedTool
|
||||
val strokeRange = effectiveTool.sharedPdfStrokeWidthRange()
|
||||
val sliderValue = strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive)
|
||||
val activeColor = if (isEraser) Color.White else Color(selectedColor)
|
||||
|
||||
Surface(
|
||||
color = Color(0xFF1E1E1E),
|
||||
contentColor = Color.White,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
shadowElevation = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (isEraser) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(104.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val diameter = (sliderValue * 800f).coerceIn(10f, 128f).dp
|
||||
Canvas(modifier = Modifier.size(diameter)) {
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = 0.3f),
|
||||
radius = size.minDimension / 2f
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = size.minDimension / 2f,
|
||||
style = Stroke(width = 2.dp.toPx())
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
tools.forEach { tool ->
|
||||
SharedPdfToolButton(
|
||||
tool = tool,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = onToolSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showStrokeSettings) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "Thickness ${sliderValue.sharedPdfStrokePercent(strokeRange)}",
|
||||
color = Color.White.copy(alpha = 0.86f),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = onStrokeWidthChange,
|
||||
valueRange = strokeRange,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = Color.White,
|
||||
activeTrackColor = if (selectedTool == PdfInkTool.ERASER) Color.White else Color(selectedColor).copy(alpha = 1f),
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.18f)
|
||||
if (!isEraser) {
|
||||
SharedPdfInkColorPalette(
|
||||
colors = if (isHighlighter) {
|
||||
highlighterPalette.ifEmpty { SharedPdfHighlighterPalette.defaultColors }
|
||||
} else {
|
||||
penPalette.ifEmpty { SharedPdfAnnotationDefaults.penPalette }
|
||||
},
|
||||
selectedColor = selectedColor,
|
||||
matchRgbOnly = isHighlighter,
|
||||
onColorSelected = { color ->
|
||||
onColorSelected(
|
||||
if (isHighlighter) {
|
||||
color.withSharedPdfAnnotationAlpha(Color(selectedColor).alpha)
|
||||
} else {
|
||||
color
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedTool.isHighlighter) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = if (isEraser) {
|
||||
"Eraser size ${sliderValue.sharedPdfStrokePercent(strokeRange)}"
|
||||
} else {
|
||||
"Thickness ${sliderValue.sharedPdfStrokePercent(strokeRange)}"
|
||||
},
|
||||
color = Color.White.copy(alpha = 0.86f),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = onStrokeWidthChange,
|
||||
valueRange = strokeRange,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = Color.White,
|
||||
activeTrackColor = activeColor.copy(alpha = 1f),
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.18f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (isHighlighter) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -243,6 +430,158 @@ fun SharedPdfAnnotationToolDock(
|
|||
)
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
val alpha = Color(selectedColor).alpha.coerceIn(0.1f, 1f)
|
||||
Text(
|
||||
text = "Opacity ${(alpha * 100f).roundToInt()}",
|
||||
color = Color.White.copy(alpha = 0.86f),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Slider(
|
||||
value = alpha,
|
||||
onValueChange = { nextAlpha ->
|
||||
onColorSelected(selectedColor.withSharedPdfAnnotationAlpha(nextAlpha))
|
||||
},
|
||||
valueRange = 0.1f..1f,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = Color.White,
|
||||
activeTrackColor = Color(selectedColor).copy(alpha = 1f),
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.18f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedPdfInkColorPalette(
|
||||
colors: List<Int>,
|
||||
selectedColor: Int,
|
||||
matchRgbOnly: Boolean,
|
||||
onColorSelected: (Int) -> Unit
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
colors.forEach { argb ->
|
||||
val selected = if (matchRgbOnly) {
|
||||
(argb and 0x00FFFFFF) == (selectedColor and 0x00FFFFFF)
|
||||
} else {
|
||||
argb == selectedColor
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(argb).copy(alpha = 1f))
|
||||
.border(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) Color.White else Color.White.copy(alpha = 0.22f),
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable { onColorSelected(argb) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPdfHighlighterPaletteEditor(
|
||||
palette: SharedPdfHighlighterPalette,
|
||||
onPaletteChange: (SharedPdfHighlighterPalette) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val sanitized = palette.sanitized()
|
||||
var editingSlot by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Highlight colors",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = "Tap a color to customize it.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState())
|
||||
) {
|
||||
sanitized.colors.forEachIndexed { index, argb ->
|
||||
val color = Color(argb)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(38.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color.copy(alpha = 1f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f),
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable { editingSlot = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editingSlot?.let { slot ->
|
||||
val initialColor = Color(sanitized.colors.getOrElse(slot) { SharedPdfHighlighterPalette.defaultColors.first() }).copy(alpha = 1f)
|
||||
SharedHsvColorPickerDialog(
|
||||
initialColor = initialColor,
|
||||
title = "Highlight color ${slot + 1}",
|
||||
onDismiss = { editingSlot = null },
|
||||
onSave = { color ->
|
||||
onPaletteChange(
|
||||
sanitized.withColorAt(
|
||||
slotIndex = slot,
|
||||
colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
|
||||
)
|
||||
)
|
||||
editingSlot = null
|
||||
}
|
||||
) { color ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = if (color.luminance() > 0.5f) Color.Black else Color.White
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("PDF highlighter", fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Saved with reader highlight transparency.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -334,19 +673,35 @@ fun SharedPdfTextBoxEditorOverlay(
|
|||
val moveHandleWidthPx = with(density) { moveHandleWidth.toPx() }
|
||||
val moveHandleHeightPx = with(density) { moveHandleHeight.toPx() }
|
||||
val moveHandleBelow = topPx + heightPx + moveHandleHeightPx + 10f <= canvasSize.height
|
||||
var textFieldValue by remember(id) {
|
||||
mutableStateOf(TextFieldValue(text, TextRange(text.length)))
|
||||
}
|
||||
|
||||
LaunchedEffect(id, style) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
LaunchedEffect(id, text) {
|
||||
if (text != textFieldValue.text) {
|
||||
textFieldValue = TextFieldValue(text, TextRange(text.length))
|
||||
}
|
||||
}
|
||||
|
||||
val fontSizePx = style.sharedPdfTextFontSizePx(canvasSize)
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
value = textFieldValue,
|
||||
onValueChange = { nextValue ->
|
||||
textFieldValue = nextValue
|
||||
if (nextValue.text != text) {
|
||||
onTextChange(nextValue.text)
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = textColor,
|
||||
fontSize = style.fontSize.sp,
|
||||
lineHeight = (style.fontSize * 1.25f).sp,
|
||||
fontSize = with(density) { fontSizePx.toSp() },
|
||||
lineHeight = with(density) { (fontSizePx * 1.25f).toSp() },
|
||||
fontWeight = if (style.isBold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (style.isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||
fontFamily = sharedPdfFontFamily(style.fontName ?: style.fontPath),
|
||||
|
|
@ -540,7 +895,7 @@ fun SharedPdfTextStyleControls(
|
|||
selected = style.fontSize.toInt() == size.toInt(),
|
||||
selectedBackground = selectedBackground,
|
||||
unselectedBackground = unselectedBackground,
|
||||
onClick = { onStyleChange(style.copy(fontSize = size)) }
|
||||
onClick = { onStyleChange(style.withSharedPdfTextFontSize(size)) }
|
||||
) {
|
||||
Text(
|
||||
text = size.toInt().toString(),
|
||||
|
|
@ -612,6 +967,7 @@ fun SharedPdfTextStyleControls(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@Composable
|
||||
fun SharedPdfAnnotationOverlay(
|
||||
annotations: List<SharedPdfAnnotation>,
|
||||
|
|
@ -620,7 +976,10 @@ fun SharedPdfAnnotationOverlay(
|
|||
activeTool: PdfInkTool = PdfInkTool.PEN,
|
||||
activeStrokeColorArgb: Int = 0xFF1976D2.toInt(),
|
||||
activeStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth,
|
||||
selectedAnnotationId: String? = null
|
||||
selectedAnnotationId: String? = null,
|
||||
eraserPosition: Offset? = null,
|
||||
showEraserIndicator: Boolean = false,
|
||||
eraserStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER).strokeWidth
|
||||
) {
|
||||
if (canvasSize.width <= 0 || canvasSize.height <= 0) return
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -628,22 +987,14 @@ fun SharedPdfAnnotationOverlay(
|
|||
Box(Modifier.fillMaxSize()) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
annotations.forEach { annotation ->
|
||||
val isSelected = annotation.matchesSelectedAnnotation(selectedAnnotationId)
|
||||
if (isSelected && annotation.kind == PdfAnnotationKind.INK) {
|
||||
SharedPdfInkRenderer.createRenderData(annotation, canvasSize)?.let { renderData ->
|
||||
drawInkRenderData(renderData, selectedOutline = true)
|
||||
}
|
||||
}
|
||||
|
||||
when (annotation.kind) {
|
||||
PdfAnnotationKind.HIGHLIGHT -> {
|
||||
val highlightBounds = annotation.boundsList.ifEmpty { listOfNotNull(annotation.bounds) }
|
||||
highlightBounds.forEach { bounds ->
|
||||
drawRect(
|
||||
color = Color(annotation.colorArgb),
|
||||
color = Color(annotation.colorArgb).copy(alpha = SharedPdfAndroidHighlightColors.RenderAlpha),
|
||||
topLeft = bounds.topLeft(canvasSize),
|
||||
size = bounds.size(canvasSize),
|
||||
blendMode = BlendMode.Multiply
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -662,19 +1013,9 @@ fun SharedPdfAnnotationOverlay(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected && annotation.kind != PdfAnnotationKind.INK) {
|
||||
val bounds = annotation.bounds ?: annotation.boundsList.firstOrNull() ?: return@forEach
|
||||
drawRect(
|
||||
color = Color(0xFF64B5F6),
|
||||
topLeft = bounds.topLeft(canvasSize),
|
||||
size = bounds.size(canvasSize),
|
||||
style = Stroke(width = 2f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeStroke.size > 1) {
|
||||
if (activeStroke.isNotEmpty()) {
|
||||
val activeAnnotation = SharedPdfAnnotation(
|
||||
id = "active",
|
||||
pageIndex = 0,
|
||||
|
|
@ -686,6 +1027,22 @@ fun SharedPdfAnnotationOverlay(
|
|||
)
|
||||
SharedPdfInkRenderer.createRenderData(activeAnnotation, canvasSize)?.let(::drawInkRenderData)
|
||||
}
|
||||
|
||||
if (showEraserIndicator && eraserPosition != null) {
|
||||
val radius = SharedPdfInkRenderer.effectiveStrokeWidthPx(eraserStrokeWidth, canvasSize)
|
||||
.coerceAtLeast(8.dp.toPx())
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = 0.3f),
|
||||
radius = radius,
|
||||
center = eraserPosition
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.Black,
|
||||
radius = radius,
|
||||
center = eraserPosition,
|
||||
style = Stroke(width = 1.dp.toPx())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
annotations
|
||||
|
|
@ -696,17 +1053,18 @@ fun SharedPdfAnnotationOverlay(
|
|||
val topPx = bounds.top * canvasSize.height
|
||||
val widthPx = ((bounds.right - bounds.left) * canvasSize.width).coerceAtLeast(24f)
|
||||
val heightPx = ((bounds.bottom - bounds.top) * canvasSize.height).coerceAtLeast(18f)
|
||||
val fontSizePx = annotation.sharedPdfTextFontSizePx(canvasSize)
|
||||
Text(
|
||||
text = annotation.text,
|
||||
color = Color(annotation.colorArgb),
|
||||
fontSize = annotation.fontSize.sp,
|
||||
lineHeight = (annotation.fontSize * 1.25f).sp,
|
||||
fontSize = with(density) { fontSizePx.toSp() },
|
||||
lineHeight = with(density) { (fontSizePx * 1.25f).toSp() },
|
||||
fontWeight = if (annotation.isBold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (annotation.isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||
fontFamily = annotation.sharedPdfTextFontFamily(),
|
||||
textDecoration = annotation.textDecoration,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = SharedPdfTextAnnotationDefaults.estimateLineCount(annotation.text, annotation.fontSize, widthPx),
|
||||
maxLines = SharedPdfTextAnnotationDefaults.estimateLineCount(annotation.text, fontSizePx, widthPx),
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(leftPx.roundToInt(), topPx.roundToInt()) }
|
||||
.width(with(density) { widthPx.toDp() })
|
||||
|
|
@ -942,6 +1300,7 @@ private fun SharedPdfPenIcon(
|
|||
drawMatteCylinder(animatedBodyColor, collarRect)
|
||||
drawMarkerHead(animatedBodyColor, tipRect)
|
||||
}
|
||||
PdfInkTool.NONE,
|
||||
PdfInkTool.TEXT,
|
||||
PdfInkTool.ERASER -> Unit
|
||||
}
|
||||
|
|
@ -1298,14 +1657,13 @@ private fun DrawScope.drawInkPreview(
|
|||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfAnnotation.matchesSelectedAnnotation(selectedAnnotationId: String?): Boolean {
|
||||
if (selectedAnnotationId == null) return false
|
||||
return id == selectedAnnotationId || id.startsWith("${selectedAnnotationId}_line_")
|
||||
}
|
||||
|
||||
private val PdfInkTool.isHighlighter: Boolean
|
||||
get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND
|
||||
|
||||
private fun Int.withSharedPdfAnnotationAlpha(alpha: Float): Int {
|
||||
return Color(this).copy(alpha = alpha.coerceIn(0f, 1f)).toArgb()
|
||||
}
|
||||
|
||||
private val SharedPdfAnnotation.textDecoration: TextDecoration
|
||||
get() {
|
||||
val decorations = mutableListOf<TextDecoration>()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,55 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal data class SharedReaderModalAnchorBounds(
|
||||
val leftPx: Float,
|
||||
val topPx: Float,
|
||||
val widthPx: Float,
|
||||
val heightPx: Float
|
||||
)
|
||||
|
||||
internal val LocalSharedReaderModalAnchorBounds = compositionLocalOf<SharedReaderModalAnchorBounds?> { null }
|
||||
|
||||
internal enum class SharedReaderModalLevel {
|
||||
Panel,
|
||||
Popup
|
||||
}
|
||||
|
||||
val SharedReaderPopupDefaultMaxWidth = 440.dp
|
||||
private val SharedReaderPopupMinWidth = 320.dp
|
||||
private const val SharedReaderPopupWidthFraction = 0.58f
|
||||
|
||||
fun sharedReaderPopupWidth(
|
||||
availableWidth: Dp,
|
||||
maxWidth: Dp = SharedReaderPopupDefaultMaxWidth,
|
||||
minWidth: Dp = SharedReaderPopupMinWidth,
|
||||
widthFraction: Float = SharedReaderPopupWidthFraction
|
||||
): Dp {
|
||||
if (availableWidth <= 0.dp) return 0.dp
|
||||
val lowerBound = minWidth.coerceAtMost(availableWidth)
|
||||
val upperBound = maxWidth.coerceAtMost(availableWidth).coerceAtLeast(lowerBound)
|
||||
return (availableWidth * widthFraction.coerceIn(0f, 1f)).coerceIn(lowerBound, upperBound)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal expect fun SharedReaderModalLayer(
|
||||
onDismiss: () -> Unit,
|
||||
level: SharedReaderModalLevel = SharedReaderModalLevel.Popup,
|
||||
content: @Composable () -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SharedReaderPopupLayer(
|
||||
onDismiss: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedReaderModalLayer(
|
||||
onDismiss = onDismiss,
|
||||
level = SharedReaderModalLevel.Popup,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,440 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private data class SharedScrollbarState(
|
||||
val progress: Float,
|
||||
val preferredThumbHeightPx: Float,
|
||||
val contentHeightPx: Float,
|
||||
val viewportHeightPx: Float
|
||||
)
|
||||
|
||||
private data class SharedPdfScrollbarState(
|
||||
val progress: Float,
|
||||
val contentHeightPx: Float,
|
||||
val viewportHeightPx: Float
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SharedReaderVerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollbarState by remember(listState) {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
||||
if (totalItems == 0 || visibleItems.isEmpty() || viewportHeight <= 0f) {
|
||||
return@derivedStateOf null
|
||||
}
|
||||
|
||||
val averageItemHeight = visibleItems.sumOf { it.size }.toFloat() / visibleItems.size
|
||||
val contentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
|
||||
val viewportRatio = viewportHeight / contentHeight
|
||||
if (viewportRatio >= 1f) return@derivedStateOf null
|
||||
|
||||
val maxThumbHeight = viewportHeight / 2f
|
||||
val minThumbHeight = minOf(80f, maxThumbHeight)
|
||||
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
|
||||
val currentScroll = (listState.firstVisibleItemIndex * averageItemHeight) +
|
||||
listState.firstVisibleItemScrollOffset
|
||||
val maxScroll = contentHeight - viewportHeight
|
||||
val progress = (currentScroll / maxScroll).coerceIn(0f, 1f)
|
||||
|
||||
SharedScrollbarState(
|
||||
progress = progress,
|
||||
preferredThumbHeightPx = thumbHeight,
|
||||
contentHeightPx = contentHeight,
|
||||
viewportHeightPx = viewportHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val state = scrollbarState ?: return
|
||||
val density = LocalDensity.current
|
||||
var isDraggingScrollbar by remember { mutableStateOf(false) }
|
||||
var scrollbarVisible by remember { mutableStateOf(false) }
|
||||
var scrollInteractionTick by remember { mutableIntStateOf(0) }
|
||||
var scrollbarTrackHeight by remember { mutableStateOf(0f) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
var previousIndex = listState.firstVisibleItemIndex
|
||||
var previousOffset = listState.firstVisibleItemScrollOffset
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.collect { (index, offset) ->
|
||||
if (index != previousIndex || abs(offset - previousOffset) > 1) {
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
previousIndex = index
|
||||
previousOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollInteractionTick, isDraggingScrollbar) {
|
||||
if (isDraggingScrollbar) {
|
||||
scrollbarVisible = true
|
||||
} else if (scrollInteractionTick > 0) {
|
||||
scrollbarVisible = true
|
||||
delay(5_000)
|
||||
scrollbarVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
val scrollbarAlpha by animateFloatAsState(
|
||||
targetValue = if (scrollbarVisible || isDraggingScrollbar) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "sharedReaderScrollbarAlpha"
|
||||
)
|
||||
val activeThemeColor = MaterialTheme.colorScheme.primary
|
||||
val idleColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f)
|
||||
val barColor by animateColorAsState(
|
||||
targetValue = if (isDraggingScrollbar) activeThemeColor else idleColor,
|
||||
label = "sharedReaderScrollbarColor"
|
||||
)
|
||||
val scrollbarIdleWidth = 4.dp
|
||||
val scrollbarActiveWidth = 8.dp
|
||||
val barWidth by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveWidth else scrollbarIdleWidth,
|
||||
label = "sharedReaderScrollbarWidth"
|
||||
)
|
||||
val preferredThumbHeight = with(density) { state.preferredThumbHeightPx.toDp() }
|
||||
val scrollbarIdleHeight = preferredThumbHeight.coerceAtLeast(40.dp)
|
||||
val scrollbarActiveHeight = preferredThumbHeight.coerceAtLeast(60.dp)
|
||||
val barHeight by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
|
||||
label = "sharedReaderScrollbarHeight"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(36.dp)
|
||||
.padding(top = 8.dp, bottom = 8.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
scrollbarTrackHeight = coordinates.size.height.toFloat()
|
||||
}
|
||||
) {
|
||||
val thumbHeightPx = with(density) { barHeight.toPx() }
|
||||
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
|
||||
val availableSpace = (effectiveTrackHeight - thumbHeightPx).coerceAtLeast(0f)
|
||||
val thumbY = (availableSpace * state.progress).coerceIn(0f, availableSpace)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(x = 0, y = thumbY.roundToInt()) }
|
||||
.align(Alignment.TopEnd)
|
||||
.alpha(scrollbarAlpha)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = Modifier
|
||||
.height(barHeight)
|
||||
.width(36.dp)
|
||||
.pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
val deltaY = change.position.y - change.previousPosition.y
|
||||
if (deltaY != 0f) {
|
||||
change.consume()
|
||||
val trackHeight = scrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: state.viewportHeightPx
|
||||
val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() })
|
||||
.coerceAtLeast(1f)
|
||||
val scrollDelta = (deltaY / trackSpace) *
|
||||
(state.contentHeightPx - state.viewportHeightPx)
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = barWidth, height = barHeight)
|
||||
.background(barColor, RoundedCornerShape(999.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPdfVerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
pageCount: Int,
|
||||
currentPage: Int,
|
||||
isDarkMode: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollbarState by remember(listState) {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
if (totalItems == 0 || visibleItems.isEmpty() || viewportHeight <= 0f) {
|
||||
return@derivedStateOf null
|
||||
}
|
||||
|
||||
val averageItemHeight = visibleItems.sumOf { it.size }.toFloat() / visibleItems.size
|
||||
val contentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
|
||||
val maxScroll = contentHeight - viewportHeight
|
||||
if (maxScroll <= 1f) return@derivedStateOf null
|
||||
|
||||
val currentScroll = (listState.firstVisibleItemIndex * averageItemHeight) +
|
||||
listState.firstVisibleItemScrollOffset
|
||||
SharedPdfScrollbarState(
|
||||
progress = (currentScroll / maxScroll).coerceIn(0f, 1f),
|
||||
contentHeightPx = contentHeight,
|
||||
viewportHeightPx = viewportHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val state = scrollbarState ?: return
|
||||
val density = LocalDensity.current
|
||||
var isDraggingScrollbar by remember { mutableStateOf(false) }
|
||||
var scrollbarVisible by remember { mutableStateOf(false) }
|
||||
var scrollInteractionTick by remember { mutableIntStateOf(0) }
|
||||
var scrollbarTrackHeight by remember { mutableStateOf(0f) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
var previousIndex = listState.firstVisibleItemIndex
|
||||
var previousOffset = listState.firstVisibleItemScrollOffset
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.collect { (index, offset) ->
|
||||
if (index != previousIndex || abs(offset - previousOffset) > 1) {
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
previousIndex = index
|
||||
previousOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollInteractionTick, isDraggingScrollbar) {
|
||||
if (isDraggingScrollbar) {
|
||||
scrollbarVisible = true
|
||||
} else if (scrollInteractionTick > 0) {
|
||||
scrollbarVisible = true
|
||||
delay(5_000)
|
||||
scrollbarVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
val scrollbarAlpha by animateFloatAsState(
|
||||
targetValue = if (scrollbarVisible || isDraggingScrollbar) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "sharedPdfScrollbarAlpha"
|
||||
)
|
||||
val activeThemeColor = if (isDarkMode) Color(0xFF1976D2) else Color(0xFF4285F4)
|
||||
val idleColor = if (isDarkMode) Color.Gray else Color.DarkGray
|
||||
val barColor by animateColorAsState(
|
||||
targetValue = if (isDraggingScrollbar) activeThemeColor else idleColor,
|
||||
label = "sharedPdfScrollbarColor"
|
||||
)
|
||||
val scrollbarIdleWidth = 4.dp
|
||||
val scrollbarActiveWidth = 8.dp
|
||||
val barWidth by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveWidth else scrollbarIdleWidth,
|
||||
label = "sharedPdfScrollbarWidth"
|
||||
)
|
||||
val scrollbarIdleHeight = 40.dp
|
||||
val scrollbarActiveHeight = 60.dp
|
||||
val barHeight by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
|
||||
label = "sharedPdfScrollbarHeight"
|
||||
)
|
||||
val safeCurrentPage = if (pageCount > 0) currentPage.coerceIn(0, pageCount - 1) else 0
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(48.dp)
|
||||
.padding(top = 12.dp, bottom = 12.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
scrollbarTrackHeight = coordinates.size.height.toFloat()
|
||||
}
|
||||
) {
|
||||
val thumbHeightPx = with(density) { barHeight.toPx() }
|
||||
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
|
||||
val availableSpace = (effectiveTrackHeight - thumbHeightPx).coerceAtLeast(0f)
|
||||
val thumbY = (availableSpace * state.progress).coerceIn(0f, availableSpace)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(x = 0, y = thumbY.roundToInt()) }
|
||||
.align(Alignment.TopEnd)
|
||||
.wrapContentSize(align = Alignment.CenterEnd, unbounded = true)
|
||||
.alpha(scrollbarAlpha)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AnimatedVisibility(
|
||||
visible = isDraggingScrollbar && pageCount > 0,
|
||||
enter = fadeIn() + slideInHorizontally { it / 2 },
|
||||
exit = fadeOut() + slideOutHorizontally { it / 2 }
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = activeThemeColor,
|
||||
shadowElevation = 4.dp,
|
||||
modifier = Modifier.padding(end = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${safeCurrentPage + 1}/$pageCount",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = Modifier
|
||||
.height(barHeight)
|
||||
.width(48.dp)
|
||||
.pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
val deltaY = change.position.y - change.previousPosition.y
|
||||
if (deltaY != 0f) {
|
||||
change.consume()
|
||||
val trackHeight = scrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: state.viewportHeightPx
|
||||
val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() })
|
||||
.coerceAtLeast(1f)
|
||||
val scrollDelta = (deltaY / trackSpace) *
|
||||
(state.contentHeightPx - state.viewportHeightPx)
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = barWidth, height = barHeight)
|
||||
.background(barColor, RoundedCornerShape(999.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.sharedAcceleratedLazyWheelScroll(
|
||||
listState: LazyListState,
|
||||
multiplier: Float = 4f
|
||||
): Modifier {
|
||||
val safeMultiplier = multiplier.coerceIn(1f, 12f)
|
||||
return pointerInput(listState, safeMultiplier) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (event.type != PointerEventType.Scroll) continue
|
||||
val scrollDelta = event.changes.fold(0f) { total, change ->
|
||||
val delta = change.scrollDelta
|
||||
total + if (abs(delta.y) >= abs(delta.x)) delta.y else delta.x
|
||||
}
|
||||
if (abs(scrollDelta) > 0.01f) {
|
||||
val adaptiveMultiplier = when {
|
||||
abs(scrollDelta) < 1f -> 24f
|
||||
abs(scrollDelta) < 8f -> 10f
|
||||
else -> safeMultiplier
|
||||
}
|
||||
listState.dispatchRawDelta(scrollDelta * (adaptiveMultiplier - 1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,723 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.BuiltInPdfReaderThemes
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.ReaderAction
|
||||
import com.aryan.reader.shared.ReaderToolbarPreferences
|
||||
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||
import com.aryan.reader.shared.SharedSettingsAction
|
||||
import com.aryan.reader.shared.SharedSettingsCategoryModel
|
||||
import com.aryan.reader.shared.SharedSettingsDestination
|
||||
import com.aryan.reader.shared.SharedSettingsHubModel
|
||||
import com.aryan.reader.shared.SharedSettingsItemKind
|
||||
import com.aryan.reader.shared.SharedSettingsItemModel
|
||||
import com.aryan.reader.shared.SharedSettingsPageKind
|
||||
import com.aryan.reader.shared.SharedSettingsPageModel
|
||||
import com.aryan.reader.shared.SharedSettingsSearchResult
|
||||
import com.aryan.reader.shared.parentDestination
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
@Composable
|
||||
fun SharedSettingsHub(
|
||||
model: SharedSettingsHubModel,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
readerDefaultSettings: ReaderSettings,
|
||||
onReaderDefaultSettingsChange: (ReaderSettings) -> Unit,
|
||||
pdfReaderDefaultSettings: ReaderSettings = readerDefaultSettings,
|
||||
onPdfReaderDefaultSettingsChange: (ReaderSettings) -> Unit = onReaderDefaultSettingsChange,
|
||||
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
|
||||
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
|
||||
onAction: (SharedSettingsAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
readerToolbarPreferences: ReaderToolbarPreferences? = null,
|
||||
onReaderToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit = {},
|
||||
customFonts: List<CustomFontItem> = emptyList(),
|
||||
onPickCustomFont: (() -> String?)? = null,
|
||||
readerCustomTextureIds: List<String> = emptyList(),
|
||||
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)? = null,
|
||||
showTopBar: Boolean = true,
|
||||
onBack: (() -> Unit)? = null,
|
||||
destination: SharedSettingsDestination = SharedSettingsDestination.ROOT,
|
||||
onDestinationChange: (SharedSettingsDestination) -> Unit = {},
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp)
|
||||
) {
|
||||
val page = remember(model, destination) { model.page(destination) }
|
||||
val searchResults = remember(model, query) { model.searchResults(query) }
|
||||
|
||||
fun navigateTo(next: SharedSettingsDestination) {
|
||||
onQueryChange("")
|
||||
onDestinationChange(next)
|
||||
}
|
||||
|
||||
fun navigateUp() {
|
||||
if (query.isNotBlank()) {
|
||||
onQueryChange("")
|
||||
return
|
||||
}
|
||||
val parent = destination.parentDestination()
|
||||
if (parent != null) {
|
||||
onDestinationChange(parent)
|
||||
} else {
|
||||
onBack?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(contentPadding)
|
||||
) {
|
||||
val contentWidth = if (maxWidth >= 960.dp) Modifier.width(860.dp) else Modifier.fillMaxWidth()
|
||||
Column(
|
||||
modifier = contentWidth
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
if (showTopBar) {
|
||||
SharedSettingsHeader(
|
||||
page = page,
|
||||
canNavigateUp = query.isNotBlank() || destination != SharedSettingsDestination.ROOT || onBack != null,
|
||||
onNavigateUp = ::navigateUp
|
||||
)
|
||||
}
|
||||
|
||||
SharedStableOutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
label = { Text("Search settings") }
|
||||
)
|
||||
|
||||
when {
|
||||
query.isNotBlank() -> SharedSettingsSearchResults(
|
||||
results = searchResults,
|
||||
onNavigate = ::navigateTo,
|
||||
onAction = onAction,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
page.kind == SharedSettingsPageKind.ROOT -> SharedSettingsCategoryList(
|
||||
categories = page.categories,
|
||||
onNavigate = ::navigateTo,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
page.kind == SharedSettingsPageKind.CATEGORY -> SharedSettingsItemList(
|
||||
page = page,
|
||||
onNavigate = ::navigateTo,
|
||||
onAction = onAction,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
else -> SharedSettingsDetailPage(
|
||||
page = page,
|
||||
settings = readerDefaultSettings,
|
||||
onSettingsChange = onReaderDefaultSettingsChange,
|
||||
pdfSettings = pdfReaderDefaultSettings,
|
||||
onPdfSettingsChange = onPdfReaderDefaultSettingsChange,
|
||||
toolbarPreferences = readerToolbarPreferences,
|
||||
onToolbarPreferencesChange = onReaderToolbarPreferencesChange,
|
||||
ttsReplacementPreferences = ttsReplacementPreferences,
|
||||
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange,
|
||||
customFonts = customFonts,
|
||||
onPickCustomFont = onPickCustomFont,
|
||||
readerCustomTextureIds = readerCustomTextureIds,
|
||||
onImportReaderTexture = onImportReaderTexture,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsHeader(
|
||||
page: SharedSettingsPageModel,
|
||||
canNavigateUp: Boolean,
|
||||
onNavigateUp: () -> Unit
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (canNavigateUp) {
|
||||
TextButton(onClick = onNavigateUp) {
|
||||
Text("Back")
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(page.title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
page.summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsCategoryList(
|
||||
categories: List<SharedSettingsCategoryModel>,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit,
|
||||
modifier: Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
categories.forEach { category ->
|
||||
item(key = category.destination.name) {
|
||||
SharedSettingsCategoryRow(category = category, onNavigate = onNavigate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsCategoryRow(
|
||||
category: SharedSettingsCategoryModel,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)),
|
||||
onClick = { onNavigate(category.destination) }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Icon(
|
||||
category.destination.iconForSettingsDestination(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(category.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
category.summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
if (category.itemCount == 1) "1 setting" else "${category.itemCount} settings",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsItemList(
|
||||
page: SharedSettingsPageModel,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit,
|
||||
onAction: (SharedSettingsAction) -> Unit,
|
||||
modifier: Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
item(key = page.destination.name) {
|
||||
SharedSettingsGroup {
|
||||
page.items.forEachIndexed { index, item ->
|
||||
SharedSettingsRow(
|
||||
item = item,
|
||||
onNavigate = onNavigate,
|
||||
onAction = onAction
|
||||
)
|
||||
if (index != page.items.lastIndex) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsSearchResults(
|
||||
results: List<SharedSettingsSearchResult>,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit,
|
||||
onAction: (SharedSettingsAction) -> Unit,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (results.isEmpty()) {
|
||||
SharedSettingsEmptySearch(modifier = modifier)
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
item {
|
||||
SharedSettingsGroup {
|
||||
results.forEachIndexed { index, result ->
|
||||
SharedSettingsSearchResultRow(
|
||||
result = result,
|
||||
onNavigate = onNavigate,
|
||||
onAction = onAction
|
||||
)
|
||||
if (index != results.lastIndex) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsEmptySearch(modifier: Modifier) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(36.dp))
|
||||
Text("No settings found", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsSearchResultRow(
|
||||
result: SharedSettingsSearchResult,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit,
|
||||
onAction: (SharedSettingsAction) -> Unit
|
||||
) {
|
||||
val contentColor = if (result.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.46f)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
enabled = result.enabled,
|
||||
onClick = {
|
||||
result.destination?.let(onNavigate) ?: result.action?.let(onAction)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
result.action?.iconForSettings() ?: result.destination?.iconForSettingsDestination() ?: Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (result.kind == SharedSettingsItemKind.DESTRUCTIVE) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(result.title, fontWeight = FontWeight.SemiBold, color = contentColor)
|
||||
Text(
|
||||
result.breadcrumb,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
result.summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (result.enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.48f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
when (result.kind) {
|
||||
SharedSettingsItemKind.TOGGLE -> {
|
||||
Switch(
|
||||
checked = result.checked == true,
|
||||
enabled = result.enabled,
|
||||
onCheckedChange = { result.action?.let(onAction) }
|
||||
)
|
||||
}
|
||||
else -> Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsGroup(content: @Composable () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsRow(
|
||||
item: SharedSettingsItemModel,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit,
|
||||
onAction: (SharedSettingsAction) -> Unit
|
||||
) {
|
||||
val contentColor = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.46f)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
enabled = item.enabled,
|
||||
onClick = {
|
||||
item.destination?.let(onNavigate) ?: onAction(item.action)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 2.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
item.action.iconForSettings(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.kind == SharedSettingsItemKind.DESTRUCTIVE) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(item.title, fontWeight = FontWeight.SemiBold, color = contentColor)
|
||||
Text(
|
||||
item.summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (item.enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.48f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
when (item.kind) {
|
||||
SharedSettingsItemKind.TOGGLE -> {
|
||||
Switch(
|
||||
checked = item.checked == true,
|
||||
enabled = item.enabled,
|
||||
onCheckedChange = { onAction(item.action) }
|
||||
)
|
||||
}
|
||||
SharedSettingsItemKind.INFO -> Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
SharedSettingsItemKind.DESTRUCTIVE,
|
||||
SharedSettingsItemKind.NAVIGATION,
|
||||
SharedSettingsItemKind.CONTROL -> Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsDetailPage(
|
||||
page: SharedSettingsPageModel,
|
||||
settings: ReaderSettings,
|
||||
onSettingsChange: (ReaderSettings) -> Unit,
|
||||
pdfSettings: ReaderSettings,
|
||||
onPdfSettingsChange: (ReaderSettings) -> Unit,
|
||||
toolbarPreferences: ReaderToolbarPreferences?,
|
||||
onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit,
|
||||
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
|
||||
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
|
||||
customFonts: List<CustomFontItem>,
|
||||
onPickCustomFont: (() -> String?)?,
|
||||
readerCustomTextureIds: List<String>,
|
||||
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
page.localOverrideNote?.let { note ->
|
||||
SharedSettingsLocalOverrideNote(note)
|
||||
}
|
||||
|
||||
SharedSettingsDetailSurface {
|
||||
when (page.destination) {
|
||||
SharedSettingsDestination.EPUB_FORMAT -> {
|
||||
SharedReaderFormatControls(
|
||||
settings = settings,
|
||||
toolbarPreferences = ReaderToolbarPreferences(),
|
||||
onPickCustomFont = onPickCustomFont,
|
||||
customFonts = customFonts,
|
||||
onReaderAction = { action ->
|
||||
if (action is ReaderAction.SettingsChanged) onSettingsChange(action.settings)
|
||||
}
|
||||
)
|
||||
}
|
||||
SharedSettingsDestination.EPUB_THEME_TEXTURE -> {
|
||||
SharedReaderThemeControls(
|
||||
settings = settings,
|
||||
customTextureIds = readerCustomTextureIds,
|
||||
onImportTexture = onImportReaderTexture,
|
||||
onSettingsChange = onSettingsChange
|
||||
)
|
||||
}
|
||||
SharedSettingsDestination.EPUB_VISUAL_DEFAULTS -> {
|
||||
SharedReaderVisualOptionsControls(
|
||||
settings = settings,
|
||||
onReaderAction = { action ->
|
||||
if (action is ReaderAction.SettingsChanged) onSettingsChange(action.settings)
|
||||
}
|
||||
)
|
||||
}
|
||||
SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("Fixed-layout appearance", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"These defaults apply where the platform supports shared PDF appearance. Per-book PDF overrides stay in the PDF reader.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
SharedReaderThemeControls(
|
||||
settings = pdfSettings,
|
||||
builtInThemes = BuiltInPdfReaderThemes,
|
||||
customTextureIds = readerCustomTextureIds,
|
||||
onImportTexture = onImportReaderTexture,
|
||||
onSettingsChange = onPdfSettingsChange
|
||||
)
|
||||
HorizontalDivider()
|
||||
Text("Visual options", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
SharedPdfVisualOptionDefaultsSwitch(
|
||||
title = "Remove gap between pages",
|
||||
summary = "Applies to vertical PDF reading mode.",
|
||||
checked = !pdfSettings.pdfVerticalPageGapVisible,
|
||||
onCheckedChange = { removeGap ->
|
||||
onPdfSettingsChange(pdfSettings.copy(pdfVerticalPageGapVisible = !removeGap))
|
||||
}
|
||||
)
|
||||
SharedPdfVisualOptionDefaultsSwitch(
|
||||
title = "Hide page number overlay",
|
||||
summary = "Removes the small page count label from each PDF page.",
|
||||
checked = !pdfSettings.pdfPageNumberOverlayVisible,
|
||||
onCheckedChange = { hideOverlay ->
|
||||
onPdfSettingsChange(pdfSettings.copy(pdfPageNumberOverlayVisible = !hideOverlay))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedSettingsDestination.PDF_READER_TOOLS -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("Reader-managed PDF tools", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedSettingsDestination.READER_TOOLBAR_DEFAULTS -> {
|
||||
if (toolbarPreferences == null) {
|
||||
Text("Reader toolbar defaults are managed from the reader on this platform.")
|
||||
} else {
|
||||
SharedReaderToolbarControls(
|
||||
toolbarPreferences = toolbarPreferences,
|
||||
onToolbarPreferencesChange = onToolbarPreferencesChange
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedSettingsDestination.EPUB_TTS_REPLACEMENTS,
|
||||
SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> {
|
||||
SharedReaderTtsReplacementControls(
|
||||
preferences = ttsReplacementPreferences,
|
||||
bookId = "global",
|
||||
onPreferencesChange = onTtsReplacementPreferencesChange,
|
||||
allowBookScope = false
|
||||
)
|
||||
}
|
||||
else -> Text(page.summary, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsDetailSurface(content: @Composable () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsLocalOverrideNote(note: SharedSettingsItemModel) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.42f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(note.title, fontWeight = FontWeight.SemiBold)
|
||||
Text(note.summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedPdfVisualOptionDefaultsSwitch(
|
||||
title: String,
|
||||
summary: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(title, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedSettingsDestination.iconForSettingsDestination(): ImageVector {
|
||||
return when (this) {
|
||||
SharedSettingsDestination.EPUB_TEXT,
|
||||
SharedSettingsDestination.EPUB_FORMAT,
|
||||
SharedSettingsDestination.EPUB_TTS_REPLACEMENTS,
|
||||
SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> Icons.Default.TextFields
|
||||
SharedSettingsDestination.PDF_COMICS,
|
||||
SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS,
|
||||
SharedSettingsDestination.PDF_READER_TOOLS,
|
||||
SharedSettingsDestination.EPUB_THEME_TEXTURE,
|
||||
SharedSettingsDestination.EPUB_VISUAL_DEFAULTS,
|
||||
SharedSettingsDestination.READER_TOOLBAR_DEFAULTS,
|
||||
SharedSettingsDestination.THEME_APPEARANCE -> Icons.Default.Palette
|
||||
SharedSettingsDestination.TTS_AI -> Icons.Default.Settings
|
||||
SharedSettingsDestination.LIBRARY_SYNC_STORAGE -> Icons.Default.Folder
|
||||
SharedSettingsDestination.SYNC_ACCOUNTS -> Icons.Default.Cloud
|
||||
SharedSettingsDestination.EXTRA -> Icons.Default.Settings
|
||||
SharedSettingsDestination.HELP_ABOUT -> Icons.Default.Info
|
||||
SharedSettingsDestination.ROOT -> Icons.Default.Settings
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedSettingsAction.iconForSettings(): ImageVector {
|
||||
return when (this) {
|
||||
SharedSettingsAction.TEXT_READER_DEFAULTS,
|
||||
SharedSettingsAction.TTS_REPLACEMENTS,
|
||||
SharedSettingsAction.TTS_SETTINGS,
|
||||
SharedSettingsAction.HIDE_READER_AI -> Icons.Default.TextFields
|
||||
SharedSettingsAction.PDF_READER_DEFAULTS,
|
||||
SharedSettingsAction.READER_TOOLBAR,
|
||||
SharedSettingsAction.APP_THEME -> Icons.Default.Palette
|
||||
SharedSettingsAction.CUSTOM_FONTS -> Icons.Default.TextFields
|
||||
SharedSettingsAction.SIGN_IN,
|
||||
SharedSettingsAction.SIGN_OUT,
|
||||
SharedSettingsAction.CLOUD_SYNC -> Icons.Default.Cloud
|
||||
SharedSettingsAction.FOLDER_SYNC -> Icons.Default.Folder
|
||||
SharedSettingsAction.CLEAR_BOOK_CACHE,
|
||||
SharedSettingsAction.CLEAR_REFLOW_CACHE,
|
||||
SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA -> Icons.Default.Delete
|
||||
SharedSettingsAction.HELP_FEEDBACK -> Icons.Default.Feedback
|
||||
SharedSettingsAction.SUPPORT -> Icons.Default.Favorite
|
||||
SharedSettingsAction.LOCAL_OVERRIDE_NOTE,
|
||||
SharedSettingsAction.ABOUT -> Icons.Default.Info
|
||||
SharedSettingsAction.EXPORT_LOGS,
|
||||
SharedSettingsAction.DEBUG_ACTIONS,
|
||||
SharedSettingsAction.TEST_PANEL_DETECTION,
|
||||
SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION,
|
||||
SharedSettingsAction.DEVICE_MANAGEMENT,
|
||||
SharedSettingsAction.AI_SETTINGS,
|
||||
SharedSettingsAction.LANGUAGE,
|
||||
SharedSettingsAction.TABS_TOGGLE,
|
||||
SharedSettingsAction.RECENT_LIMIT,
|
||||
SharedSettingsAction.STRICT_FILE_FILTER,
|
||||
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR,
|
||||
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> Icons.Default.Settings
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
@Composable
|
||||
fun SharedStableOutlinedTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
supportingText: @Composable (() -> Unit)? = null,
|
||||
isError: Boolean = false,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
singleLine: Boolean = false,
|
||||
minLines: Int = 1,
|
||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
shape: Shape = OutlinedTextFieldDefaults.shape,
|
||||
selectionKey: Any? = Unit
|
||||
) {
|
||||
var fieldValue by remember(selectionKey) {
|
||||
mutableStateOf(value.toTextFieldValueWithCursorAtEnd())
|
||||
}
|
||||
|
||||
LaunchedEffect(selectionKey, value) {
|
||||
if (value != fieldValue.text) {
|
||||
fieldValue = value.toTextFieldValueWithCursorAtEnd()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { nextValue ->
|
||||
fieldValue = nextValue
|
||||
if (nextValue.text != value) {
|
||||
onValueChange(nextValue.text)
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
label = label,
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon,
|
||||
trailingIcon = trailingIcon,
|
||||
supportingText = supportingText,
|
||||
isError = isError,
|
||||
visualTransformation = visualTransformation,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
maxLines = if (singleLine) 1 else maxLines.coerceAtLeast(minLines),
|
||||
shape = shape
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.toTextFieldValueWithCursorAtEnd(): TextFieldValue {
|
||||
return TextFieldValue(
|
||||
text = this,
|
||||
selection = TextRange(length)
|
||||
)
|
||||
}
|
||||
|
|
@ -43,7 +43,6 @@ import androidx.compose.material3.Icon
|
|||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -207,7 +206,7 @@ private fun SharedGoogleFontsDialog(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
SharedStableOutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -435,8 +434,8 @@ fun SharedSupportProjectScreen(
|
|||
fun SharedAboutScreen(
|
||||
versionName: String,
|
||||
buildLabel: String,
|
||||
onOpenSource: () -> Unit,
|
||||
onOpenIssues: () -> Unit,
|
||||
onOpenSource: (() -> Unit)? = null,
|
||||
onOpenIssues: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
|
|
@ -471,18 +470,22 @@ fun SharedAboutScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
SharedUtilityOptionCard(
|
||||
title = "Source Code",
|
||||
body = "Browse the project source on GitHub.",
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenSource
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "Issues",
|
||||
body = "Open the issue tracker for bugs and feature requests.",
|
||||
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenIssues
|
||||
)
|
||||
if (onOpenSource != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = "Source Code",
|
||||
body = "Browse the project source on GitHub.",
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenSource
|
||||
)
|
||||
}
|
||||
if (onOpenIssues != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = "Issues",
|
||||
body = "Open the issue tracker for bugs and feature requests.",
|
||||
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenIssues
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -153,9 +153,11 @@ class EpubAnnotationSerializerTest {
|
|||
fun `highlight bridge parser accepts raw or wrapped json payloads`() {
|
||||
val payload = """{"cfi":"desktop:0:4:9","text":"word","colorId":"yellow","chapterIndex":0,"locator":{"chapterIndex":0,"startOffset":4,"endOffset":9,"textQuote":"word","cfi":"desktop:0:4:9"}}"""
|
||||
val wrappedPayload = "\"${payload.replace("\"", "\\\"")}\""
|
||||
val arrayPayload = "[$wrappedPayload]"
|
||||
|
||||
assertEquals(4, EpubAnnotationSerializer.parseHighlightJsonLenient(payload)?.locator?.startOffset)
|
||||
assertEquals(9, EpubAnnotationSerializer.parseHighlightJsonLenient(wrappedPayload)?.locator?.endOffset)
|
||||
assertEquals("word", EpubAnnotationSerializer.parseHighlightJsonLenient(arrayPayload)?.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.aryan.reader.shared
|
|||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FileCapabilitiesTest {
|
||||
|
|
@ -12,6 +14,17 @@ class FileCapabilitiesTest {
|
|||
PDF_VIEWER_FILE_TYPES + EPUB_READER_FILE_TYPES,
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
|
||||
)
|
||||
assertFalse(FileType.UNKNOWN in SharedFileCapabilities.knownFileTypes)
|
||||
assertFalse(FileType.UNKNOWN in SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID))
|
||||
assertNull(SharedFileCapabilities.primaryExtensionFor(FileType.UNKNOWN))
|
||||
assertNull(SharedFileCapabilities.mimeTypeFor(FileType.UNKNOWN))
|
||||
assertEquals("epub", SharedFileCapabilities.primaryExtensionFor(FileType.EPUB))
|
||||
assertEquals("application/pdf", SharedFileCapabilities.mimeTypeFor(FileType.PDF))
|
||||
assertEquals("pptx", SharedFileCapabilities.primaryExtensionFor(FileType.PPTX))
|
||||
assertEquals(
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
SharedFileCapabilities.mimeTypeFor(FileType.PPTX)
|
||||
)
|
||||
assertEquals(
|
||||
setOf(
|
||||
FileType.EPUB,
|
||||
|
|
@ -42,6 +55,11 @@ class FileCapabilitiesTest {
|
|||
ReaderFeatureSurface.PDF_VIEWER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.PDF, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.PDF_VIEWER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.PPTX, ReaderPlatform.ANDROID)
|
||||
)
|
||||
assertNull(SharedFileCapabilities.surfaceFor(FileType.PPTX, ReaderPlatform.DESKTOP))
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.TEXT_READER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.MD, ReaderPlatform.DESKTOP)
|
||||
|
|
@ -68,11 +86,27 @@ class FileCapabilitiesTest {
|
|||
assertEquals(FileType.HTML, SharedFileCapabilities.fileTypeForName("chapter.xhtml"))
|
||||
assertEquals(FileType.HTML, "chapter.xhtml".toFileType())
|
||||
assertEquals(FileType.MOBI, SharedFileCapabilities.fileTypeForName("book.azw3"))
|
||||
assertEquals(FileType.FB2, SharedFileCapabilities.fileTypeForName("book.fb2.zip"))
|
||||
assertEquals(FileType.PPTX, SharedFileCapabilities.fileTypeForName("slides.pptx"))
|
||||
assertEquals(FileType.HTML, SharedFileCapabilities.fileTypeForName("payload.json.txt"))
|
||||
assertEquals(FileType.EPUB, SharedFileCapabilities.fileTypeForName("book.epub.txt"))
|
||||
assertEquals(FileType.UNKNOWN, SharedFileCapabilities.fileTypeForName("archive.zip"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared file name policy detects manual only files and suffixes`() {
|
||||
assertTrue(SharedFileCapabilities.isCodeOrDataFileName("table.csv"))
|
||||
assertTrue(SharedFileCapabilities.isManualOnlyReaderFileName("script.kt.txt"))
|
||||
assertFalse(SharedFileCapabilities.isManualOnlyReaderFileName("chapter.html"))
|
||||
assertFalse(SharedFileCapabilities.isLocalFolderSyncEligibleFile("table.csv", "text/csv"))
|
||||
assertFalse(SharedFileCapabilities.isLocalFolderSyncEligibleFile("payload", "application/json"))
|
||||
assertTrue(SharedFileCapabilities.isLocalFolderSyncEligibleFile("book.fodt", "text/xml"))
|
||||
assertEquals(".md.txt", SharedFileCapabilities.fileExtensionSuffixForName("notes.md.txt"))
|
||||
assertEquals(".fb2.zip.txt", SharedFileCapabilities.fileExtensionSuffixForName("book.fb2.zip.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop parity gaps list Android readable formats not yet available on desktop`() {
|
||||
assertEquals(emptyList(), SharedFileCapabilities.desktopParityGaps())
|
||||
assertEquals(listOf(FileType.PPTX), SharedFileCapabilities.desktopParityGaps())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,17 @@ class LocalFolderSyncEngineTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local folder sidecar filenames stay short for long book ids`() {
|
||||
val bookId = "local_" + "Very Long Book Name ".repeat(20) + ".pdf"
|
||||
|
||||
assertEquals(".book_37739e3be68f.json", localFolderSyncMetadataFileName(bookId))
|
||||
assertEquals(".book_37739e3be68f.tmp", localFolderSyncMetadataTempFileName(bookId))
|
||||
assertEquals(".book_37739e3be68f_annotations.json", localFolderSyncAnnotationFileName(bookId))
|
||||
assertEquals(".book_37739e3be68f_annotations.tmp", localFolderSyncAnnotationTempFileName(bookId))
|
||||
assertTrue(localFolderSyncAnnotationFileName(bookId).length < 80)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync imports scanned folder books with remote metadata`() {
|
||||
val state = SharedReaderScreenState()
|
||||
|
|
@ -42,7 +53,7 @@ class LocalFolderSyncEngineTest {
|
|||
|
||||
val book = result.state.rawLibraryBooks.single()
|
||||
assertEquals("local_Book.pdf", book.id)
|
||||
assertEquals("Remote Title", book.title)
|
||||
assertEquals("Book", book.title)
|
||||
assertEquals(4, book.lastPageIndex)
|
||||
assertEquals(25f, book.progressPercentage)
|
||||
assertEquals("C:/Library", book.sourceFolder)
|
||||
|
|
@ -73,7 +84,7 @@ class LocalFolderSyncEngineTest {
|
|||
)
|
||||
|
||||
val book = result.state.rawLibraryBooks.single()
|
||||
assertEquals("Remote", book.title)
|
||||
assertEquals("Local", book.title)
|
||||
assertEquals(80f, book.progressPercentage)
|
||||
assertEquals(1, result.stats.remoteMetadataUpdates)
|
||||
}
|
||||
|
|
@ -107,6 +118,31 @@ class LocalFolderSyncEngineTest {
|
|||
assertEquals(0, result.stats.remoteMetadataUpdates)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar display name survives physical folder scan`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
timestamp = 500L,
|
||||
displayName = "Reader Name",
|
||||
title = "Local"
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf")),
|
||||
remoteMetadata = mapOf(
|
||||
"local_Book.pdf" to metadata(
|
||||
id = "local_Book.pdf",
|
||||
displayName = "Reader Name",
|
||||
modified = 500L
|
||||
)
|
||||
),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
assertEquals("Reader Name", result.state.rawLibraryBooks.single().displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync migrates desktop path ids and preserves references`() {
|
||||
val oldId = "C:/Library/Series/Book.pdf"
|
||||
|
|
@ -173,12 +209,154 @@ class LocalFolderSyncEngineTest {
|
|||
assertEquals(1, result.stats.removedBooks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync ignores unknown scanned files even with default allowed types`() {
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(),
|
||||
folder = SyncedFolder(
|
||||
uriString = "C:/Library",
|
||||
name = "Library",
|
||||
lastScanTime = 0L
|
||||
),
|
||||
files = listOf(scannedFile("archive.zip", "archive.zip", type = FileType.UNKNOWN)),
|
||||
remoteMetadata = emptyMap(),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
assertTrue(result.state.rawLibraryBooks.isEmpty())
|
||||
assertEquals(0, result.stats.supportedFiles)
|
||||
assertEquals(0, result.stats.newBooks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default synced folder allowed types exclude unknown`() {
|
||||
assertFalse(FileType.UNKNOWN in SyncedFolder("C:/Library", "Library", lastScanTime = 0L).allowedFileTypes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata sidecar is skipped for clean unread folder books`() {
|
||||
assertNull(book(id = "local_Book.pdf", isRecent = false, progress = null).toSharedFolderBookMetadata())
|
||||
assertNotNull(book(id = "local_Book.pdf", isRecent = true).toSharedFolderBookMetadata())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata sidecar preserves precise reader position`() {
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = 2,
|
||||
pageIndex = 7,
|
||||
startOffset = 320,
|
||||
endOffset = 320,
|
||||
cfi = "desktop:2:320:320"
|
||||
)
|
||||
|
||||
val metadata = book(
|
||||
id = "local_Book.pdf",
|
||||
progress = 45f,
|
||||
readerPosition = locator
|
||||
).toSharedFolderBookMetadata() ?: error("Expected sidecar")
|
||||
val restored = metadata.toBookItem(
|
||||
file = scannedFile("Book.pdf", "Book.pdf"),
|
||||
existing = null,
|
||||
nowMillis = 2_000L
|
||||
)
|
||||
|
||||
assertEquals(2, metadata.lastChapterIndex)
|
||||
assertEquals(7, metadata.lastPage)
|
||||
assertEquals("desktop:2:320:320", metadata.lastPositionCfi)
|
||||
assertNull(metadata.locatorBlockIndex)
|
||||
assertNull(metadata.locatorCharOffset)
|
||||
assertEquals(locator, restored.readerPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata sidecar ignores legacy editable metadata`() {
|
||||
val local = book(id = "local_Book.pdf")
|
||||
.copy(
|
||||
isRecent = true,
|
||||
title = "Edited Title",
|
||||
author = "Edited Author",
|
||||
seriesName = "Edited Series",
|
||||
seriesIndex = 2.0,
|
||||
description = "<p>Edited summary</p>",
|
||||
originalTitle = "Original Title",
|
||||
originalAuthor = "Original Author",
|
||||
originalSeriesName = "Original Series",
|
||||
originalSeriesIndex = 1.0,
|
||||
originalDescription = "Original summary"
|
||||
)
|
||||
|
||||
val metadata = local.toSharedFolderBookMetadata() ?: error("Expected sidecar")
|
||||
val legacyMetadata = metadata.copy(
|
||||
title = "Legacy Sidecar Title",
|
||||
author = "Legacy Sidecar Author",
|
||||
seriesName = "Legacy Sidecar Series",
|
||||
seriesIndex = 2.0,
|
||||
description = "<p>Legacy summary</p>",
|
||||
originalTitle = "Legacy Original Title",
|
||||
originalAuthor = "Legacy Original Author",
|
||||
originalSeriesName = "Legacy Original Series",
|
||||
originalSeriesIndex = 1.0,
|
||||
originalDescription = "Legacy original summary"
|
||||
)
|
||||
val restored = metadata.toBookItem(
|
||||
file = scannedFile("Book.pdf", "Book.pdf"),
|
||||
existing = book(id = "local_Book.pdf", title = "Stale"),
|
||||
nowMillis = 2_000L
|
||||
)
|
||||
val restoredFromLegacy = legacyMetadata.toBookItem(
|
||||
file = scannedFile("Book.pdf", "Book.pdf"),
|
||||
existing = book(id = "local_Book.pdf", title = "Stale"),
|
||||
nowMillis = 2_000L
|
||||
)
|
||||
|
||||
assertNull(metadata.title)
|
||||
assertNull(metadata.description)
|
||||
assertNull(metadata.originalTitle)
|
||||
assertEquals("Stale", restored.title)
|
||||
assertNull(restored.author)
|
||||
assertNull(restored.seriesName)
|
||||
assertNull(restored.description)
|
||||
assertEquals("Stale", restoredFromLegacy.title)
|
||||
assertNull(restoredFromLegacy.author)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync resets extracted metadata and cover when folder file modified time changes`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
fileSize = 123L,
|
||||
title = "Extracted title",
|
||||
coverImagePath = "C:/Covers/book.png",
|
||||
folderTextMetadataParsed = true
|
||||
).copy(
|
||||
author = "Extracted author",
|
||||
description = "Extracted summary",
|
||||
seriesName = "Extracted series",
|
||||
seriesIndex = 1.0,
|
||||
originalTitle = "Extracted title",
|
||||
originalAuthor = "Extracted author",
|
||||
originalDescription = "Extracted summary",
|
||||
fileContentModifiedTimestamp = 100L
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf", size = 123L, lastModified = 500L)),
|
||||
remoteMetadata = emptyMap(),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
val updated = result.state.rawLibraryBooks.single()
|
||||
assertNull(updated.coverImagePath)
|
||||
assertFalse(updated.folderTextMetadataParsed)
|
||||
assertEquals(500L, updated.fileContentModifiedTimestamp)
|
||||
assertEquals("Book", updated.title)
|
||||
assertNull(updated.author)
|
||||
assertNull(updated.description)
|
||||
assertNull(updated.seriesName)
|
||||
assertNull(updated.originalTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync resets extracted metadata and cover when folder file size changes`() {
|
||||
val existing = book(
|
||||
|
|
@ -214,16 +392,18 @@ class LocalFolderSyncEngineTest {
|
|||
private fun scannedFile(
|
||||
name: String,
|
||||
relativePath: String,
|
||||
size: Long = 123L
|
||||
size: Long = 123L,
|
||||
type: FileType = FileType.PDF,
|
||||
lastModified: Long = 100L
|
||||
): SharedFolderScannedFile {
|
||||
return SharedFolderScannedFile(
|
||||
name = name,
|
||||
path = "C:/Library/$relativePath",
|
||||
sourceFolder = "C:/Library",
|
||||
relativePath = relativePath,
|
||||
type = FileType.PDF,
|
||||
type = type,
|
||||
size = size,
|
||||
lastModified = 100L
|
||||
lastModified = lastModified
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +418,8 @@ class LocalFolderSyncEngineTest {
|
|||
isRecent: Boolean = false,
|
||||
fileSize: Long = 0L,
|
||||
coverImagePath: String? = null,
|
||||
folderTextMetadataParsed: Boolean = false
|
||||
folderTextMetadataParsed: Boolean = false,
|
||||
readerPosition: ReaderLocator? = null
|
||||
): BookItem {
|
||||
return BookItem(
|
||||
id = id,
|
||||
|
|
@ -250,15 +431,18 @@ class LocalFolderSyncEngineTest {
|
|||
title = title,
|
||||
progressPercentage = progress,
|
||||
fileSize = fileSize,
|
||||
fileContentModifiedTimestamp = 100L,
|
||||
sourceFolder = sourceFolder,
|
||||
isRecent = isRecent,
|
||||
folderTextMetadataParsed = folderTextMetadataParsed
|
||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||
readerPosition = readerPosition
|
||||
)
|
||||
}
|
||||
|
||||
private fun metadata(
|
||||
id: String,
|
||||
title: String = "Book",
|
||||
displayName: String = "Book.pdf",
|
||||
lastPage: Int? = null,
|
||||
progress: Float = 0f,
|
||||
modified: Long
|
||||
|
|
@ -267,7 +451,7 @@ class LocalFolderSyncEngineTest {
|
|||
bookId = id,
|
||||
title = title,
|
||||
author = null,
|
||||
displayName = "Book.pdf",
|
||||
displayName = displayName,
|
||||
type = FileType.PDF.name,
|
||||
lastChapterIndex = null,
|
||||
lastPage = lastPage,
|
||||
|
|
|
|||
|
|
@ -37,10 +37,14 @@ class ReaderActionReducerTest {
|
|||
|
||||
val searched = chapterTwo.reduce(ReaderAction.SearchChanged("needle"), engine)
|
||||
assertTrue(searched.searchResults.size >= 2)
|
||||
assertTrue(searched.activeSearchResultIndex >= 0)
|
||||
assertEquals(-1, searched.activeSearchResultIndex)
|
||||
assertEquals(chapterTwo.reader.currentPageIndex, searched.reader.currentPageIndex)
|
||||
|
||||
val nextSearch = searched.reduce(ReaderAction.NextSearchResult, engine)
|
||||
assertEquals(searched.activeSearchResultIndex + 1, nextSearch.activeSearchResultIndex)
|
||||
assertEquals(
|
||||
searched.searchResults.indexOfFirst { it.pageIndex >= searched.reader.currentPageIndex },
|
||||
nextSearch.activeSearchResultIndex
|
||||
)
|
||||
|
||||
val directSearch = searched.reduce(ReaderAction.GoToSearchResult(0), engine)
|
||||
assertEquals(0, directSearch.activeSearchResultIndex)
|
||||
|
|
@ -86,11 +90,16 @@ class ReaderActionReducerTest {
|
|||
val closed = hiddenPanel.reduce(ReaderAction.SearchClosed, engine)
|
||||
|
||||
assertTrue(opened.isSearchActive)
|
||||
assertTrue(opened.showSearchResultsPanel)
|
||||
assertEquals(2, caseSensitive.searchResults.size)
|
||||
assertEquals(-1, caseSensitive.activeSearchResultIndex)
|
||||
assertEquals(session.reader.currentPageIndex, caseSensitive.reader.currentPageIndex)
|
||||
assertEquals(1, wholeWords.searchResults.size)
|
||||
assertEquals(false, hiddenPanel.showSearchResultsPanel)
|
||||
assertEquals("", closed.searchQuery)
|
||||
assertTrue(closed.searchResults.isEmpty())
|
||||
assertEquals(-1, closed.activeSearchResultIndex)
|
||||
assertTrue(closed.showSearchResultsPanel)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ReaderDefaultSettingsStateTest {
|
||||
|
||||
@Test
|
||||
fun `epub reader defaults to vertical mode`() {
|
||||
assertEquals(ReaderReadingMode.VERTICAL, ReaderSettings().readingMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader default settings reducer updates shared state`() {
|
||||
val defaults = ReaderSettings(
|
||||
fontSize = 24,
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
textAlign = SharedReaderTextAlign.JUSTIFY,
|
||||
themeId = "sepia"
|
||||
)
|
||||
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.ReaderDefaultSettingsChanged(defaults))
|
||||
|
||||
assertEquals(defaults, state.readerDefaultSettings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf reader default settings reducer updates separate shared state`() {
|
||||
val epubDefaults = ReaderSettings(themeId = "sepia")
|
||||
val pdfDefaults = ReaderSettings(themeId = "reverse")
|
||||
|
||||
val state = SharedReaderScreenState(readerDefaultSettings = epubDefaults)
|
||||
.reduce(AppAction.PdfReaderDefaultSettingsChanged(pdfDefaults))
|
||||
|
||||
assertEquals(epubDefaults, state.readerDefaultSettings)
|
||||
assertEquals(pdfDefaults, state.pdfReaderDefaultSettings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader default settings persist in shared snapshot json`() {
|
||||
val defaults = ReaderSettings(
|
||||
fontSize = 21,
|
||||
lineSpacing = 1.8f,
|
||||
margin = 72,
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
textAlign = SharedReaderTextAlign.CENTER,
|
||||
pageWidth = 920,
|
||||
fontFamily = "Serif",
|
||||
themeId = "dark",
|
||||
textureId = "paper",
|
||||
textureAlpha = 0.25f
|
||||
)
|
||||
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
SharedLibrarySnapshotJson.encode(
|
||||
SharedLibrarySnapshot(readerDefaultSettings = defaults)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(defaults, decoded.readerDefaultSettings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf reader default settings persist separately in shared snapshot json`() {
|
||||
val epubDefaults = ReaderSettings(themeId = "sepia")
|
||||
val pdfDefaults = ReaderSettings(themeId = "reverse")
|
||||
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
SharedLibrarySnapshotJson.encode(
|
||||
SharedLibrarySnapshot(
|
||||
readerDefaultSettings = epubDefaults,
|
||||
pdfReaderDefaultSettings = pdfDefaults
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(epubDefaults, decoded.readerDefaultSettings)
|
||||
assertEquals(pdfDefaults, decoded.pdfReaderDefaultSettings)
|
||||
}
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@ class ReaderExtrasModelsTest {
|
|||
)
|
||||
)
|
||||
val engine = ReaderEngine()
|
||||
val paginated = engine.createSession(book)
|
||||
val paginated = engine.createSession(book, settings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED))
|
||||
.reduce(ReaderAction.GoToChapter(1), engine)
|
||||
val vertical = engine.createSession(book, settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL))
|
||||
.reduce(ReaderAction.GoToChapter(1), engine)
|
||||
|
|
@ -194,6 +194,33 @@ class ReaderExtrasModelsTest {
|
|||
assertEquals(listOf(0, 1), ReaderTtsPlanner.chunksFromCurrentLocation(session).map { it.chapterIndex }.distinct())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner starts onward reading at visible locator offset`() {
|
||||
val source = "First hidden sentence. Second visible sentence. Third visible sentence."
|
||||
val visibleOffset = source.indexOf("Second")
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-visible",
|
||||
fileName = "tts-visible.epub",
|
||||
title = "TTS visible",
|
||||
chapters = listOf(SharedEpubChapter("one", "One", source))
|
||||
)
|
||||
val session = ReaderEngine().createSession(book).copy(
|
||||
navigationLocator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = visibleOffset,
|
||||
endOffset = visibleOffset,
|
||||
textQuote = "Second visible sentence."
|
||||
)
|
||||
)
|
||||
|
||||
val chunks = ReaderTtsPlanner.chunksFromCurrentLocation(session)
|
||||
|
||||
assertEquals(visibleOffset, chunks.first().startOffset)
|
||||
assertTrue(chunks.first().text.startsWith("Second visible sentence."))
|
||||
assertFalse(chunks.any { it.text.startsWith("First hidden") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner maps trimmed page text back to source offsets`() {
|
||||
val source = "Intro.\n\n Leading words continue."
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SettingsHubModelsTest {
|
||||
|
||||
@Test
|
||||
fun `settings hub root shows parent categories only`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedSettingsDestination.EPUB_TEXT,
|
||||
SharedSettingsDestination.PDF_COMICS,
|
||||
SharedSettingsDestination.THEME_APPEARANCE,
|
||||
SharedSettingsDestination.TTS_AI,
|
||||
SharedSettingsDestination.LIBRARY_SYNC_STORAGE,
|
||||
SharedSettingsDestination.SYNC_ACCOUNTS,
|
||||
SharedSettingsDestination.EXTRA
|
||||
),
|
||||
model.rootCategories.map { it.destination }
|
||||
)
|
||||
assertTrue(model.page(SharedSettingsDestination.ROOT).items.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline feature policy hides network backed nested settings`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(
|
||||
platform = SharedSettingsPlatform.ANDROID,
|
||||
featurePolicy = SharedFeaturePolicy.OssOffline,
|
||||
aiSettingsAvailable = true,
|
||||
isSignedIn = false
|
||||
)
|
||||
)
|
||||
val actions = model.visibleNestedActions()
|
||||
|
||||
assertFalse(SharedSettingsAction.AI_SETTINGS in actions)
|
||||
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
|
||||
assertFalse(SharedSettingsAction.SIGN_IN in actions)
|
||||
assertTrue(SharedSettingsAction.TTS_SETTINGS in actions)
|
||||
assertTrue(SharedSettingsAction.ABOUT in actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync unavailable hides account rows while preserving folder sync`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(
|
||||
platform = SharedSettingsPlatform.DESKTOP,
|
||||
syncAvailable = false,
|
||||
folderSyncAvailable = true,
|
||||
isSignedIn = true,
|
||||
isProUser = true
|
||||
)
|
||||
)
|
||||
val actions = model.visibleNestedActions()
|
||||
|
||||
assertFalse(SharedSettingsAction.SIGN_IN in actions)
|
||||
assertFalse(SharedSettingsAction.SIGN_OUT in actions)
|
||||
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
|
||||
assertTrue(SharedSettingsAction.FOLDER_SYNC in actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader tabs setting can be omitted for platforms without visible tabs`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(
|
||||
platform = SharedSettingsPlatform.DESKTOP,
|
||||
includeReaderTabs = false
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(SharedSettingsAction.TABS_TOGGLE in model.visibleNestedActions())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local override note appears on reader detail pages only`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP)
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
model.page(SharedSettingsDestination.EPUB_TEXT)
|
||||
.items
|
||||
.any { it.action == SharedSettingsAction.LOCAL_OVERRIDE_NOTE }
|
||||
)
|
||||
val note = model.page(SharedSettingsDestination.EPUB_FORMAT).localOverrideNote
|
||||
|
||||
assertEquals(SharedSettingsItemKind.INFO, note?.kind)
|
||||
assertTrue(note?.summary.orEmpty().contains("Local overrides"))
|
||||
assertTrue(note?.summary.orEmpty().contains("reader"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search returns nested results with breadcrumbs`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP)
|
||||
)
|
||||
|
||||
val results = model.searchResults("custom fonts")
|
||||
|
||||
assertEquals(1, results.size)
|
||||
assertEquals(SharedSettingsAction.CUSTOM_FONTS, results.first().action)
|
||||
assertEquals("Settings / Library & Files", results.first().breadcrumb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings destinations expose stable parents`() {
|
||||
assertEquals(SharedSettingsDestination.ROOT, SharedSettingsDestination.EPUB_TEXT.parentDestination())
|
||||
assertEquals(SharedSettingsDestination.EPUB_TEXT, SharedSettingsDestination.EPUB_FORMAT.parentDestination())
|
||||
assertEquals(SharedSettingsDestination.PDF_COMICS, SharedSettingsDestination.PDF_READER_TOOLS.parentDestination())
|
||||
assertEquals(SharedSettingsDestination.TTS_AI, SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS.parentDestination())
|
||||
assertEquals(SharedSettingsDestination.ROOT, SharedSettingsDestination.EXTRA.parentDestination())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts replacements are only exposed from global tts area`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP)
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
model.page(SharedSettingsDestination.EPUB_TEXT)
|
||||
.items
|
||||
.any { it.action == SharedSettingsAction.TTS_REPLACEMENTS }
|
||||
)
|
||||
assertEquals(
|
||||
SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS,
|
||||
model.page(SharedSettingsDestination.TTS_AI)
|
||||
.items
|
||||
.single { it.action == SharedSettingsAction.TTS_REPLACEMENTS }
|
||||
.destination
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedSettingsHubModel.visibleNestedActions(): List<SharedSettingsAction> {
|
||||
return rootCategories.flatMap { category ->
|
||||
page(category.destination).items.map { it.action }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedImportPlannerTest {
|
||||
|
||||
@Test
|
||||
fun `plan classifies importable duplicate and unsupported files`() {
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = listOf(
|
||||
ImportedBookFile(name = "existing.epub", uriString = null, localPath = "/books/existing.epub", size = 1L),
|
||||
ImportedBookFile(name = "new.md", uriString = null, localPath = "/books/new.md", size = 2L),
|
||||
ImportedBookFile(name = "archive.zip", uriString = null, localPath = "/books/archive.zip", size = 3L),
|
||||
ImportedBookFile(name = "new.md", uriString = null, localPath = "/books/new.md", size = 2L)
|
||||
),
|
||||
existingBookIds = setOf("/books/existing.epub"),
|
||||
platform = ReaderPlatform.DESKTOP,
|
||||
nowMillis = 100L
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedImportDecisionStatus.DUPLICATE,
|
||||
SharedImportDecisionStatus.IMPORTABLE,
|
||||
SharedImportDecisionStatus.UNSUPPORTED,
|
||||
SharedImportDecisionStatus.DUPLICATE
|
||||
),
|
||||
plan.decisions.map { it.status }
|
||||
)
|
||||
assertEquals(listOf("/books/new.md"), plan.importedBooks.map { it.id })
|
||||
assertEquals(listOf("existing.epub", "new.md", "new.md"), plan.supportedFiles.map { it.name })
|
||||
assertEquals(FileType.MD, plan.importedBooks.single().type)
|
||||
assertEquals(101L, plan.importedBooks.single().timestamp)
|
||||
assertEquals(null, plan.importedBooks.single().sourceFolder)
|
||||
assertFalse(plan.importedBooks.single().isRecent)
|
||||
assertEquals(1, plan.importedCount)
|
||||
assertEquals(2, plan.duplicateCount)
|
||||
assertEquals(1, plan.unsupportedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan uses uri as stable id when local path is absent`() {
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = listOf(
|
||||
ImportedBookFile(name = "scan.pdf", uriString = "content://scan", localPath = null, size = 4L, sourceFolder = "content://folder")
|
||||
),
|
||||
existingBookIds = emptySet(),
|
||||
platform = ReaderPlatform.ANDROID,
|
||||
nowMillis = 5L
|
||||
)
|
||||
|
||||
val book = plan.importedBooks.single()
|
||||
assertEquals("content://scan", book.id)
|
||||
assertEquals("content://scan", book.path)
|
||||
assertEquals("content://folder", book.sourceFolder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan prefers prepared file id over storage path`() {
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = listOf(
|
||||
ImportedBookFile(
|
||||
name = "novel.epub",
|
||||
uriString = null,
|
||||
localPath = "/app/books/copied.epub",
|
||||
size = 4L,
|
||||
id = "content-sha"
|
||||
)
|
||||
),
|
||||
existingBookIds = emptySet(),
|
||||
platform = ReaderPlatform.DESKTOP,
|
||||
nowMillis = 5L
|
||||
)
|
||||
|
||||
val book = plan.importedBooks.single()
|
||||
assertEquals("content-sha", book.id)
|
||||
assertEquals("/app/books/copied.epub", book.path)
|
||||
assertEquals(null, book.sourceFolder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `feedback prefers imported duplicate unsupported then failed outcomes`() {
|
||||
val imported = SharedImportPlanner.feedbackForCounts(
|
||||
counts = SharedImportOutcomeCounts(addedCount = 2, duplicateCount = 1, unsupportedCount = 1),
|
||||
importedMessage = "imported",
|
||||
duplicateMessage = "duplicate",
|
||||
unsupportedMessage = "unsupported",
|
||||
failedMessage = "failed"
|
||||
)
|
||||
val duplicate = SharedImportPlanner.feedbackForCounts(
|
||||
counts = SharedImportOutcomeCounts(duplicateCount = 1),
|
||||
importedMessage = "imported",
|
||||
duplicateMessage = "duplicate",
|
||||
unsupportedMessage = "unsupported",
|
||||
failedMessage = "failed"
|
||||
)
|
||||
val unsupported = SharedImportPlanner.feedbackForCounts(
|
||||
counts = SharedImportOutcomeCounts(unsupportedCount = 1),
|
||||
importedMessage = "imported",
|
||||
duplicateMessage = "duplicate",
|
||||
unsupportedMessage = "unsupported",
|
||||
failedMessage = "failed"
|
||||
)
|
||||
|
||||
assertEquals("imported", imported.message)
|
||||
assertFalse(imported.isError)
|
||||
assertEquals("duplicate", duplicate.message)
|
||||
assertFalse(duplicate.isError)
|
||||
assertEquals("unsupported", unsupported.message)
|
||||
assertTrue(unsupported.isError)
|
||||
}
|
||||
}
|
||||
|
|
@ -80,14 +80,19 @@ class SharedLibraryProjectorTest {
|
|||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("C:/books/notes.md", "mystery.bin", "C:/books/existing.pdf"), result.books.ids())
|
||||
assertEquals(listOf("C:/books/notes.md", "C:/books/existing.pdf"), result.books.ids())
|
||||
assertEquals(FileType.MD, result.books[0].type)
|
||||
assertEquals("C:/books", result.books[0].sourceFolder)
|
||||
assertFalse(result.books[0].isRecent)
|
||||
assertEquals(FileType.UNKNOWN, result.books[1].type)
|
||||
assertFalse(result.books[1].isRecent)
|
||||
assertTrue(projector.home(result).recentBooks.isEmpty())
|
||||
assertEquals("Imported 2 file(s). Reader support comes later.", result.message)
|
||||
assertEquals("Imported 1 file(s). Reader support comes later.", result.message)
|
||||
|
||||
val unsupportedOnly = projector.withImportedFiles(
|
||||
state,
|
||||
listOf(ImportedFile(name = "mystery.bin", path = null, size = 3L))
|
||||
)
|
||||
assertEquals(state.books.ids(), unsupportedOnly.books.ids())
|
||||
assertEquals("No supported files were imported.", unsupportedOnly.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -199,6 +204,34 @@ class SharedLibraryProjectorTest {
|
|||
assertEquals(listOf("loose", "tagged"), result.shelves.first { it.id == "unshelved" }.books.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedLibraryStateProjector auto creates synced folder fallback shelves from source folders`() {
|
||||
val folderBook = book(
|
||||
id = "folder_book",
|
||||
sourceFolder = "C:/Library",
|
||||
path = "C:/Library/Nested/Book.epub"
|
||||
)
|
||||
|
||||
val result = SharedLibraryStateProjector(
|
||||
SharedFolderPathResolver { item ->
|
||||
if (item.id == "folder_book") listOf("Nested") else emptyList()
|
||||
}
|
||||
).project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = SharedReaderScreenState(),
|
||||
booksFromStore = listOf(folderBook),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("C:/Library"), result.syncedFolders.map { it.uriString })
|
||||
assertEquals("Library", result.syncedFolders.single().name)
|
||||
assertEquals(listOf("folder_book"), result.shelves.first { it.id == "folder_C:/Library" }.books.ids())
|
||||
assertEquals(listOf("folder_book"), result.shelves.first { it.id == "folder_C:/Library::Nested" }.directBooks.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedLibraryStateProjector builds smart shelves from shared rules`() {
|
||||
val smartRules = SmartCollectionEngine.toJson(
|
||||
|
|
@ -244,6 +277,10 @@ class SharedLibraryProjectorTest {
|
|||
listOf(ImportedBookFile(name = "new.pdf", uriString = "content://new", localPath = null, size = 2L)),
|
||||
now = 20L
|
||||
)
|
||||
val unsupportedOnly = imported.withImportedFiles(
|
||||
listOf(ImportedBookFile(name = "archive.zip", uriString = null, localPath = "/books/archive.zip", size = 2L)),
|
||||
now = 30L
|
||||
)
|
||||
|
||||
assertEquals(listOf("content://new", "/books/existing.epub"), imported.rawLibraryBooks.ids())
|
||||
assertEquals(FileType.PDF, imported.rawLibraryBooks.first().type)
|
||||
|
|
@ -262,6 +299,8 @@ class SharedLibraryProjectorTest {
|
|||
assertTrue(projected.recentBooks.isEmpty())
|
||||
assertEquals("Imported 1 file(s).", imported.bannerMessage?.message)
|
||||
assertEquals("Those files are already in the library.", duplicateOnly.bannerMessage?.message)
|
||||
assertEquals(imported.rawLibraryBooks.ids(), unsupportedOnly.rawLibraryBooks.ids())
|
||||
assertEquals("No supported files were imported.", unsupportedOnly.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderViewport
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
|
|
@ -26,14 +28,29 @@ class SharedLibrarySnapshotJsonTest {
|
|||
coverImagePath = "C:/Covers/book.png",
|
||||
title = "Book",
|
||||
author = "Ada",
|
||||
description = "<p>A compact shared summary.</p>",
|
||||
originalTitle = "Original Book",
|
||||
originalAuthor = "Original Ada",
|
||||
originalSeriesName = "Original Series",
|
||||
originalSeriesIndex = 1.0,
|
||||
originalDescription = "Original summary",
|
||||
progressPercentage = 42f,
|
||||
fileSize = 99L,
|
||||
fileContentModifiedTimestamp = 123_456L,
|
||||
sourceFolder = "C:/Books",
|
||||
folderTextMetadataParsed = true,
|
||||
seriesName = "Series",
|
||||
seriesIndex = 2.0,
|
||||
tags = listOf(tag),
|
||||
lastPageIndex = 4,
|
||||
readerPosition = ReaderLocator(
|
||||
chapterIndex = 1,
|
||||
pageIndex = 4,
|
||||
startOffset = 220,
|
||||
endOffset = 220,
|
||||
textQuote = "Precise place",
|
||||
cfi = "desktop:1:220:220"
|
||||
),
|
||||
readerSettings = ReaderSettings(
|
||||
fontSize = 22,
|
||||
lineSpacing = 1.7f,
|
||||
|
|
@ -56,6 +73,9 @@ class SharedLibrarySnapshotJsonTest {
|
|||
systemUiMode = SystemUiMode.HIDDEN,
|
||||
pageInfoMode = PageInfoMode.SYNC,
|
||||
pageInfoPosition = PageInfoPosition.TOP,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
pdfVerticalPageGapVisible = false,
|
||||
pdfPageNumberOverlayVisible = false,
|
||||
seamlessChapterNavigation = false,
|
||||
chapterTurnDragMultiplier = 1.6f
|
||||
),
|
||||
|
|
@ -91,6 +111,15 @@ class SharedLibrarySnapshotJsonTest {
|
|||
cfi = "desktop:0:128:144"
|
||||
)
|
||||
)
|
||||
),
|
||||
pdfReaderViewport = SharedPdfReaderViewport(
|
||||
pageIndex = 4,
|
||||
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
|
||||
zoom = 1.8f,
|
||||
horizontalScrollOffset = 90,
|
||||
paginatedVerticalScrollOffset = 140,
|
||||
verticalFirstPageIndex = 3,
|
||||
verticalFirstPageScrollOffset = 44
|
||||
)
|
||||
)
|
||||
),
|
||||
|
|
@ -123,6 +152,8 @@ class SharedLibrarySnapshotJsonTest {
|
|||
customAppThemes = listOf(
|
||||
CustomAppTheme(id = "forest", name = "Forest", seedColor = Color(0xFF006C4C))
|
||||
),
|
||||
readerDefaultSettings = ReaderSettings(themeId = "sepia"),
|
||||
pdfReaderDefaultSettings = ReaderSettings(themeId = "reverse"),
|
||||
readerToolbarPreferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf(ReaderTool.SEARCH.id),
|
||||
toolOrder = listOf(ReaderTool.BOOKMARK, ReaderTool.THEME, ReaderTool.SEARCH),
|
||||
|
|
@ -169,6 +200,55 @@ class SharedLibrarySnapshotJsonTest {
|
|||
assertTrue(decoded.books.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing tab setting defaults to enabled for new desktop snapshots`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty("""{"schemaVersion":14}""")
|
||||
|
||||
assertTrue(decoded.isTabsEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy untouched epub default settings migrate to vertical mode`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 16,
|
||||
"readerDefaultSettings": {
|
||||
"readingMode": "PAGINATED"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
assertEquals(ReaderReadingMode.VERTICAL, decoded.readerDefaultSettings.readingMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy reader settings default pdf visual options to current behavior`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
"""
|
||||
{
|
||||
"books": [
|
||||
{
|
||||
"id": "book",
|
||||
"path": "C:/Books/book.pdf",
|
||||
"type": "PDF",
|
||||
"displayName": "book.pdf",
|
||||
"timestamp": 10,
|
||||
"readerSettings": {
|
||||
"themeId": "no_theme"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
val settings = decoded.books.single().readerSettings ?: error("Expected settings")
|
||||
|
||||
assertTrue(settings.pdfVerticalPageGapVisible)
|
||||
assertTrue(settings.pdfPageNumberOverlayVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy snapshot hides imported only books from recent home`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
|
|
@ -201,4 +281,36 @@ class SharedLibrarySnapshotJsonTest {
|
|||
assertFalse(decoded.books.first { it.id == "imported" }.isRecent)
|
||||
assertTrue(decoded.books.first { it.id == "opened" }.isRecent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `synced folder allowed types exclude unknown while preserving valid selections`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
"""
|
||||
{
|
||||
"syncedFolders": [
|
||||
{
|
||||
"uriString": "C:/Books",
|
||||
"name": "Books",
|
||||
"lastScanTime": 12,
|
||||
"allowedFileTypes": ["PDF", "UNKNOWN", "EPUB"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
val folder = decoded.syncedFolders.single()
|
||||
|
||||
assertEquals(setOf(FileType.PDF, FileType.EPUB), folder.allowedFileTypes)
|
||||
assertFalse(FileType.UNKNOWN in folder.allowedFileTypes)
|
||||
|
||||
val encoded = SharedLibrarySnapshotJson.encode(
|
||||
SharedLibrarySnapshot(
|
||||
syncedFolders = listOf(
|
||||
SyncedFolder("C:/Books", "Books", lastScanTime = 12L, allowedFileTypes = setOf(FileType.PDF, FileType.UNKNOWN))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse("\"UNKNOWN\"" in encoded)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedReducersTest {
|
||||
|
||||
@Test
|
||||
fun `book selection can be replaced in one reducer action`() {
|
||||
val state = SharedReaderScreenState(selectedBookIds = setOf("old"))
|
||||
|
||||
val result = state.reduce(LibraryAction.BookSelectionReplaced(setOf("one", "two")))
|
||||
|
||||
assertEquals(setOf("one", "two"), result.selectedBookIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible selection helper selects visible books and clears when all are selected`() {
|
||||
val visibleBooks = listOf(
|
||||
BookItem("one", "/books/one.epub", FileType.EPUB, "one.epub", timestamp = 1L),
|
||||
BookItem("two", "/books/two.epub", FileType.EPUB, "two.epub", timestamp = 2L)
|
||||
)
|
||||
|
||||
val selected = SharedReaderScreenState()
|
||||
.replaceBookSelectionWithVisibleBooks(visibleBooks)
|
||||
|
||||
assertEquals(setOf("one", "two"), selected.selectedBookIds)
|
||||
assertEquals(
|
||||
emptySet(),
|
||||
selected.replaceBookSelectionWithVisibleBooks(visibleBooks).selectedBookIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -103,5 +103,16 @@ class SharedOpdsCatalogsTest {
|
|||
urlPathSegment = null
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
".pptx",
|
||||
SharedOpdsDownloadNamer.resolveExtension(
|
||||
acquisition = OpdsAcquisition(
|
||||
"https://example.org/download",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
),
|
||||
contentDisposition = null,
|
||||
urlPathSegment = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.SearchHighlightMode
|
||||
import kotlin.test.Test
|
||||
|
|
@ -18,6 +19,14 @@ class PdfReaderSessionTest {
|
|||
assertTrue(state.canGoPrevious)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial interaction mode is neutral`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1)
|
||||
|
||||
assertEquals(PdfInkTool.NONE, state.selectedTool)
|
||||
assertEquals(false, state.isTextSelectionMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page navigation clamps to document bounds`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 3, initialPageIndex = 1)
|
||||
|
|
@ -61,6 +70,30 @@ class PdfReaderSessionTest {
|
|||
assertEquals(4f, state.zoom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader viewport clamps zoom pages and scroll offsets`() {
|
||||
val viewport = SharedPdfReaderViewport(
|
||||
pageIndex = 99,
|
||||
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
|
||||
zoom = Float.NaN,
|
||||
horizontalScrollOffset = -10,
|
||||
paginatedVerticalScrollOffset = -20,
|
||||
verticalFirstPageIndex = 40,
|
||||
verticalFirstPageScrollOffset = -30
|
||||
).sanitized(
|
||||
pageCount = 5,
|
||||
zoomSpec = PdfZoomSpec(min = 0.5f, max = 4f, default = 1.25f)
|
||||
)
|
||||
|
||||
assertEquals(PdfDisplayMode.VERTICAL_SCROLL, viewport.displayMode)
|
||||
assertEquals(4, viewport.pageIndex)
|
||||
assertEquals(4, viewport.verticalFirstPageIndex)
|
||||
assertEquals(1.25f, viewport.zoom)
|
||||
assertEquals(0, viewport.horizontalScrollOffset)
|
||||
assertEquals(0, viewport.paginatedVerticalScrollOffset)
|
||||
assertEquals(0, viewport.verticalFirstPageScrollOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search query resets active result and result navigation wraps`() {
|
||||
val results = listOf(
|
||||
|
|
@ -68,16 +101,42 @@ class PdfReaderSessionTest {
|
|||
SharedPdfSearchResult(pageIndex = 3, preview = "second", matchIndex = 7)
|
||||
)
|
||||
|
||||
val state = SharedPdfReaderState.initial(pageCount = 5)
|
||||
val changed = SharedPdfReaderState.initial(pageCount = 5)
|
||||
.reduce(SharedPdfReaderAction.GoToSearchResult(0, results))
|
||||
.reduce(SharedPdfReaderAction.SearchHighlightModeChanged(SearchHighlightMode.FOCUSED))
|
||||
.reduce(SharedPdfReaderAction.SearchChanged("needle"))
|
||||
val state = changed
|
||||
.reduce(SharedPdfReaderAction.GoToSearchResult(-1, results))
|
||||
|
||||
assertEquals("needle", state.searchQuery)
|
||||
assertEquals("needle", changed.searchQuery)
|
||||
assertEquals(-1, changed.activeSearchResultIndex)
|
||||
assertEquals(SearchHighlightMode.FOCUSED, changed.searchHighlightMode)
|
||||
assertEquals(1, changed.pageIndex)
|
||||
assertEquals(1, state.activeSearchResultIndex)
|
||||
assertEquals(3, state.pageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search chrome actions open toggle and close shared state`() {
|
||||
val opened = SharedPdfReaderState.initial(pageCount = 4)
|
||||
.reduce(SharedPdfReaderAction.SearchOpened)
|
||||
val typed = opened.reduce(SharedPdfReaderAction.SearchChanged("alpha"))
|
||||
val hidden = typed.reduce(SharedPdfReaderAction.SearchResultsPanelToggled)
|
||||
val closed = hidden.reduce(SharedPdfReaderAction.SearchClosed)
|
||||
|
||||
assertTrue(opened.isSearchActive)
|
||||
assertTrue(opened.showSearchResultsPanel)
|
||||
assertEquals("alpha", typed.searchQuery)
|
||||
assertTrue(typed.isSearchActive)
|
||||
assertTrue(typed.showSearchResultsPanel)
|
||||
assertEquals(-1, typed.activeSearchResultIndex)
|
||||
assertEquals(false, hidden.showSearchResultsPanel)
|
||||
assertEquals(false, closed.isSearchActive)
|
||||
assertTrue(closed.showSearchResultsPanel)
|
||||
assertEquals("", closed.searchQuery)
|
||||
assertEquals(-1, closed.activeSearchResultIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search highlight mode toggles between all and focused`() {
|
||||
val focused = SharedPdfReaderState.initial(pageCount = 1)
|
||||
|
|
@ -101,6 +160,22 @@ class PdfReaderSessionTest {
|
|||
assertEquals(config.strokeWidth, state.strokeWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text selection markup tools and neutral mode are exclusive`() {
|
||||
val selectingText = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.PEN))
|
||||
.reduce(SharedPdfReaderAction.TextSelectionModeChanged(true))
|
||||
val addingTextAnnotation = selectingText.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.TEXT))
|
||||
val neutral = addingTextAnnotation.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.NONE))
|
||||
|
||||
assertEquals(true, selectingText.isTextSelectionMode)
|
||||
assertEquals(PdfInkTool.NONE, selectingText.selectedTool)
|
||||
assertEquals(false, addingTextAnnotation.isTextSelectionMode)
|
||||
assertEquals(PdfInkTool.TEXT, addingTextAnnotation.selectedTool)
|
||||
assertEquals(false, neutral.isTextSelectionMode)
|
||||
assertEquals(PdfInkTool.NONE, neutral.selectedTool)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotation actions mutate immutable annotation list`() {
|
||||
val first = annotation("first", pageIndex = 0)
|
||||
|
|
@ -236,6 +311,7 @@ class PdfReaderSessionTest {
|
|||
assertEquals(0, punctuationResults.single().matchIndex)
|
||||
assertEquals("hello,\nworld".length, punctuationResults.single().matchLength)
|
||||
assertEquals(listOf(0, 2), alphaResults.map { it.pageIndex })
|
||||
assertEquals(listOf(3, 3), alphaResults.map { it.matchLength })
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -296,6 +372,38 @@ class PdfReaderSessionTest {
|
|||
assertEquals(5, pageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical page gap option keeps default spacing or removes it`() {
|
||||
assertEquals(8.dp, pdfVerticalPageGapDp(isPageGapVisible = true, defaultGap = 8.dp))
|
||||
assertEquals(0.dp, pdfVerticalPageGapDp(isPageGapVisible = false, defaultGap = 8.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical page layout removes fractional pixel seams when gap is hidden`() {
|
||||
val layout = calculatePdfVerticalPageLayoutPx(
|
||||
pageAspectRatios = listOf(0.707f, 0.721f, 0.69f),
|
||||
viewportWidthPx = 1081,
|
||||
viewportHeightPx = 1920,
|
||||
pageGapPx = 0
|
||||
)
|
||||
|
||||
layout.pages.zipWithNext().forEach { (previous, next) ->
|
||||
assertEquals(previous.bottomPx, next.topPx)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical page layout keeps exact configured page gap`() {
|
||||
val layout = calculatePdfVerticalPageLayoutPx(
|
||||
pageAspectRatios = listOf(0.707f, 0.721f),
|
||||
viewportWidthPx = 1081,
|
||||
viewportHeightPx = 1920,
|
||||
pageGapPx = 12
|
||||
)
|
||||
|
||||
assertEquals(layout.pages.first().bottomPx + 12, layout.pages.last().topPx)
|
||||
}
|
||||
|
||||
private fun annotation(id: String, pageIndex: Int): SharedPdfAnnotation {
|
||||
return SharedPdfAnnotation(
|
||||
id = id,
|
||||
|
|
|
|||
|
|
@ -41,10 +41,12 @@ class SharedPdfAnnotationSerializerTest {
|
|||
"ink": [
|
||||
{
|
||||
"pageIndex": 1,
|
||||
"id": "ink-1",
|
||||
"annotationType": "INK",
|
||||
"inkType": "PENCIL",
|
||||
"color": -16777216,
|
||||
"strokeWidth": 0.008,
|
||||
"note": "Desktop-only ink note",
|
||||
"points": [{"x":0.1,"y":0.2,"t":10},{"x":0.3,"y":0.4,"t":12}]
|
||||
}
|
||||
],
|
||||
|
|
@ -81,8 +83,11 @@ class SharedPdfAnnotationSerializerTest {
|
|||
|
||||
assertNotNull(data[SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS])
|
||||
assertEquals(listOf(PdfAnnotationKind.INK, PdfAnnotationKind.TEXT, PdfAnnotationKind.HIGHLIGHT), annotations.map { it.kind })
|
||||
assertEquals("ink-1", annotations[0].id)
|
||||
assertEquals(PdfInkTool.PENCIL, annotations[0].tool)
|
||||
assertEquals("Desktop-only ink note", annotations[0].note)
|
||||
assertEquals(16f, annotations[1].fontSize, 0.001f)
|
||||
assertEquals(0.032f, annotations[1].pageRelativeFontSize ?: 0f, 0.0001f)
|
||||
assertTrue(annotations[1].isBold)
|
||||
assertEquals("Keep this", annotations[2].note)
|
||||
assertEquals(4, annotations[2].rangeStartIndex)
|
||||
|
|
@ -110,7 +115,8 @@ class SharedPdfAnnotationSerializerTest {
|
|||
text = "Desktop text",
|
||||
colorArgb = 0xFF112233.toInt(),
|
||||
backgroundArgb = 0x66112233,
|
||||
fontSize = 20f
|
||||
fontSize = 20f,
|
||||
pageRelativeFontSize = 0.031f
|
||||
),
|
||||
SharedPdfAnnotation(
|
||||
id = "highlight-1",
|
||||
|
|
@ -141,15 +147,72 @@ class SharedPdfAnnotationSerializerTest {
|
|||
assertEquals("FOUNTAIN_PEN", legacy.getValue("ink").jsonArray[0].jsonObject.getValue("inkType").jsonPrimitive.content)
|
||||
assertEquals(1, legacy.getValue("textBoxes").jsonArray.size)
|
||||
assertEquals(
|
||||
0.04,
|
||||
0.031,
|
||||
legacy.getValue("textBoxes").jsonArray[0].jsonObject.getValue("fontSize").jsonPrimitive.content.toDouble(),
|
||||
0.0001
|
||||
)
|
||||
assertEquals(1, legacy.getValue("highlights").jsonArray.size)
|
||||
assertEquals("BLUE", legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("color").jsonPrimitive.content)
|
||||
assertEquals("Synced note", legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("note").jsonPrimitive.content)
|
||||
assertEquals(22, legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("rangeEnd").jsonPrimitive.content.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec treats canonical annotations as authoritative for android legacy expansion`() {
|
||||
val canonicalAnnotation = SharedPdfAnnotation(
|
||||
id = "desktop-ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = PdfInkTool.PEN,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f, 100L)),
|
||||
note = "Edited on desktop",
|
||||
colorArgb = 0xFF112233.toInt(),
|
||||
strokeWidth = 0.01f
|
||||
)
|
||||
val payload = testJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(listOf(canonicalAnnotation)),
|
||||
"ink" to testJson.parseToJsonElement(
|
||||
"""[{"id":"stale","pageIndex":9,"annotationType":"INK","inkType":"PENCIL","color":0,"strokeWidth":1,"points":[{"x":0.9,"y":0.9}]}]"""
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val legacy = testJson.parseToJsonElement(
|
||||
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(payload)
|
||||
).jsonObject
|
||||
val ink = legacy.getValue("ink").jsonArray.single().jsonObject
|
||||
|
||||
assertEquals("desktop-ink", ink.getValue("id").jsonPrimitive.content)
|
||||
assertEquals("Edited on desktop", ink.getValue("note").jsonPrimitive.content)
|
||||
assertEquals(0, ink.getValue("pageIndex").jsonPrimitive.content.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec expands empty canonical annotations to empty android legacy arrays`() {
|
||||
val payload = testJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(emptyList())
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val legacy = testJson.parseToJsonElement(
|
||||
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(payload)
|
||||
).jsonObject
|
||||
|
||||
assertEquals(0, legacy.getValue("ink").jsonArray.size)
|
||||
assertEquals(0, legacy.getValue("textBoxes").jsonArray.size)
|
||||
assertEquals(0, legacy.getValue("highlights").jsonArray.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `embedded annotation threads link replies and nearby orphan comments`() {
|
||||
val root = embeddedAnnotation(
|
||||
|
|
|
|||
|
|
@ -139,6 +139,35 @@ class SharedPdfRichTextTest {
|
|||
assertEquals(document, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loaded rich text rescales font spans when real page height arrives`() {
|
||||
val document = SharedPdfRichDocument(
|
||||
text = "Stable size",
|
||||
spans = listOf(
|
||||
SharedPdfRichSpan(
|
||||
start = 0,
|
||||
end = 6,
|
||||
color = Color.Black.toArgb(),
|
||||
backgroundColor = Color.Transparent.toArgb(),
|
||||
fontSizeNorm = 0.02f,
|
||||
isBold = false,
|
||||
isItalic = false,
|
||||
isUnderline = false,
|
||||
isStrikethrough = false
|
||||
)
|
||||
)
|
||||
)
|
||||
val referenceHeight = 1_414f
|
||||
val actualHeight = 1_000f
|
||||
val loadedBeforeLayout = SharedPdfRichTextMapper.toAnnotatedString(document, referenceHeight)
|
||||
|
||||
val loadedAtActualHeight = loadedBeforeLayout.withScaledSharedPdfRichFontSizes(actualHeight / referenceHeight)
|
||||
val savedAgain = SharedPdfRichTextMapper.fromAnnotatedString(loadedAtActualHeight, actualHeight)
|
||||
|
||||
assertEquals(20.sp, loadedAtActualHeight.spanStyles.single().item.fontSize)
|
||||
assertEquals(0.02f, savedAgain.spans.single().fontSizeNorm, 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serializer returns empty document for blank and corrupt payloads`() {
|
||||
assertEquals(SharedPdfRichDocument(), SharedPdfRichTextSerializer.decode(""))
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ class SharedPdfTextAnnotationsTest {
|
|||
assertEquals(PdfAnnotationKind.TEXT, annotation.kind)
|
||||
assertEquals(PdfInkTool.TEXT, annotation.tool)
|
||||
assertEquals("Styled note", annotation.text)
|
||||
assertEquals(style, annotation.sharedPdfTextStyle())
|
||||
assertEquals(style.copy(pageRelativeFontSize = 0.04f), annotation.sharedPdfTextStyle())
|
||||
assertEquals(0.04f, annotation.pageRelativeFontSize ?: 0f, 0.0001f)
|
||||
assertEquals(99L, annotation.createdAt)
|
||||
assertTrue(annotation.bounds!!.left >= 0f)
|
||||
assertTrue(annotation.bounds.right <= 1f)
|
||||
|
|
@ -72,7 +73,28 @@ class SharedPdfTextAnnotationsTest {
|
|||
assertEquals("Keep me", updated.text)
|
||||
assertEquals(original.bounds, updated.bounds)
|
||||
assertEquals(5L, updated.createdAt)
|
||||
assertEquals(style, updated.sharedPdfTextStyle())
|
||||
assertEquals(style.copy(pageRelativeFontSize = 0.048f), updated.sharedPdfTextStyle())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page relative font size drives Android-compatible text rendering size`() {
|
||||
val canvasSize = IntSize(1_000, 1_500)
|
||||
val style = SharedPdfTextStyleConfig(fontSize = 20f, pageRelativeFontSize = 0.03f)
|
||||
|
||||
assertEquals(45f, style.sharedPdfTextFontSizePx(canvasSize), 0.0001f)
|
||||
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "text-android-size",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
bounds = PdfPageBounds(0.1f, 0.1f, 0.4f, 0.2f),
|
||||
text = "Sized like Android",
|
||||
colorArgb = 0xFF000000.toInt(),
|
||||
fontSize = 20f,
|
||||
pageRelativeFontSize = 0.03f
|
||||
)
|
||||
|
||||
assertEquals(45f, annotation.sharedPdfTextFontSizePx(canvasSize), 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -118,7 +140,7 @@ class SharedPdfTextAnnotationsTest {
|
|||
assertEquals(PdfAnnotationKind.TEXT, annotation.kind)
|
||||
assertEquals(PdfInkTool.TEXT, annotation.tool)
|
||||
assertEquals("Inline note", annotation.text)
|
||||
assertEquals(style, annotation.sharedPdfTextStyle())
|
||||
assertEquals(style.copy(pageRelativeFontSize = 0.036f), annotation.sharedPdfTextStyle())
|
||||
assertEquals(draft.bounds, annotation.bounds)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.paginatedreader.CssStyle
|
||||
import com.aryan.reader.paginatedreader.SemanticParagraph
|
||||
import kotlin.test.Test
|
||||
|
|
@ -37,6 +38,111 @@ class ReaderEngineTest {
|
|||
assertSame(first.reader.pages, second.reader.pages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visual settings update does not repaginate or move current page`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.goToPage(engine.createSession(longBook()), 1)
|
||||
val oldPages = session.reader.pages
|
||||
val oldPageIndex = session.reader.currentPageIndex
|
||||
|
||||
val updated = engine.updateSettings(
|
||||
session,
|
||||
session.reader.settings.copy(
|
||||
darkMode = true,
|
||||
themeId = "night",
|
||||
backgroundColorArgb = 0xFF101010L,
|
||||
textColorArgb = 0xFFEFEFEFL,
|
||||
textureId = "paper",
|
||||
textureAlpha = 0.25f
|
||||
)
|
||||
)
|
||||
|
||||
assertSame(oldPages, updated.reader.pages)
|
||||
assertEquals(oldPageIndex, updated.reader.currentPageIndex)
|
||||
assertEquals("night", updated.reader.settings.themeId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createSession restores precise locator ahead of fallback page index`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = longBook()
|
||||
val base = engine.createSession(book)
|
||||
val targetPage = base.reader.pages.getOrNull(2) ?: error("Expected multiple pages")
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = targetPage.chapterIndex,
|
||||
pageIndex = targetPage.pageIndex,
|
||||
startOffset = targetPage.startOffset + 12,
|
||||
endOffset = targetPage.startOffset + 12,
|
||||
cfi = "desktop:${targetPage.chapterIndex}:${targetPage.startOffset + 12}:${targetPage.startOffset + 12}"
|
||||
)
|
||||
|
||||
val restored = engine.createSession(
|
||||
book = book,
|
||||
initialPageIndex = 0,
|
||||
initialLocator = locator
|
||||
)
|
||||
|
||||
assertEquals(targetPage.pageIndex, restored.navigationLocator?.pageIndex)
|
||||
assertEquals(targetPage.pageIndex, restored.reader.currentPageIndex)
|
||||
assertEquals(locator.startOffset, restored.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `layout settings update keeps precise visible locator across reading modes`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook())
|
||||
val targetPage = session.reader.pages.getOrNull(1) ?: error("Expected multiple pages")
|
||||
val visibleLocator = ReaderLocator(
|
||||
chapterIndex = targetPage.chapterIndex,
|
||||
pageIndex = targetPage.pageIndex,
|
||||
startOffset = targetPage.startOffset + 40,
|
||||
endOffset = targetPage.startOffset + 40,
|
||||
textQuote = "visible text",
|
||||
cfi = "desktop:${targetPage.chapterIndex}:${targetPage.startOffset + 40}:${targetPage.startOffset + 40}"
|
||||
)
|
||||
val synced = engine.syncVisiblePage(session, targetPage.pageIndex, visibleLocator)
|
||||
|
||||
val updated = engine.updateSettings(
|
||||
synced,
|
||||
synced.reader.settings.copy(
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
fontSize = synced.reader.settings.fontSize + 4
|
||||
)
|
||||
)
|
||||
|
||||
val page = updated.reader.currentPage ?: error("Expected current page")
|
||||
assertEquals(visibleLocator.startOffset, updated.navigationLocator?.startOffset)
|
||||
assertTrue(visibleLocator.startOffset!! in page.startOffset..page.endOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page spread keeps right page locator while normalizing visible spread start`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook())
|
||||
val targetPage = session.reader.pages.getOrNull(3) ?: error("Expected multiple pages")
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = targetPage.chapterIndex,
|
||||
pageIndex = targetPage.pageIndex,
|
||||
startOffset = targetPage.startOffset + 20,
|
||||
endOffset = targetPage.startOffset + 20,
|
||||
cfi = "desktop:${targetPage.chapterIndex}:${targetPage.startOffset + 20}:${targetPage.startOffset + 20}"
|
||||
)
|
||||
val synced = engine.syncVisiblePage(session, targetPage.pageIndex, locator)
|
||||
|
||||
val updated = engine.updateSettings(
|
||||
synced,
|
||||
synced.reader.settings.copy(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(targetPage.pageIndex - 1, updated.reader.currentPageIndex)
|
||||
assertEquals(targetPage.pageIndex, updated.navigationLocator?.pageIndex)
|
||||
assertEquals(locator.startOffset, updated.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search returns every match on a page`() {
|
||||
val engine = ReaderEngine()
|
||||
|
|
@ -60,6 +166,7 @@ class ReaderEngineTest {
|
|||
assertEquals(3, searched.searchResults.size)
|
||||
assertEquals(listOf(0, 11, 23), searched.searchResults.map { it.matchIndex })
|
||||
assertTrue(searched.searchResults.all { it.pageIndex == 0 })
|
||||
assertEquals(-1, searched.activeSearchResultIndex)
|
||||
|
||||
val secondMatch = engine.goToSearchResult(searched, 1)
|
||||
|
||||
|
|
@ -172,6 +279,116 @@ class ReaderEngineTest {
|
|||
assertEquals(7, target.locator.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jump navigation records locator history and can step back and forward`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(multiChapterBook())
|
||||
|
||||
val second = engine.jumpToChapter(session, 1)
|
||||
val third = engine.jumpToChapter(second, 2)
|
||||
val back = engine.jumpBack(third)
|
||||
val forward = engine.jumpForward(back)
|
||||
|
||||
assertEquals(1, third.jumpHistory.backLocator?.chapterIndex)
|
||||
assertEquals(1, back.reader.currentPage?.chapterIndex)
|
||||
assertEquals(0, back.jumpHistory.backLocator?.chapterIndex)
|
||||
assertEquals(2, back.jumpHistory.forwardLocator?.chapterIndex)
|
||||
assertEquals(2, forward.reader.currentPage?.chapterIndex)
|
||||
assertTrue(engine.clearJumpHistory(forward).jumpHistory.locators.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginated mode does not record or use jump history`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
book = multiChapterBook(),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED)
|
||||
)
|
||||
|
||||
val jumped = engine.jumpToChapter(session, 1)
|
||||
val verticalWithHistory = engine.jumpToChapter(engine.createSession(multiChapterBook()), 1)
|
||||
val switchedToPaginated = engine.updateSettings(
|
||||
verticalWithHistory,
|
||||
verticalWithHistory.reader.settings.copy(readingMode = ReaderReadingMode.PAGINATED)
|
||||
)
|
||||
val withLegacyHistory = jumped.copy(
|
||||
jumpHistory = ReaderJumpHistory()
|
||||
.record(
|
||||
currentLocator = ReaderLocator(chapterIndex = 0, cfi = "desktop:0:0:0"),
|
||||
targetLocator = ReaderLocator(chapterIndex = 1, cfi = "desktop:1:0:0"),
|
||||
chapterCount = 3
|
||||
)
|
||||
)
|
||||
val back = engine.jumpBack(withLegacyHistory)
|
||||
|
||||
assertTrue(jumped.jumpHistory.locators.isEmpty())
|
||||
assertTrue(switchedToPaginated.jumpHistory.locators.isEmpty())
|
||||
assertEquals(jumped.reader.currentPageIndex, back.reader.currentPageIndex)
|
||||
assertTrue(back.jumpHistory.locators.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacePages uses captured reflow anchor when no newer navigation happened`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = manualRangeBook()
|
||||
val oldPages = listOf(
|
||||
ReaderPage(0, 0, "One", "first", 0, 100),
|
||||
ReaderPage(1, 0, "One", "second", 100, 200)
|
||||
)
|
||||
val newPages = listOf(
|
||||
ReaderPage(0, 0, "One", "first expanded", 0, 140),
|
||||
ReaderPage(1, 0, "One", "second shifted", 140, 260)
|
||||
)
|
||||
val session = engine.createSession(book).copy(
|
||||
reader = PaginatedReaderState(book, oldPages, currentPageIndex = 1),
|
||||
navigationLocator = ReaderLocator(chapterIndex = 0, pageIndex = 0, startOffset = 20, endOffset = 20),
|
||||
navigationRequestId = 4L
|
||||
)
|
||||
val reflowAnchor = ReaderLocator(chapterIndex = 0, pageIndex = 1, startOffset = 160, endOffset = 160)
|
||||
|
||||
val replaced = engine.replacePages(
|
||||
state = session,
|
||||
pages = newPages,
|
||||
reflowAnchor = reflowAnchor,
|
||||
navigationRequestIdAtReflowStart = 4L
|
||||
)
|
||||
|
||||
assertEquals(1, replaced.reader.currentPageIndex)
|
||||
assertEquals(1, replaced.navigationLocator?.pageIndex)
|
||||
assertEquals(160, replaced.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacePages lets newer explicit navigation override reflow anchor`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = manualRangeBook()
|
||||
val oldPages = listOf(
|
||||
ReaderPage(0, 0, "One", "first", 0, 100),
|
||||
ReaderPage(1, 0, "One", "second", 100, 200)
|
||||
)
|
||||
val newPages = listOf(
|
||||
ReaderPage(0, 0, "One", "first expanded", 0, 140),
|
||||
ReaderPage(1, 0, "One", "second shifted", 140, 260)
|
||||
)
|
||||
val session = engine.createSession(book).copy(
|
||||
reader = PaginatedReaderState(book, oldPages, currentPageIndex = 0),
|
||||
navigationLocator = ReaderLocator(chapterIndex = 0, pageIndex = 0, startOffset = 20, endOffset = 20),
|
||||
navigationRequestId = 5L
|
||||
)
|
||||
val staleReflowAnchor = ReaderLocator(chapterIndex = 0, pageIndex = 1, startOffset = 160, endOffset = 160)
|
||||
|
||||
val replaced = engine.replacePages(
|
||||
state = session,
|
||||
pages = newPages,
|
||||
reflowAnchor = staleReflowAnchor,
|
||||
navigationRequestIdAtReflowStart = 4L
|
||||
)
|
||||
|
||||
assertEquals(0, replaced.reader.currentPageIndex)
|
||||
assertEquals(0, replaced.navigationLocator?.pageIndex)
|
||||
assertEquals(20, replaced.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
private fun longBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "long",
|
||||
|
|
@ -187,4 +404,32 @@ class ReaderEngineTest {
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun multiChapterBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "multi",
|
||||
fileName = "multi.epub",
|
||||
title = "Multi",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(id = "one", title = "One", plainText = "First chapter text."),
|
||||
SharedEpubChapter(id = "two", title = "Two", plainText = "Second chapter text."),
|
||||
SharedEpubChapter(id = "three", title = "Three", plainText = "Third chapter text.")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun manualRangeBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "manual",
|
||||
fileName = "manual.epub",
|
||||
title = "Manual",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = List(300) { "x" }.joinToString("")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,8 +61,156 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
highlights = listOf(highlight)
|
||||
)
|
||||
|
||||
assertEquals(1, Regex("<mark class=\"reader-user-highlight").findAll(html).count())
|
||||
assertTrue(html.contains("""alpha beta <mark class="reader-user-highlight user-highlight-yellow" data-reader-highlight-id="highlight-1" data-reader-start-offset="11" data-reader-end-offset="16">alpha</mark> beta"""))
|
||||
assertEquals(1, Regex("<span class=\"reader-user-highlight").findAll(html).count())
|
||||
assertTrue(html.contains("""alpha beta <span class="reader-user-highlight user-highlight-yellow" data-reader-highlight-id="highlight-1" data-cfi="desktop:0:11:16" data-reader-start-offset="11" data-reader-end-offset="16">alpha</span> beta"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stored highlights split around paragraph markup`() {
|
||||
val text = "alpha\n\nbeta"
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "desktop:0:0:${text.length}",
|
||||
text = "alpha beta",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = 0,
|
||||
endOffset = text.length,
|
||||
textQuote = "alpha beta",
|
||||
cfi = "desktop:0:0:${text.length}"
|
||||
)
|
||||
)
|
||||
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook(text),
|
||||
page = ReaderPage(0, 0, "One", text, 0, text.length),
|
||||
settings = ReaderSettings(),
|
||||
highlights = listOf(highlight)
|
||||
)
|
||||
|
||||
assertEquals(2, Regex("<span class=\"reader-user-highlight").findAll(html).count())
|
||||
assertFalse(html.contains("<span class=\"reader-user-highlight user-highlight-yellow\" data-reader-highlight-id=\"highlight-1\" data-cfi=\"desktop:0:0:${text.length}\" data-reader-start-offset=\"0\" data-reader-end-offset=\"${text.length}\"><p"))
|
||||
assertFalse(html.contains("</p></span>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader highlight script verifies stored text before applying offsets`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("alpha beta alpha beta"),
|
||||
page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "alpha beta alpha beta",
|
||||
startOffset = 0,
|
||||
endOffset = 21
|
||||
),
|
||||
settings = ReaderSettings()
|
||||
)
|
||||
|
||||
assertTrue(html.contains("actualNormalized !== expectedNormalized"))
|
||||
assertTrue(html.contains("startOffset >= pageEnd || endOffset <= pageStart"))
|
||||
assertTrue(html.contains("normalizedRangeForText(searchRoot, expectedNormalized, false)"))
|
||||
assertTrue(html.contains("locator.textQuote || highlight.text"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader highlight script wraps locally before guarded bridge send`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
page = ReaderPage(0, 0, "One", "alpha beta", 0, 10),
|
||||
settings = ReaderSettings()
|
||||
)
|
||||
val localWrapIndex = html.indexOf("wrapRangeTextSegments(localRange")
|
||||
val bridgeSendIndex = html.indexOf("sendReaderHighlightCreated(payload, 0)")
|
||||
|
||||
assertTrue(html.contains("function sendReaderHighlightCreated(payload, attempt)"))
|
||||
assertTrue(html.contains("highlight_bridge_error attempt="))
|
||||
assertTrue(html.contains("var marker = document.createElement('span');"))
|
||||
assertTrue(html.contains("range.intersectsNode(node)"))
|
||||
assertFalse(html.contains("paintUserHighlightRange(payload"))
|
||||
assertTrue(localWrapIndex >= 0)
|
||||
assertTrue(bridgeSendIndex > localWrapIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader highlight script wraps text fallback highlights without stale overlay rects`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
page = ReaderPage(0, 0, "One", "alpha beta", 0, 10),
|
||||
settings = ReaderSettings()
|
||||
)
|
||||
|
||||
assertTrue(html.contains("function applyHighlightTextFallback(highlight)"))
|
||||
assertTrue(html.contains("applyHighlightTextFallback(highlight);"))
|
||||
assertTrue(html.contains("normalizedRangeForText(content, expectedText, false)"))
|
||||
assertTrue(html.contains("wrapRangeTextSegments(range, function ()"))
|
||||
assertFalse(html.contains("function paintUserHighlightRange("))
|
||||
assertFalse(html.contains("reader-user-highlight-layer"))
|
||||
assertFalse(html.contains("reader-user-highlight-rect"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader highlight script rejects mismatched fallback text ranges`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("alpha beta alpha beta"),
|
||||
page = ReaderPage(0, 0, "One", "alpha beta alpha beta", 0, 21),
|
||||
settings = ReaderSettings()
|
||||
)
|
||||
|
||||
assertTrue(html.contains("function rangeMatchesStoredOffsets(content, range, startOffset, endOffset)"))
|
||||
assertTrue(html.contains("rangeMatchesStoredOffsets(content, textRange, startOffset, endOffset)"))
|
||||
assertTrue(html.contains("highlight_expected_mismatch id="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader highlight script reconciles unsaved local highlight wrappers`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
page = ReaderPage(0, 0, "One", "alpha beta", 0, 10),
|
||||
settings = ReaderSettings()
|
||||
)
|
||||
|
||||
assertTrue(html.contains("var readerCurrentHighlights = [];"))
|
||||
assertTrue(html.contains("function scheduleReaderHighlightReconcile()"))
|
||||
assertTrue(html.contains("scheduleReaderHighlightReconcile();"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page document can render a two page spread`() {
|
||||
val left = ReaderPage(
|
||||
pageIndex = 2,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "left page",
|
||||
startOffset = 0,
|
||||
endOffset = 9
|
||||
)
|
||||
val right = ReaderPage(
|
||||
pageIndex = 3,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "right page",
|
||||
startOffset = 10,
|
||||
endOffset = 20
|
||||
)
|
||||
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("left page\n\nright page"),
|
||||
page = left,
|
||||
visiblePages = listOf(left, right),
|
||||
settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
|
||||
)
|
||||
|
||||
assertTrue(html.contains("reader-spread"))
|
||||
assertEquals(2, Regex("<section class=\"page\"").findAll(html).count())
|
||||
assertTrue(html.contains("data-reader-page-index=\"2\""))
|
||||
assertTrue(html.contains("data-reader-page-index=\"3\""))
|
||||
assertTrue(html.contains("readerPaginationLayoutLog"))
|
||||
assertTrue(html.contains("EpistemeEpubPagination"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -91,6 +239,20 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
assertTrue(html.contains("scrollToActiveLocator"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical document styles native scrollbar from reader theme variables`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
|
||||
)
|
||||
|
||||
assertTrue(html.contains("--reader-scrollbar-track: color-mix(in srgb, var(--reader-bg)"))
|
||||
assertTrue(html.contains("--reader-scrollbar-thumb: color-mix(in srgb, var(--reader-fg)"))
|
||||
assertTrue(html.contains("scrollbar-color: var(--reader-scrollbar-thumb) var(--reader-scrollbar-track)"))
|
||||
assertTrue(html.contains("body.reader-vertical::-webkit-scrollbar-thumb"))
|
||||
assertTrue(html.contains("body.reader-vertical::-webkit-scrollbar-thumb:hover"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection menu omits ai and tts actions when disabled`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
|
|
@ -110,8 +272,159 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
|
||||
assertFalse(html.contains("""data-action="define""""))
|
||||
assertFalse(html.contains("""data-action="speak""""))
|
||||
assertTrue(html.contains("""data-action="dictionary""""))
|
||||
assertTrue(html.contains("""data-action="web-search""""))
|
||||
assertTrue(html.contains("""aria-label="Search""""))
|
||||
assertTrue(html.contains("""<svg viewBox="0 0 960 960""""))
|
||||
assertFalse(html.contains("""data-action="dictionary""""))
|
||||
assertFalse(html.contains("""data-action="translate""""))
|
||||
assertFalse(html.contains("""data-action="find""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection menu omits all external lookup actions when offline`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "alpha beta",
|
||||
startOffset = 0,
|
||||
endOffset = 10
|
||||
),
|
||||
settings = ReaderSettings(),
|
||||
externalLookupEnabled = false
|
||||
)
|
||||
|
||||
assertFalse(html.contains("""data-action="dictionary""""))
|
||||
assertFalse(html.contains("""data-action="web-search""""))
|
||||
assertFalse(html.contains("""data-action="translate""""))
|
||||
assertFalse(html.contains("""data-action="find""""))
|
||||
assertTrue(html.contains("""data-action="copy""""))
|
||||
assertTrue(html.contains("""data-action="clear""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection menu opens from regular selection and right click`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "alpha beta",
|
||||
startOffset = 0,
|
||||
endOffset = 10
|
||||
),
|
||||
settings = ReaderSettings()
|
||||
)
|
||||
|
||||
assertTrue(html.contains("function scheduleMenuFromSelection()"))
|
||||
assertTrue(html.contains("selectionAnchorRect(selection)"))
|
||||
assertTrue(html.contains("if (selectionPointerDown || activeSelectionHandle) return;"))
|
||||
assertTrue(html.contains("rangeBoundaryRect(range.startContainer"))
|
||||
assertTrue(html.contains("document.addEventListener('selectionchange'"))
|
||||
assertTrue(html.contains("document.addEventListener('pointerdown'"))
|
||||
assertTrue(html.contains("document.addEventListener('mouseup'"))
|
||||
assertTrue(html.contains("document.addEventListener('contextmenu'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection menu renders icons and draggable handles`() {
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("alpha beta"),
|
||||
page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "alpha beta",
|
||||
startOffset = 0,
|
||||
endOffset = 10
|
||||
),
|
||||
settings = ReaderSettings()
|
||||
)
|
||||
|
||||
assertTrue(html.contains("""class="reader-selection-icon""""))
|
||||
assertTrue(html.contains("""id="reader-selection-start-handle""""))
|
||||
assertTrue(html.contains("""id="reader-selection-end-handle""""))
|
||||
assertTrue(html.contains("beginSelectionHandleDrag('start'"))
|
||||
assertTrue(html.contains("requestSelectionHandleUpdate(event)"))
|
||||
assertTrue(html.contains("document.addEventListener('selectstart'"))
|
||||
assertTrue(html.contains("EPUB_SELECTION_DEBUG"))
|
||||
assertTrue(html.contains("readerSelectionDebugLog('drag_line"))
|
||||
assertTrue(html.contains("rangeTouchesSelectionChrome"))
|
||||
assertTrue(html.contains("element.closest('#reader-selection-menu, .reader-selection-handle')"))
|
||||
assertFalse(html.contains("next.toString().trim().length"))
|
||||
assertTrue(html.contains("document.caretRangeFromPoint"))
|
||||
assertTrue(html.contains("wrapRangeTextSegments(range"))
|
||||
assertFalse(html.contains("surroundContents"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginated spread script targets the actual page host for locator highlights`() {
|
||||
val left = ReaderPage(
|
||||
pageIndex = 2,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "left page",
|
||||
startOffset = 0,
|
||||
endOffset = 9
|
||||
)
|
||||
val right = ReaderPage(
|
||||
pageIndex = 3,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "right page",
|
||||
startOffset = 10,
|
||||
endOffset = 20
|
||||
)
|
||||
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("left page\n\nright page"),
|
||||
page = left,
|
||||
visiblePages = listOf(left, right),
|
||||
settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
|
||||
)
|
||||
|
||||
assertTrue(html.contains("function readerHostsForLocator(chapterIndex, startOffset, endOffset)"))
|
||||
assertTrue(html.contains("function readerHostForLocator(chapterIndex, startOffset, endOffset)"))
|
||||
assertTrue(html.contains("var targetChapters = readerHostsForLocator(chapterIndex, startOffset, endOffset);"))
|
||||
assertTrue(html.contains("var chapter = readerHostForLocator(chapterIndex, startOffset, endOffset);"))
|
||||
assertTrue(html.contains("data-reader-active-page-index"))
|
||||
assertTrue(html.contains("positionFromReaderHost(activePage, activeStart)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginated spread script can create one highlight from a selection crossing visible pages`() {
|
||||
val left = ReaderPage(
|
||||
pageIndex = 2,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "left page",
|
||||
startOffset = 0,
|
||||
endOffset = 9
|
||||
)
|
||||
val right = ReaderPage(
|
||||
pageIndex = 3,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "right page",
|
||||
startOffset = 10,
|
||||
endOffset = 20
|
||||
)
|
||||
|
||||
val html = ReaderHtmlDocumentBuilder.pageDocument(
|
||||
book = repeatedWordBook("left page\n\nright page"),
|
||||
page = left,
|
||||
visiblePages = listOf(left, right),
|
||||
settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
|
||||
)
|
||||
|
||||
assertTrue(html.contains("function selectionSegmentsForRange(range)"))
|
||||
assertTrue(html.contains("var sameChapter = segments.every(function (segment)"))
|
||||
assertTrue(html.contains("var cfi = 'desktop:' + chapterIndex + ':' + startOffset + ':' + endOffset;"))
|
||||
assertTrue(html.contains("payloads.forEach(function (payload)"))
|
||||
assertTrue(html.contains("wrapRangeTextSegments(segment.range"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -208,6 +521,10 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
)
|
||||
|
||||
assertTrue(html.contains("""<a href="notes.xhtml#ref" data-reader-link="true">reference</a>"""))
|
||||
assertTrue(html.contains("--reader-link:"))
|
||||
assertTrue(html.contains("a[href],"))
|
||||
assertTrue(html.contains("color: var(--reader-link) !important"))
|
||||
assertTrue(html.contains("a[href] *"))
|
||||
assertTrue(html.contains("readerLinkClicked"))
|
||||
assertTrue(html.contains("bridge_missing"))
|
||||
assertTrue(html.contains("readerlink://click?payload="))
|
||||
|
|
@ -360,6 +677,34 @@ class ReaderHtmlDocumentBuilderTest {
|
|||
assertTrue(html.contains("""<a href="chap02.xhtml" data-reader-link="true">Chapter two</a>"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical document includes theme aware link styling`() {
|
||||
val html = ReaderHtmlDocumentBuilder.verticalDocument(
|
||||
book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Read more",
|
||||
htmlContent = """<p><a href="notes.xhtml"><span>Read more</span></a></p>"""
|
||||
)
|
||||
)
|
||||
),
|
||||
settings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
darkMode = true
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(html.contains("--reader-link:"))
|
||||
assertTrue(html.contains("--reader-link-bg: rgba("))
|
||||
assertTrue(html.contains("a[href] *,"))
|
||||
assertTrue(html.contains("""<a href="notes.xhtml"><span>Read more</span></a>"""))
|
||||
}
|
||||
|
||||
private fun repeatedWordBook(text: String): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "book",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderJumpHistoryTest {
|
||||
|
||||
@Test
|
||||
fun `records explicit locator jumps and exposes back and forward locators`() {
|
||||
val start = locator(chapter = 0, cfi = "start")
|
||||
val middle = locator(chapter = 1, cfi = "middle")
|
||||
val end = locator(chapter = 2, cfi = "end")
|
||||
|
||||
val recorded = ReaderJumpHistory()
|
||||
.record(currentLocator = start, targetLocator = middle, chapterCount = 4)
|
||||
.record(currentLocator = middle, targetLocator = end, chapterCount = 4)
|
||||
|
||||
val steppedBack = recorded.stepBack()
|
||||
val branched = steppedBack.record(
|
||||
currentLocator = middle,
|
||||
targetLocator = locator(chapter = 3, cfi = "appendix"),
|
||||
chapterCount = 4
|
||||
)
|
||||
|
||||
assertEquals(listOf(start, middle, end), recorded.locators)
|
||||
assertEquals(middle, recorded.backLocator)
|
||||
assertEquals(null, recorded.forwardLocator)
|
||||
assertEquals(start, steppedBack.backLocator)
|
||||
assertEquals(end, steppedBack.forwardLocator)
|
||||
assertEquals(listOf(start, middle, locator(chapter = 3, cfi = "appendix")), branched.locators)
|
||||
assertEquals(middle, branched.backLocator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores invalid and duplicate jumps prunes chapters and caps entries`() {
|
||||
val unchanged = ReaderJumpHistory()
|
||||
.record(currentLocator = locator(chapter = 0, cfi = "same"), targetLocator = locator(chapter = 0, cfi = "same"), chapterCount = 3)
|
||||
.record(currentLocator = locator(chapter = 0, cfi = "ok"), targetLocator = locator(chapter = 99, cfi = "bad"), chapterCount = 3)
|
||||
|
||||
val pruned = ReaderJumpHistory(
|
||||
locators = listOf(
|
||||
locator(chapter = 0, cfi = "start"),
|
||||
locator(chapter = 3, cfi = "drop"),
|
||||
locator(chapter = 1, cfi = "keep")
|
||||
),
|
||||
cursor = 2
|
||||
).pruned(chapterCount = 2)
|
||||
|
||||
val capped = (0 until 40).fold(ReaderJumpHistory(maxEntries = 5)) { history, index ->
|
||||
history.record(
|
||||
currentLocator = locator(chapter = 0, cfi = "spot-$index"),
|
||||
targetLocator = locator(chapter = 0, cfi = "spot-${index + 1}"),
|
||||
chapterCount = 1
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(unchanged.locators.isEmpty())
|
||||
assertTrue(
|
||||
locator(chapter = 0, cfi = "stable").copy(pageIndex = 12)
|
||||
.hasSameJumpLocation(locator(chapter = 0, cfi = "stable").copy(pageIndex = 48))
|
||||
)
|
||||
assertEquals(listOf(locator(chapter = 0, cfi = "start"), locator(chapter = 1, cfi = "keep")), pruned.locators)
|
||||
assertEquals(1, pruned.cursor)
|
||||
assertEquals((36..40).map { locator(chapter = 0, cfi = "spot-$it") }, capped.locators)
|
||||
assertEquals(4, capped.cursor)
|
||||
}
|
||||
|
||||
private fun locator(chapter: Int, cfi: String): ReaderLocator {
|
||||
return ReaderLocator(chapterIndex = chapter, cfi = cfi)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderSpreadLayoutTest {
|
||||
|
||||
@Test
|
||||
fun `single page mode keeps direct page indexes`() {
|
||||
val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.SINGLE)
|
||||
|
||||
assertEquals(3, ReaderSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals("4", ReaderSpreadLayout.pageRangeLabel(3, pageCount = 10, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page mode normalizes direct jumps to the spread start`() {
|
||||
val settings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
|
||||
assertEquals(2, ReaderSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(2, 3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals("3-4", ReaderSpreadLayout.pageRangeLabel(3, pageCount = 10, settings = settings))
|
||||
assertEquals(2, ReaderSpreadLayout.sliderPositionForPage(3, pageCount = 10, settings = settings))
|
||||
assertEquals(3, ReaderSpreadLayout.pageNumberForSliderPosition(2, pageCount = 10, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page mode advances by spread and clamps odd final page`() {
|
||||
val settings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
|
||||
assertEquals(3, ReaderSpreadLayout.sliderStepCount(pageCount = 5, settings = settings))
|
||||
assertEquals(2, ReaderSpreadLayout.nextPageIndex(0, pageCount = 5, settings = settings))
|
||||
assertEquals(4, ReaderSpreadLayout.nextPageIndex(2, pageCount = 5, settings = settings))
|
||||
assertEquals(listOf(4), ReaderSpreadLayout.visiblePageIndices(4, pageCount = 5, settings = settings))
|
||||
assertEquals(5, ReaderSpreadLayout.pageNumberForSliderPosition(3, pageCount = 5, settings = settings))
|
||||
assertFalse(ReaderSpreadLayout.canGoNext(4, pageCount = 5, settings = settings))
|
||||
assertTrue(ReaderSpreadLayout.canGoNext(2, pageCount = 5, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page progress uses the visible spread end`() {
|
||||
val settings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
val state = PaginatedReaderState(
|
||||
book = SharedEpubBook("book", "book.epub", "Book", chapters = emptyList()),
|
||||
pages = (0 until 4).map { index ->
|
||||
ReaderPage(index, chapterIndex = 0, chapterTitle = "One", text = "$index", startOffset = index, endOffset = index + 1)
|
||||
},
|
||||
currentPageIndex = 2,
|
||||
settings = settings
|
||||
)
|
||||
|
||||
assertEquals(100f, state.progress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spread mode is ignored in vertical reading`() {
|
||||
val settings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
|
||||
assertEquals(3, ReaderSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals(1, ReaderSpreadLayout.pageStep(settings))
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,9 @@ import com.aryan.reader.shared.BookItem
|
|||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.LibraryFilters
|
||||
import com.aryan.reader.shared.ReadStatusFilter
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.Shelf
|
||||
import com.aryan.reader.shared.ShelfType
|
||||
|
|
@ -16,6 +19,43 @@ import kotlin.test.assertTrue
|
|||
|
||||
class NonReaderLayoutModelsTest {
|
||||
|
||||
@Test
|
||||
fun `desktop library exposes the same top level organization tabs as Android`() {
|
||||
val visibleTabs = visibleNonReaderLibraryTabs()
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
NonReaderLibraryTab.BOOKS,
|
||||
NonReaderLibraryTab.SHELVES,
|
||||
NonReaderLibraryTab.FOLDERS
|
||||
),
|
||||
visibleTabs
|
||||
)
|
||||
assertFalse(NonReaderLibraryTab.SMART_SHELVES in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.TAGS in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.UNREAD in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.IN_PROGRESS in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.COMPLETED in visibleTabs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop library filter file type groups include every shared readable format`() {
|
||||
val groupedTypes = nonReaderLibraryFileTypeGroups().flatMap { it.fileTypes }
|
||||
|
||||
assertEquals(
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP),
|
||||
groupedTypes.toSet()
|
||||
)
|
||||
assertEquals(groupedTypes.size, groupedTypes.toSet().size)
|
||||
assertTrue(FileType.DOCX in groupedTypes)
|
||||
assertTrue(FileType.FODT in groupedTypes)
|
||||
assertFalse(FileType.PPTX in groupedTypes)
|
||||
assertTrue(
|
||||
nonReaderLibraryFileTypeGroups()
|
||||
.any { it.title == "Comics" && FileType.CBR in it.fileTypes && FileType.CB7 in it.fileTypes }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home layout separates active tab pinned and recent books`() {
|
||||
val activeTab = book("tab", title = "Open Tab", progress = 12f)
|
||||
|
|
@ -116,6 +156,56 @@ class NonReaderLayoutModelsTest {
|
|||
assertEquals(1, organization.folderCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library visible selection follows folder shelf navigation`() {
|
||||
val rootBook = book("root", sourceFolder = "/sync")
|
||||
val childBook = book("child", sourceFolder = "/sync")
|
||||
val rootShelf = Shelf(
|
||||
id = "folder_/sync",
|
||||
name = "Sync",
|
||||
type = ShelfType.FOLDER,
|
||||
books = listOf(rootBook, childBook),
|
||||
directBooks = listOf(rootBook),
|
||||
childShelfIds = listOf("folder_/sync::Nested")
|
||||
)
|
||||
val childShelf = Shelf(
|
||||
id = "folder_/sync::Nested",
|
||||
name = "Nested",
|
||||
type = ShelfType.FOLDER,
|
||||
books = listOf(childBook),
|
||||
directBooks = listOf(childBook),
|
||||
parentShelfId = rootShelf.id,
|
||||
depth = 1
|
||||
)
|
||||
|
||||
val rootState = SharedReaderScreenState(
|
||||
shelves = listOf(rootShelf, childShelf),
|
||||
libraryBooks = listOf(rootBook, childBook)
|
||||
)
|
||||
val childState = rootState.copy(viewingShelfId = childShelf.id)
|
||||
|
||||
assertEquals(
|
||||
listOf("root", "child"),
|
||||
rootState.visibleBooksForLibrarySelection(NonReaderLibraryTab.FOLDERS).map { it.id }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("child"),
|
||||
childState.visibleBooksForLibrarySelection(NonReaderLibraryTab.FOLDERS).map { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library organization does not expose unknown as an available file type`() {
|
||||
val organization = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(
|
||||
book("known", type = FileType.PDF),
|
||||
book("unknown", type = FileType.UNKNOWN)
|
||||
)
|
||||
).toNonReaderLibraryOrganizationModel()
|
||||
|
||||
assertEquals(listOf(FileType.PDF), organization.availableFileTypes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell model keeps primary navigation simple and exposes all tool actions`() {
|
||||
val model = sharedAppShellModel(
|
||||
|
|
@ -124,7 +214,7 @@ class NonReaderLayoutModelsTest {
|
|||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(SharedAppTab.HOME, SharedAppTab.LIBRARY, SharedAppTab.CATALOGS, SharedAppTab.READER),
|
||||
listOf(SharedAppTab.HOME, SharedAppTab.LIBRARY, SharedAppTab.CATALOGS),
|
||||
model.primaryTabs
|
||||
)
|
||||
assertEquals(SharedAppTab.HOME, model.selectedPrimaryTab)
|
||||
|
|
@ -138,12 +228,68 @@ class NonReaderLayoutModelsTest {
|
|||
assertTrue(SharedAppToolAction.SUPPORT in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.ABOUT in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.TABS_TOGGLE in model.toolActions)
|
||||
assertTrue(model.showPrimaryNavigation)
|
||||
|
||||
val withoutAi = sharedAppShellModel(SharedAppTab.SHELVES, aiSettingsAvailable = false)
|
||||
assertEquals(SharedAppTab.LIBRARY, withoutAi.selectedPrimaryTab)
|
||||
assertFalse(SharedAppToolAction.AI_SETTINGS in withoutAi.toolActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell model hides primary navigation while reading`() {
|
||||
val readerModel = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.READER,
|
||||
aiSettingsAvailable = true
|
||||
)
|
||||
val libraryModel = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.LIBRARY,
|
||||
aiSettingsAvailable = true
|
||||
)
|
||||
|
||||
assertFalse(readerModel.showPrimaryNavigation)
|
||||
assertTrue(libraryModel.showPrimaryNavigation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline shell model hides network backed navigation and tools`() {
|
||||
val model = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.CATALOGS,
|
||||
aiSettingsAvailable = true,
|
||||
featurePolicy = SharedFeaturePolicy.OssOffline
|
||||
)
|
||||
|
||||
assertEquals(listOf(SharedAppTab.HOME, SharedAppTab.LIBRARY), model.primaryTabs)
|
||||
assertEquals(SharedAppTab.HOME, model.selectedPrimaryTab)
|
||||
assertFalse(SharedAppToolAction.AI_SETTINGS in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.HELP_FEEDBACK in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.SUPPORT in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.ABOUT in model.toolActions)
|
||||
assertTrue(model.showPrimaryNavigation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collection cover stack uses Android cover order and limit`() {
|
||||
val books = listOf(
|
||||
book("one", coverImagePath = "/covers/one.png"),
|
||||
book("two", coverImagePath = "/covers/two.png"),
|
||||
book("three", coverImagePath = "/covers/three.png"),
|
||||
book("four", coverImagePath = "/covers/four.png"),
|
||||
book("five", coverImagePath = "/covers/five.png")
|
||||
)
|
||||
|
||||
val coverBooks = collectionCoverStackBooks(
|
||||
Shelf("manual", "Manual", ShelfType.MANUAL, books)
|
||||
)
|
||||
|
||||
assertEquals(listOf("four", "three", "two", "one"), coverBooks.map { it.id })
|
||||
assertEquals(
|
||||
listOf("/covers/four.png", "/covers/three.png", "/covers/two.png", "/covers/one.png"),
|
||||
coverBooks.map { it.coverImagePath }
|
||||
)
|
||||
assertTrue(collectionCoverStackBooks(Shelf("empty", "Empty", ShelfType.FOLDER, emptyList())).isEmpty())
|
||||
}
|
||||
|
||||
private fun book(
|
||||
id: String,
|
||||
title: String = id,
|
||||
|
|
@ -151,13 +297,15 @@ class NonReaderLayoutModelsTest {
|
|||
progress: Float? = null,
|
||||
tags: List<Tag> = emptyList(),
|
||||
sourceFolder: String? = null,
|
||||
path: String? = "/books/$id.epub"
|
||||
path: String? = "/books/$id.epub",
|
||||
coverImagePath: String? = null
|
||||
) = BookItem(
|
||||
id = id,
|
||||
path = path,
|
||||
type = type,
|
||||
displayName = "$id.epub",
|
||||
timestamp = 1L,
|
||||
coverImagePath = coverImagePath,
|
||||
title = title,
|
||||
progressPercentage = progress,
|
||||
tags = tags,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import com.aryan.reader.shared.ReaderTool
|
|||
import com.aryan.reader.shared.ReaderToolbarPreferences
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderState
|
||||
import com.aryan.reader.shared.reader.ReaderEngine
|
||||
import com.aryan.reader.shared.reader.SampleReaderBooks
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
|
@ -18,10 +19,10 @@ import kotlin.test.assertTrue
|
|||
class ReaderWorkspaceModelsTest {
|
||||
|
||||
@Test
|
||||
fun `epub workspace maps shared toolbar preferences to reader sidebars and inspector`() {
|
||||
val session = ReaderEngine().createSession(SampleReaderBooks.desktopWelcomeBook())
|
||||
fun `epub workspace maps shared toolbar preferences without toolbar tab`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf(ReaderTool.THEME.id, ReaderTool.FORMAT.id),
|
||||
hiddenToolIds = setOf(ReaderTool.THEME.id, ReaderTool.FORMAT.id, ReaderTool.BOOKMARK.id),
|
||||
bottomToolIds = setOf(ReaderTool.SLIDER.id, ReaderTool.SEARCH.id)
|
||||
)
|
||||
|
||||
|
|
@ -33,21 +34,32 @@ class ReaderWorkspaceModelsTest {
|
|||
)
|
||||
|
||||
assertEquals(ReaderWorkspaceKind.EPUB, model.kind)
|
||||
assertTrue(ReaderWorkspaceLeftSection.CONTENTS in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceLeftSection.SEARCH in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceLeftSection.BOOKMARKS in model.leftSections)
|
||||
assertEquals(
|
||||
listOf(
|
||||
ReaderWorkspaceLeftSection.CONTENTS,
|
||||
ReaderWorkspaceLeftSection.NOTES,
|
||||
ReaderWorkspaceLeftSection.BOOKMARKS
|
||||
),
|
||||
model.leftSections
|
||||
)
|
||||
assertFalse(ReaderWorkspaceLeftSection.SEARCH in model.leftSections)
|
||||
assertFalse(ReaderWorkspaceTopAction.BOOKMARK in model.topActions)
|
||||
assertFalse(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections)
|
||||
assertFalse(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.SEARCH in model.topActions)
|
||||
assertTrue(ReaderWorkspaceTopAction.FULL_SCREEN in model.topActions)
|
||||
assertTrue(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
assertTrue(ReaderWorkspaceBottomAction.PAGE_SLIDER in model.bottomActions)
|
||||
assertFalse(model.panelDefaults.leftOpen)
|
||||
assertFalse(model.panelDefaults.inspectorOpen)
|
||||
assertFalse(model.chrome.preferAutoHide)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chrome model is forced visible for active reader states`() {
|
||||
val model = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
preferAutoHide = false,
|
||||
searchActive = true,
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = true,
|
||||
|
|
@ -59,7 +71,7 @@ class ReaderWorkspaceModelsTest {
|
|||
ttsBusy = true
|
||||
)
|
||||
|
||||
assertTrue(model.preferAutoHide)
|
||||
assertFalse(model.preferAutoHide)
|
||||
assertTrue(model.forceVisible)
|
||||
assertEquals(
|
||||
setOf("search", "inspector", "annotation", "rich-text", "loading", "error", "auto-scroll", "tts"),
|
||||
|
|
@ -67,6 +79,48 @@ class ReaderWorkspaceModelsTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub workspace ignores desktop visual options in inspector`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = ReaderTool.entries
|
||||
.filterNot { it == ReaderTool.VISUAL_OPTIONS }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
)
|
||||
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = true
|
||||
)
|
||||
|
||||
assertFalse(ReaderWorkspaceInspectorSection.TOOLS in model.inspectorSections)
|
||||
assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub workspace ignores external lookup in inspector`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = ReaderTool.entries
|
||||
.filterNot { it == ReaderTool.DICTIONARY }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
)
|
||||
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = true
|
||||
)
|
||||
|
||||
assertFalse(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
|
||||
assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toolbar quick actions preserve visibility order and bottom placement`() {
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
|
|
@ -104,6 +158,34 @@ class ReaderWorkspaceModelsTest {
|
|||
assertFalse(ReaderTool.BOOKMARK in bottomToolsWithAi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toolbar quick actions hide online tools when unavailable`() {
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
toolOrder = listOf(
|
||||
ReaderTool.DICTIONARY,
|
||||
ReaderTool.SEARCH,
|
||||
ReaderTool.AI_FEATURES,
|
||||
ReaderTool.TTS_CONTROLS
|
||||
) + ReaderTool.entries,
|
||||
bottomToolIds = setOf(
|
||||
ReaderTool.DICTIONARY.id,
|
||||
ReaderTool.SEARCH.id,
|
||||
ReaderTool.AI_FEATURES.id,
|
||||
ReaderTool.TTS_CONTROLS.id
|
||||
)
|
||||
)
|
||||
|
||||
val tools = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = true,
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = false
|
||||
)
|
||||
|
||||
assertEquals(listOf(ReaderTool.SEARCH), tools)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf workspace defaults to reading first while keeping annotation tools in inspector`() {
|
||||
val model = pdfReaderWorkspaceModel(
|
||||
|
|
@ -124,14 +206,26 @@ class ReaderWorkspaceModelsTest {
|
|||
|
||||
assertEquals(ReaderWorkspaceKind.PDF, model.kind)
|
||||
assertNull(model.defaultPdfInteractionMode)
|
||||
assertTrue(ReaderWorkspaceLeftSection.CONTENTS in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceLeftSection.SEARCH in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceLeftSection.BOOKMARKS in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceLeftSection.NOTES in model.leftSections)
|
||||
assertEquals(
|
||||
listOf(
|
||||
ReaderWorkspaceLeftSection.CONTENTS,
|
||||
ReaderWorkspaceLeftSection.NOTES,
|
||||
ReaderWorkspaceLeftSection.BOOKMARKS,
|
||||
ReaderWorkspaceLeftSection.PAGES
|
||||
),
|
||||
model.leftSections
|
||||
)
|
||||
assertFalse(ReaderWorkspaceLeftSection.SEARCH in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.TOOLS in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.BOOKMARK in model.topActions)
|
||||
assertTrue(ReaderWorkspaceTopAction.FULL_SCREEN in model.topActions)
|
||||
assertTrue(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
assertFalse(model.panelDefaults.leftOpen)
|
||||
assertFalse(model.panelDefaults.inspectorOpen)
|
||||
assertFalse(model.chrome.preferAutoHide)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -156,6 +250,7 @@ class ReaderWorkspaceModelsTest {
|
|||
)
|
||||
|
||||
assertTrue(model.chrome.forceVisible)
|
||||
assertFalse(model.chrome.preferAutoHide)
|
||||
assertTrue("search" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("annotation" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("error" in model.chrome.forceVisibleReasons)
|
||||
|
|
@ -163,4 +258,19 @@ class ReaderWorkspaceModelsTest {
|
|||
assertTrue("tts" in model.chrome.forceVisibleReasons)
|
||||
assertFalse(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
}
|
||||
|
||||
private fun readerFixtureBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "reader_fixture",
|
||||
fileName = "Reader Fixture.epub",
|
||||
title = "Reader Fixture",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "intro",
|
||||
title = "Intro",
|
||||
plainText = "A short reader fixture for workspace model tests."
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,26 @@ class SharedAppThemeColorMathTest {
|
|||
assertTrue(abs(original.blue - roundTripped.blue) < 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hsv wheel maps center to no saturation and right edge to red`() {
|
||||
val center = sharedHsvWheelSelection(
|
||||
offsetX = 50f,
|
||||
offsetY = 50f,
|
||||
width = 100f,
|
||||
height = 100f
|
||||
)
|
||||
val rightEdge = sharedHsvWheelSelection(
|
||||
offsetX = 100f,
|
||||
offsetY = 50f,
|
||||
width = 100f,
|
||||
height = 100f
|
||||
)
|
||||
|
||||
assertClose(0f, center.saturation)
|
||||
assertClose(0f, rightEdge.hue)
|
||||
assertClose(1f, rightEdge.saturation)
|
||||
}
|
||||
|
||||
private fun assertClose(expected: Float, actual: Float) {
|
||||
assertTrue(abs(expected - actual) < 0.01f, "Expected $expected but was $actual")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import com.aryan.reader.shared.HighlightColor
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class SharedNativePaginatedReaderInteractionTest {
|
||||
@Test
|
||||
fun `word selection trims punctuation around long press range`() {
|
||||
val range = sharedNativeReaderTrimmedWordRange(
|
||||
text = "\"Reader,\" she said.",
|
||||
start = 0,
|
||||
end = 9
|
||||
)
|
||||
|
||||
assertNotNull(range)
|
||||
assertEquals(1, range.start)
|
||||
assertEquals(7, range.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `word selection ignores punctuation only range`() {
|
||||
val range = sharedNativeReaderTrimmedWordRange(
|
||||
text = "...",
|
||||
start = 0,
|
||||
end = 3
|
||||
)
|
||||
|
||||
assertNull(range)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `highlight for native selection keeps desktop locator offsets`() {
|
||||
val selection = SharedNativeReaderTextSelection(
|
||||
chapterIndex = 2,
|
||||
pageIndex = 7,
|
||||
startOffset = 120,
|
||||
endOffset = 136,
|
||||
text = "selected passage"
|
||||
)
|
||||
|
||||
val highlight = sharedNativeReaderHighlightForSelection(selection, HighlightColor.YELLOW)
|
||||
|
||||
assertEquals("desktop:2:120:136", highlight.cfi)
|
||||
assertEquals(2, highlight.chapterIndex)
|
||||
assertEquals(7, highlight.locator.pageIndex)
|
||||
assertEquals(120, highlight.locator.startOffset)
|
||||
assertEquals(136, highlight.locator.endOffset)
|
||||
assertEquals("selected passage", highlight.locator.textQuote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `highlight for block selection keeps android style cfi and locator offsets`() {
|
||||
val selection = SharedNativeReaderTextSelection(
|
||||
chapterIndex = 1,
|
||||
pageIndex = 4,
|
||||
startOffset = 105,
|
||||
endOffset = 220,
|
||||
text = "selected across blocks",
|
||||
startPageIndex = 4,
|
||||
endPageIndex = 4,
|
||||
startBlockIndex = 8,
|
||||
endBlockIndex = 10,
|
||||
startBlockCharOffset = 100,
|
||||
endBlockCharOffset = 200,
|
||||
startLocalOffset = 5,
|
||||
endLocalOffset = 20,
|
||||
startBaseCfi = "/4/2/8",
|
||||
endBaseCfi = "/4/2/10"
|
||||
)
|
||||
|
||||
val highlight = sharedNativeReaderHighlightForSelection(selection, HighlightColor.GREEN)
|
||||
|
||||
assertEquals("/4/2/8:5|/4/2/10:20", highlight.cfi)
|
||||
assertEquals(1, highlight.chapterIndex)
|
||||
assertEquals(4, highlight.locator.pageIndex)
|
||||
assertEquals(105, highlight.locator.startOffset)
|
||||
assertEquals(220, highlight.locator.endOffset)
|
||||
assertEquals("selected across blocks", highlight.locator.textQuote)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedReaderModalSizingTest {
|
||||
|
||||
@Test
|
||||
fun `reader popup width is capped on wide surfaces`() {
|
||||
assertEquals(SharedReaderPopupDefaultMaxWidth, sharedReaderPopupWidth(1200.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader popup width stays usable on narrow surfaces`() {
|
||||
assertEquals(320.dp, sharedReaderPopupWidth(500.dp))
|
||||
assertEquals(280.dp, sharedReaderPopupWidth(280.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
private val SharedReaderDiagnosticTags: Set<String> =
|
||||
System.getProperty(SharedReaderDiagnosticsTagsProperty)
|
||||
.orEmpty()
|
||||
.split(',', ';', ' ', '\t', '\n')
|
||||
.mapNotNull { rawTag ->
|
||||
rawTag.trim()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.lowercase()
|
||||
}
|
||||
.toSet()
|
||||
|
||||
internal actual val SharedReaderDiagnosticsEnabled: Boolean =
|
||||
System.getProperty(SharedReaderDiagnosticsProperty)
|
||||
?.trim()
|
||||
?.equals("true", ignoreCase = true) == true ||
|
||||
SharedReaderDiagnosticTags.isNotEmpty()
|
||||
|
||||
internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean {
|
||||
if (SharedReaderDiagnosticTags.isEmpty()) return true
|
||||
return "*" in SharedReaderDiagnosticTags || tag.lowercase() in SharedReaderDiagnosticTags
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object DesktopBookCoverImageCache {
|
||||
private const val MaxEntries = 96
|
||||
private const val MaxCoverDimensionPx = 512
|
||||
|
||||
private data class Entry(
|
||||
val length: Long,
|
||||
val lastModified: Long,
|
||||
val bitmap: ImageBitmap
|
||||
)
|
||||
|
||||
private val entries = LinkedHashMap<String, Entry>(MaxEntries, 0.75f, true)
|
||||
|
||||
fun peek(path: String): ImageBitmap? {
|
||||
val file = File(path)
|
||||
if (!file.isFile) return null
|
||||
val key = file.absolutePath
|
||||
val length = file.length()
|
||||
val lastModified = file.lastModified()
|
||||
return synchronized(entries) {
|
||||
val entry = entries[key]
|
||||
if (entry != null && entry.length == length && entry.lastModified == lastModified) {
|
||||
entry.bitmap
|
||||
} else {
|
||||
entries.remove(key)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(path: String): ImageBitmap? {
|
||||
peek(path)?.let { return it }
|
||||
val file = File(path)
|
||||
if (!file.isFile) return null
|
||||
val bitmap = decodeCover(file) ?: return null
|
||||
val entry = Entry(
|
||||
length = file.length(),
|
||||
lastModified = file.lastModified(),
|
||||
bitmap = bitmap
|
||||
)
|
||||
synchronized(entries) {
|
||||
entries[file.absolutePath] = entry
|
||||
trimToMaxEntries()
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun clearForTests() {
|
||||
synchronized(entries) {
|
||||
entries.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun trimToMaxEntries() {
|
||||
while (entries.size > MaxEntries) {
|
||||
val eldestKey = entries.keys.firstOrNull() ?: return
|
||||
entries.remove(eldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeCover(file: File): ImageBitmap? {
|
||||
runCatching { ImageIO.read(file) }.getOrNull()
|
||||
?.scaledToFit(MaxCoverDimensionPx)
|
||||
?.toComposeImageBitmap()
|
||||
?.let { return it }
|
||||
|
||||
return runCatching {
|
||||
SkiaImage.makeFromEncoded(file.readBytes()).toComposeImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun BufferedImage.scaledToFit(maxDimension: Int): BufferedImage {
|
||||
val largestDimension = maxOf(width, height)
|
||||
if (largestDimension <= maxDimension) return this
|
||||
val scale = maxDimension.toDouble() / largestDimension.toDouble()
|
||||
val targetWidth = (width * scale).roundToInt().coerceAtLeast(1)
|
||||
val targetHeight = (height * scale).roundToInt().coerceAtLeast(1)
|
||||
val target = BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB)
|
||||
val graphics = target.createGraphics()
|
||||
try {
|
||||
graphics.setRenderingHint(
|
||||
RenderingHints.KEY_INTERPOLATION,
|
||||
RenderingHints.VALUE_INTERPOLATION_BILINEAR
|
||||
)
|
||||
graphics.setRenderingHint(
|
||||
RenderingHints.KEY_RENDERING,
|
||||
RenderingHints.VALUE_RENDER_QUALITY
|
||||
)
|
||||
graphics.drawImage(this, 0, 0, targetWidth, targetHeight, null)
|
||||
} finally {
|
||||
graphics.dispose()
|
||||
}
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.aryan.reader.paginatedreader.SemanticImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
@Composable
|
||||
fun DesktopEpubNativeImage(
|
||||
image: SemanticImage,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var bitmap by remember(image.path) {
|
||||
mutableStateOf(DesktopEpubNativeImageCache.peek(image.path))
|
||||
}
|
||||
|
||||
LaunchedEffect(image.path) {
|
||||
if (bitmap == null) {
|
||||
bitmap = withContext(Dispatchers.IO) {
|
||||
DesktopEpubNativeImageCache.load(image.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val currentBitmap = bitmap
|
||||
if (currentBitmap != null) {
|
||||
Image(
|
||||
bitmap = currentBitmap,
|
||||
contentDescription = image.altText ?: "Image from EPUB",
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Fit,
|
||||
colorFilter = image.readerImageColorFilter()
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = image.altText?.takeIf { it.isNotBlank() } ?: image.path.substringAfterLast('/').substringAfterLast('\\'),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object DesktopEpubNativeImageCache {
|
||||
private const val MaxEntries = 160
|
||||
|
||||
private data class Entry(
|
||||
val length: Long?,
|
||||
val lastModified: Long?,
|
||||
val bitmap: ImageBitmap
|
||||
)
|
||||
|
||||
private val entries = LinkedHashMap<String, Entry>(MaxEntries, 0.75f, true)
|
||||
|
||||
fun peek(path: String): ImageBitmap? {
|
||||
val source = DesktopEpubImageSource.from(path) ?: return null
|
||||
return synchronized(entries) {
|
||||
val entry = entries[source.key]
|
||||
if (entry != null && entry.length == source.length && entry.lastModified == source.lastModified) {
|
||||
entry.bitmap
|
||||
} else {
|
||||
entries.remove(source.key)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(path: String): ImageBitmap? {
|
||||
peek(path)?.let { return it }
|
||||
val source = DesktopEpubImageSource.from(path) ?: return null
|
||||
val bitmap = decode(source) ?: return null
|
||||
synchronized(entries) {
|
||||
entries[source.key] = Entry(
|
||||
length = source.length,
|
||||
lastModified = source.lastModified,
|
||||
bitmap = bitmap
|
||||
)
|
||||
trimToMaxEntries()
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun trimToMaxEntries() {
|
||||
while (entries.size > MaxEntries) {
|
||||
val eldestKey = entries.keys.firstOrNull() ?: return
|
||||
entries.remove(eldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(source: DesktopEpubImageSource): ImageBitmap? {
|
||||
val bytes = source.bytes() ?: return null
|
||||
runCatching {
|
||||
ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap()
|
||||
}.getOrNull()?.let { return it }
|
||||
|
||||
return runCatching {
|
||||
SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DesktopEpubImageSource(
|
||||
val key: String,
|
||||
val length: Long?,
|
||||
val lastModified: Long?
|
||||
) {
|
||||
abstract fun bytes(): ByteArray?
|
||||
|
||||
data class FileSource(private val file: File) : DesktopEpubImageSource(
|
||||
key = file.absolutePath,
|
||||
length = file.length(),
|
||||
lastModified = file.lastModified()
|
||||
) {
|
||||
override fun bytes(): ByteArray? = runCatching { file.readBytes() }.getOrNull()
|
||||
}
|
||||
|
||||
data class DataUriSource(private val path: String) : DesktopEpubImageSource(
|
||||
key = path,
|
||||
length = path.length.toLong(),
|
||||
lastModified = null
|
||||
) {
|
||||
override fun bytes(): ByteArray? {
|
||||
val marker = "base64,"
|
||||
val markerIndex = path.indexOf(marker, ignoreCase = true)
|
||||
if (markerIndex < 0) return null
|
||||
val base64 = path.substring(markerIndex + marker.length)
|
||||
if (base64.isBlank()) return null
|
||||
return runCatching { Base64.getDecoder().decode(base64) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(path: String): DesktopEpubImageSource? {
|
||||
if (path.startsWith("data:image/", ignoreCase = true)) {
|
||||
return DataUriSource(path)
|
||||
}
|
||||
val file = File(path)
|
||||
return if (file.isFile) FileSource(file) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SemanticImage.readerImageColorFilter(): ColorFilter? {
|
||||
if (style.blockStyle.filter != "invert(100%)") return null
|
||||
return ColorFilter.colorMatrix(
|
||||
ColorMatrix(
|
||||
floatArrayOf(
|
||||
-1f, 0f, 0f, 0f, 255f,
|
||||
0f, -1f, 0f, 0f, 255f,
|
||||
0f, 0f, -1f, 0f, 255f,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
internal actual fun LocalBookCoverImage(
|
||||
|
|
@ -15,19 +18,21 @@ internal actual fun LocalBookCoverImage(
|
|||
contentDescription: String?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
val bitmap = remember(path) {
|
||||
runCatching {
|
||||
val file = File(path)
|
||||
if (!file.isFile) {
|
||||
null
|
||||
} else {
|
||||
SkiaImage.makeFromEncoded(file.readBytes()).toComposeImageBitmap()
|
||||
}
|
||||
}.getOrNull()
|
||||
var bitmap by remember(path) {
|
||||
mutableStateOf(DesktopBookCoverImageCache.peek(path))
|
||||
}
|
||||
|
||||
LaunchedEffect(path) {
|
||||
if (bitmap == null) {
|
||||
bitmap = withContext(Dispatchers.IO) {
|
||||
DesktopBookCoverImageCache.load(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
bitmap = bitmap!!,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Crop
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.Window as ComposeWindow
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import kotlinx.coroutines.delay
|
||||
import java.awt.EventQueue
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.awt.Window as AwtWindow
|
||||
|
||||
@Composable
|
||||
internal actual fun SharedReaderModalLayer(
|
||||
onDismiss: () -> Unit,
|
||||
level: SharedReaderModalLevel,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val anchor = LocalSharedReaderModalAnchorBounds.current
|
||||
val density = LocalDensity.current
|
||||
val ownerWindow = remember { currentNonModalOwnerWindow() }
|
||||
val dialogSize = with(density) {
|
||||
anchor?.let {
|
||||
DpSize(
|
||||
width = it.widthPx.toDp().coerceAtLeast(360.dp),
|
||||
height = it.heightPx.toDp().coerceAtLeast(360.dp)
|
||||
)
|
||||
} ?: DpSize(720.dp, 620.dp)
|
||||
}
|
||||
val dialogPosition = with(density) {
|
||||
val ownerLocation = ownerWindow?.let { window ->
|
||||
runCatching { window.locationOnScreen }.getOrNull()
|
||||
}
|
||||
if (anchor != null && ownerLocation != null) {
|
||||
WindowPosition(
|
||||
(ownerLocation.x + anchor.leftPx).toDp(),
|
||||
(ownerLocation.y + anchor.topPx).toDp()
|
||||
)
|
||||
} else {
|
||||
WindowPosition(Alignment.Center)
|
||||
}
|
||||
}
|
||||
val state = rememberWindowState(position = dialogPosition, size = dialogSize)
|
||||
val windowTitle = when (level) {
|
||||
SharedReaderModalLevel.Panel -> "Reader Panel"
|
||||
SharedReaderModalLevel.Popup -> "Reader Popup"
|
||||
}
|
||||
|
||||
LaunchedEffect(dialogPosition, dialogSize) {
|
||||
state.position = dialogPosition
|
||||
state.size = dialogSize
|
||||
}
|
||||
DisposableEffect(ownerWindow) {
|
||||
onDispose {
|
||||
ownerWindow?.restoreFocusAfterSharedReaderModal()
|
||||
}
|
||||
}
|
||||
|
||||
ComposeWindow(
|
||||
onCloseRequest = onDismiss,
|
||||
state = state,
|
||||
title = windowTitle,
|
||||
undecorated = true,
|
||||
transparent = true,
|
||||
resizable = false,
|
||||
alwaysOnTop = true,
|
||||
focusable = true
|
||||
) {
|
||||
val modalWindow = window
|
||||
LaunchedEffect(modalWindow, level) {
|
||||
modalWindow.name = SharedReaderModalWindowNamePrefix + level.name
|
||||
modalWindow.isAlwaysOnTop = true
|
||||
val frontAttempts = if (level == SharedReaderModalLevel.Popup) 4 else 3
|
||||
repeat(frontAttempts) { attempt ->
|
||||
delay(if (attempt == 0) 30L else 80L)
|
||||
modalWindow.isAlwaysOnTop = true
|
||||
modalWindow.toFront()
|
||||
modalWindow.requestFocus()
|
||||
modalWindow.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private const val SharedReaderModalWindowNamePrefix = "shared-reader-modal:"
|
||||
|
||||
private fun AwtWindow.restoreFocusAfterSharedReaderModal() {
|
||||
EventQueue.invokeLater {
|
||||
if (!isDisplayable || !isShowing) return@invokeLater
|
||||
if (this is java.awt.Frame && extendedState and java.awt.Frame.ICONIFIED != 0) {
|
||||
extendedState = extendedState and java.awt.Frame.ICONIFIED.inv()
|
||||
}
|
||||
toFront()
|
||||
requestFocus()
|
||||
requestFocusInWindow()
|
||||
focusOwner?.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentNonModalOwnerWindow(): AwtWindow? {
|
||||
val activeWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow
|
||||
if (activeWindow != null && !activeWindow.isSharedReaderModalWindow()) {
|
||||
return activeWindow
|
||||
}
|
||||
return AwtWindow.getWindows()
|
||||
.filter { window -> window.isShowing && window.isDisplayable && !window.isSharedReaderModalWindow() }
|
||||
.maxByOrNull { window ->
|
||||
when {
|
||||
window.isFocused -> 3
|
||||
window.isActive -> 2
|
||||
window.isVisible -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AwtWindow.isSharedReaderModalWindow(): Boolean {
|
||||
val windowTitle = when (this) {
|
||||
is java.awt.Dialog -> title
|
||||
is java.awt.Frame -> title
|
||||
else -> ""
|
||||
}
|
||||
return name?.startsWith(SharedReaderModalWindowNamePrefix) == true ||
|
||||
windowTitle.startsWith("Reader Panel") ||
|
||||
windowTitle.startsWith("Reader Popup")
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import java.nio.file.Files
|
||||
import kotlin.io.path.toFile
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedEpubMetadataEditorTest {
|
||||
@Test
|
||||
fun `rewrite updates existing OPF metadata`() = withTempDir { dir ->
|
||||
val source = File(dir, "source.epub")
|
||||
val output = File(dir, "output.epub")
|
||||
writeEpub(source, metadata = """
|
||||
<metadata>
|
||||
<dc:title>Old</dc:title>
|
||||
<dc:creator>Old Author</dc:creator>
|
||||
<dc:description>Old summary</dc:description>
|
||||
<meta name="calibre:series" content="Old Series" />
|
||||
<meta name="calibre:series_index" content="1" />
|
||||
</metadata>
|
||||
""".trimIndent())
|
||||
|
||||
val result = SharedEpubMetadataEditor.rewrite(
|
||||
source = source,
|
||||
destination = output,
|
||||
update = update()
|
||||
)
|
||||
|
||||
assertEquals("New Title", result.title)
|
||||
assertEquals("New Author", result.author)
|
||||
assertEquals("New summary", result.description)
|
||||
assertEquals("New Series", result.seriesName)
|
||||
assertEquals(2.5, result.seriesIndex)
|
||||
assertEquals(result, SharedEpubMetadataEditor.readMetadata(output))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rewrite creates missing metadata elements`() = withTempDir { dir ->
|
||||
val source = File(dir, "source.epub")
|
||||
val output = File(dir, "output.epub")
|
||||
writeEpub(source, metadata = "<metadata />")
|
||||
|
||||
val result = SharedEpubMetadataEditor.rewrite(source, output, update())
|
||||
|
||||
assertEquals("New Title", result.title)
|
||||
assertEquals("New Author", result.author)
|
||||
assertEquals("New summary", result.description)
|
||||
assertEquals("New Series", result.seriesName)
|
||||
assertEquals(2.5, result.seriesIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rewrite preserves non OPF zip entries`() = withTempDir { dir ->
|
||||
val source = File(dir, "source.epub")
|
||||
val output = File(dir, "output.epub")
|
||||
writeEpub(source)
|
||||
|
||||
SharedEpubMetadataEditor.rewrite(source, output, update())
|
||||
|
||||
ZipFile(output).use { zip ->
|
||||
assertEquals("chapter", zip.getInputStream(assertNotNull(zip.getEntry("OEBPS/chapter.xhtml"))).reader().readText())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rewrite keeps mimetype first and stored`() = withTempDir { dir ->
|
||||
val source = File(dir, "source.epub")
|
||||
val output = File(dir, "output.epub")
|
||||
writeEpub(source)
|
||||
|
||||
SharedEpubMetadataEditor.rewrite(source, output, update())
|
||||
|
||||
ZipInputStream(output.inputStream()).use { zip ->
|
||||
val first = assertNotNull(zip.nextEntry)
|
||||
assertEquals("mimetype", first.name)
|
||||
assertEquals(ZipEntry.STORED, first.method)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rewrite in place rejects invalid epub without replacing source`() = withTempDir { dir ->
|
||||
val source = File(dir, "broken.epub").apply { writeText("not an epub") }
|
||||
val backup = File(dir, "backup.epub")
|
||||
|
||||
assertFailsWith<Exception> {
|
||||
SharedEpubMetadataEditor.rewriteInPlace(source, backup, update())
|
||||
}
|
||||
|
||||
assertEquals("not an epub", source.readText())
|
||||
assertTrue(!backup.exists())
|
||||
}
|
||||
|
||||
private fun update(): SharedEpubMetadataUpdate {
|
||||
return SharedEpubMetadataUpdate(
|
||||
title = "New Title",
|
||||
author = "New Author",
|
||||
description = "New summary",
|
||||
seriesName = "New Series",
|
||||
seriesIndex = 2.5
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeEpub(
|
||||
target: File,
|
||||
metadata: String = """
|
||||
<metadata>
|
||||
<dc:title>Old</dc:title>
|
||||
</metadata>
|
||||
""".trimIndent()
|
||||
) {
|
||||
ZipOutputStream(target.outputStream()).use { zip ->
|
||||
zip.putStoredText("mimetype", "application/epub+zip")
|
||||
zip.putText(
|
||||
"META-INF/container.xml",
|
||||
"""<container><rootfiles><rootfile full-path="OEBPS/content.opf" /></rootfiles></container>"""
|
||||
)
|
||||
zip.putText(
|
||||
"OEBPS/content.opf",
|
||||
"""
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
$metadata
|
||||
<manifest><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" /></manifest>
|
||||
<spine><itemref idref="chapter" /></spine>
|
||||
</package>
|
||||
""".trimIndent()
|
||||
)
|
||||
zip.putText("OEBPS/chapter.xhtml", "chapter")
|
||||
}
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putText(name: String, text: String) {
|
||||
putNextEntry(ZipEntry(name))
|
||||
write(text.toByteArray())
|
||||
closeEntry()
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putStoredText(name: String, text: String) {
|
||||
val bytes = text.toByteArray()
|
||||
val crc = CRC32().apply { update(bytes) }.value
|
||||
val entry = ZipEntry(name).apply {
|
||||
method = ZipEntry.STORED
|
||||
size = bytes.size.toLong()
|
||||
compressedSize = bytes.size.toLong()
|
||||
this.crc = crc
|
||||
}
|
||||
putNextEntry(entry)
|
||||
write(bytes)
|
||||
closeEntry()
|
||||
}
|
||||
|
||||
private inline fun withTempDir(block: (File) -> Unit) {
|
||||
val dir = createTempDir(prefix = "epub-metadata-test")
|
||||
try {
|
||||
block(dir)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class SharedEpubPaginationCacheTest {
|
||||
|
||||
@Test
|
||||
fun `page cache round trips measured pages`() = runBlocking {
|
||||
val root = Files.createTempDirectory("reader-page-cache").toFile()
|
||||
try {
|
||||
val cache = SharedEpubPaginationCache(root)
|
||||
val book = cacheBook()
|
||||
val settings = ReaderSettings(fontSize = 19, lineSpacing = 1.5f)
|
||||
val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720)
|
||||
val pages = listOf(
|
||||
ReaderPage(
|
||||
pageIndex = 12,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "Cached page",
|
||||
startOffset = 4,
|
||||
endOffset = 15
|
||||
)
|
||||
)
|
||||
|
||||
cache.save(book, settings, viewport, pages)
|
||||
val loaded = cache.load(book, settings, viewport)
|
||||
|
||||
assertNotNull(loaded)
|
||||
assertEquals(1, loaded.size)
|
||||
assertEquals(0, loaded.first().pageIndex)
|
||||
assertEquals("Cached page", loaded.first().text)
|
||||
assertEquals(4, loaded.first().startOffset)
|
||||
assertEquals(15, loaded.first().endOffset)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page cache misses when viewport or chapter content changes`() = runBlocking {
|
||||
val root = Files.createTempDirectory("reader-page-cache").toFile()
|
||||
try {
|
||||
val cache = SharedEpubPaginationCache(root)
|
||||
val book = cacheBook()
|
||||
val settings = ReaderSettings()
|
||||
val viewport = ReaderViewportSpec(widthPx = 900, heightPx = 700)
|
||||
val pages = listOf(
|
||||
ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "Cached page",
|
||||
startOffset = 0,
|
||||
endOffset = 11
|
||||
)
|
||||
)
|
||||
|
||||
cache.save(book, settings, viewport, pages)
|
||||
|
||||
assertNull(cache.load(book, settings, viewport.copy(widthPx = 901)))
|
||||
assertNull(
|
||||
cache.load(
|
||||
book.copy(
|
||||
chapters = book.chapters.map { chapter ->
|
||||
chapter.copy(plainText = chapter.plainText + " Changed.")
|
||||
}
|
||||
),
|
||||
settings,
|
||||
viewport
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pagination cache key changes for spread mode`() {
|
||||
val root = Files.createTempDirectory("reader-page-cache").toFile()
|
||||
try {
|
||||
val cache = SharedEpubPaginationCache(root)
|
||||
val book = cacheBook()
|
||||
val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720)
|
||||
val single = cache.keyFor(book, ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.SINGLE), viewport)
|
||||
val spread = cache.keyFor(book, ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE), viewport)
|
||||
|
||||
assertFalse(single.configHash == spread.configHash)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear all removes persisted and memory pagination pages`() = runBlocking {
|
||||
val root = Files.createTempDirectory("reader-page-cache").toFile()
|
||||
try {
|
||||
val cache = SharedEpubPaginationCache(root)
|
||||
val book = cacheBook()
|
||||
val settings = ReaderSettings()
|
||||
val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720)
|
||||
val pages = listOf(
|
||||
ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "Cached page",
|
||||
startOffset = 0,
|
||||
endOffset = 11
|
||||
)
|
||||
)
|
||||
|
||||
cache.save(book, settings, viewport, pages)
|
||||
assertNotNull(cache.load(book, settings, viewport))
|
||||
|
||||
cache.clearAll()
|
||||
|
||||
assertNull(cache.load(book, settings, viewport))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cacheBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "book-id",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
author = "Author",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter-1",
|
||||
title = "One",
|
||||
plainText = "Cached page content.",
|
||||
htmlContent = "<p>Cached page content.</p>",
|
||||
baseHref = "one.xhtml"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import com.aryan.reader.shared.FileType
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class SharedJvmBookLoadCacheTest {
|
||||
|
||||
@Test
|
||||
fun `book load cache round trips parsed shared book`() {
|
||||
val root = Files.createTempDirectory("reader-book-load-cache").toFile()
|
||||
try {
|
||||
val cache = SharedJvmBookLoadCache(root)
|
||||
val key = SharedJvmBookLoadCacheKey(
|
||||
canonicalPath = "C:/Books/book.epub",
|
||||
type = FileType.EPUB,
|
||||
length = 1234L,
|
||||
lastModified = 5678L
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "C:/Books/book.epub",
|
||||
fileName = "book.epub",
|
||||
title = "Cached Book",
|
||||
author = "Author",
|
||||
css = mapOf("style.css" to "p { margin: 0; }"),
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Hello cache.",
|
||||
htmlContent = "<p>Hello cache.</p>",
|
||||
baseHref = "one.xhtml"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
cache.save(key, book)
|
||||
val loaded = cache.load(key)
|
||||
|
||||
assertNotNull(loaded)
|
||||
assertEquals(book.title, loaded.title)
|
||||
assertEquals(book.css, loaded.css)
|
||||
assertEquals("Hello cache.", loaded.chapters.single().plainText)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book load cache misses when source fingerprint changes`() {
|
||||
val root = Files.createTempDirectory("reader-book-load-cache").toFile()
|
||||
try {
|
||||
val cache = SharedJvmBookLoadCache(root)
|
||||
val key = SharedJvmBookLoadCacheKey(
|
||||
canonicalPath = "C:/Books/book.epub",
|
||||
type = FileType.EPUB,
|
||||
length = 1234L,
|
||||
lastModified = 5678L
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "C:/Books/book.epub",
|
||||
fileName = "book.epub",
|
||||
title = "Cached Book",
|
||||
chapters = listOf(SharedEpubChapter("one", "One", "Hello cache."))
|
||||
)
|
||||
|
||||
cache.save(key, book)
|
||||
|
||||
assertNull(cache.load(key.copy(lastModified = 5679L)))
|
||||
assertNull(cache.load(key.copy(length = 1235L)))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import com.aryan.reader.paginatedreader.SemanticImage
|
||||
import com.aryan.reader.shared.FileType
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.Base64
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.test.Test
|
||||
|
|
@ -139,6 +141,60 @@ class SharedJvmBookLoaderTest {
|
|||
assertTrue(book.chapters.joinToString("\n") { it.plainText }.length > 100)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub loader keeps embedded images in semantic pagination blocks`() = withTempDir { dir ->
|
||||
val file = File(dir, "image-book.epub")
|
||||
writeZip(file) {
|
||||
text(
|
||||
"META-INF/container.xml",
|
||||
"""
|
||||
<container>
|
||||
<rootfiles>
|
||||
<rootfile full-path="OPS/content.opf"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"OPS/content.opf",
|
||||
"""
|
||||
<package>
|
||||
<metadata>
|
||||
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Image Book</dc:title>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="pixel" href="images/pixel.png" media-type="image/png"/>
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="chapter"/>
|
||||
</spine>
|
||||
</package>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"OPS/chapter.xhtml",
|
||||
"""
|
||||
<html>
|
||||
<body>
|
||||
<h1>One</h1>
|
||||
<p>Before</p>
|
||||
<img src="images/pixel.png" alt="Pixel"/>
|
||||
<p>After</p>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
)
|
||||
bytes("OPS/images/pixel.png", onePixelPng)
|
||||
}
|
||||
|
||||
val book = SharedJvmBookLoader.loadEpub(file)
|
||||
val image = book.chapters.single().semanticBlocks.filterIsInstance<SemanticImage>().single()
|
||||
|
||||
assertTrue(image.path.startsWith("data:image/png;base64,"))
|
||||
assertEquals("Pixel", image.altText)
|
||||
}
|
||||
|
||||
private fun withTempDir(block: (File) -> Unit) {
|
||||
val dir = Files.createTempDirectory("reader-shared-loader").toFile()
|
||||
try {
|
||||
|
|
@ -193,9 +249,16 @@ class SharedJvmBookLoaderTest {
|
|||
|
||||
private class ZipBuilder(private val zip: ZipOutputStream) {
|
||||
fun text(path: String, value: String) {
|
||||
bytes(path, value.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
fun bytes(path: String, value: ByteArray) {
|
||||
zip.putNextEntry(ZipEntry(path))
|
||||
zip.write(value.toByteArray(Charsets.UTF_8))
|
||||
zip.write(value)
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
|
||||
private val onePixelPng: ByteArray =
|
||||
Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class SharedJvmLruMemoryCacheTest {
|
||||
@Test
|
||||
fun `cache evicts least recently used entry`() {
|
||||
val cache = SharedJvmLruMemoryCache<String, Int>(maxEntries = 2)
|
||||
|
||||
cache["one"] = 1
|
||||
cache["two"] = 2
|
||||
assertEquals(1, cache["one"])
|
||||
|
||||
cache["three"] = 3
|
||||
|
||||
assertEquals(1, cache["one"])
|
||||
assertNull(cache["two"])
|
||||
assertEquals(3, cache["three"])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedJvmUserDirectoriesTest {
|
||||
@Test
|
||||
fun `shared jvm cache root uses xdg cache on linux`() {
|
||||
val root = sharedJvmEpistemeCacheRoot(
|
||||
env = mapOf("XDG_CACHE_HOME" to "/tmp/reader-cache")::get,
|
||||
userHome = "/home/reader",
|
||||
osName = "Linux"
|
||||
)
|
||||
|
||||
assertEquals("/tmp/reader-cache/episteme", root.portablePath())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared jvm cache root preserves windows appdata location`() {
|
||||
val root = sharedJvmEpistemeCacheRoot(
|
||||
env = mapOf("APPDATA" to "C:/Users/reader/AppData/Roaming")::get,
|
||||
userHome = "C:/Users/reader",
|
||||
osName = "Windows 11"
|
||||
)
|
||||
|
||||
assertEquals("C:/Users/reader/AppData/Roaming/Episteme", root.portablePath())
|
||||
}
|
||||
}
|
||||
|
||||
private fun java.io.File.portablePath(): String {
|
||||
return path.replace('\\', '/')
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import androidx.compose.ui.text.ParagraphStyle
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.paginatedreader.BlockStyle
|
||||
import com.aryan.reader.paginatedreader.BoxBorders
|
||||
import com.aryan.reader.paginatedreader.CssStyle
|
||||
import com.aryan.reader.paginatedreader.SemanticParagraph
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class SharedMeasuredEpubPaginatorTest {
|
||||
|
||||
@Test
|
||||
fun `two page geometry caps each page to rendered page width on wide viewports`() {
|
||||
val geometry = measuredPageGeometryFor(
|
||||
settings = ReaderSettings(
|
||||
pageWidth = 760,
|
||||
margin = 48,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
),
|
||||
viewport = ReaderViewportSpec(widthPx = 2_400, heightPx = 1_200)
|
||||
)
|
||||
|
||||
assertEquals(760, geometry.pageWidthPx)
|
||||
assertEquals(1_104, geometry.pageHeightPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `geometry does not invent minimum page space beyond the rendered viewport`() {
|
||||
val geometry = measuredPageGeometryFor(
|
||||
settings = ReaderSettings(
|
||||
pageWidth = 760,
|
||||
horizontalMargin = 80,
|
||||
verticalMargin = 120
|
||||
),
|
||||
viewport = ReaderViewportSpec(widthPx = 300, heightPx = 220)
|
||||
)
|
||||
|
||||
assertEquals(140, geometry.pageWidthPx)
|
||||
assertEquals(1, geometry.pageHeightPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `geometry scales css-sized page settings to measured desktop pixels`() {
|
||||
val geometry = measuredPageGeometryFor(
|
||||
settings = ReaderSettings(
|
||||
pageWidth = 760,
|
||||
horizontalMargin = 0,
|
||||
verticalMargin = 0
|
||||
),
|
||||
viewport = ReaderViewportSpec(widthPx = 1_900, heightPx = 860),
|
||||
densityScale = 1.25f
|
||||
)
|
||||
|
||||
assertEquals(950, geometry.pageWidthPx)
|
||||
assertEquals(860, geometry.pageHeightPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paragraph split trims whitespace and prepares continuation styling`() {
|
||||
val paragraph = SemanticParagraph(
|
||||
text = "Alpha beta gamma delta",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(
|
||||
paragraphStyle = ParagraphStyle(
|
||||
textIndent = TextIndent(firstLine = 24.sp, restLine = 8.sp)
|
||||
),
|
||||
blockStyle = BlockStyle(
|
||||
margin = BoxBorders(top = 12.dp)
|
||||
)
|
||||
),
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 100,
|
||||
blockIndex = 7
|
||||
)
|
||||
|
||||
val split = assertNotNull(splitSemanticTextBlockAtOffsetForPagination(paragraph, 11))
|
||||
|
||||
assertEquals("Alpha beta", split.first.text)
|
||||
assertEquals(100, split.first.startCharOffsetInSource)
|
||||
assertEquals("gamma delta", split.second.text)
|
||||
assertEquals(112, split.second.startCharOffsetInSource)
|
||||
assertEquals(
|
||||
TextIndent(firstLine = 0.sp, restLine = 8.sp),
|
||||
split.second.style.paragraphStyle.textIndent
|
||||
)
|
||||
assertEquals(0.dp, split.second.style.blockStyle.margin.top)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pagination stack collapses adjacent margins and can ignore trailing bottom margin`() {
|
||||
val items = listOf(
|
||||
PaginationStackItem(contentHeightPx = 100, marginTopPx = 18, marginBottomPx = 18),
|
||||
PaginationStackItem(contentHeightPx = 80, marginTopPx = 18, marginBottomPx = 18)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
216,
|
||||
collapsedPaginationStackHeight(items, includeTrailingBottomMargin = false)
|
||||
)
|
||||
assertEquals(
|
||||
234,
|
||||
collapsedPaginationStackHeight(items, includeTrailingBottomMargin = true)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pagination stack prefix fitting includes trailing bottom margin`() {
|
||||
val items = listOf(
|
||||
PaginationStackItem(contentHeightPx = 100, marginTopPx = 10, marginBottomPx = 30),
|
||||
PaginationStackItem(contentHeightPx = 80, marginTopPx = 10, marginBottomPx = 30)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
1,
|
||||
paginationStackPrefixCountThatFits(
|
||||
items = items,
|
||||
availableHeightPx = 220,
|
||||
includeTrailingBottomMargin = true
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
2,
|
||||
paginationStackPrefixCountThatFits(
|
||||
items = items,
|
||||
availableHeightPx = 220,
|
||||
includeTrailingBottomMargin = false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import java.awt.Color
|
||||
import java.awt.image.BufferedImage
|
||||
import java.nio.file.Files
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopBookCoverImageCacheTest {
|
||||
|
||||
@Test
|
||||
fun `cover cache reloads when source fingerprint changes`() {
|
||||
val root = Files.createTempDirectory("reader-cover-cache").toFile()
|
||||
try {
|
||||
DesktopBookCoverImageCache.clearForTests()
|
||||
val cover = root.resolve("cover.png")
|
||||
writeImage(cover.absolutePath, width = 64, height = 64)
|
||||
|
||||
val first = DesktopBookCoverImageCache.load(cover.absolutePath)
|
||||
assertNotNull(first)
|
||||
assertEquals(64, first.width)
|
||||
|
||||
writeImage(cover.absolutePath, width = 96, height = 48)
|
||||
cover.setLastModified(cover.lastModified() + 2_000L)
|
||||
|
||||
val second = DesktopBookCoverImageCache.load(cover.absolutePath)
|
||||
assertNotNull(second)
|
||||
assertEquals(96, second.width)
|
||||
assertEquals(48, second.height)
|
||||
} finally {
|
||||
DesktopBookCoverImageCache.clearForTests()
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `large covers are cached at thumbnail size`() {
|
||||
val root = Files.createTempDirectory("reader-cover-cache").toFile()
|
||||
try {
|
||||
DesktopBookCoverImageCache.clearForTests()
|
||||
val cover = root.resolve("large-cover.png")
|
||||
writeImage(cover.absolutePath, width = 1_200, height = 800)
|
||||
|
||||
val bitmap = DesktopBookCoverImageCache.load(cover.absolutePath)
|
||||
|
||||
assertNotNull(bitmap)
|
||||
assertTrue(bitmap.width <= 512)
|
||||
assertTrue(bitmap.height <= 512)
|
||||
} finally {
|
||||
DesktopBookCoverImageCache.clearForTests()
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeImage(path: String, width: Int, height: Int) {
|
||||
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)
|
||||
val graphics = image.createGraphics()
|
||||
try {
|
||||
graphics.color = Color(0x2A, 0x5C, 0x88)
|
||||
graphics.fillRect(0, 0, width, height)
|
||||
} finally {
|
||||
graphics.dispose()
|
||||
}
|
||||
ImageIO.write(image, "png", java.io.File(path))
|
||||
}
|
||||
}
|
||||
|
|
@ -36,8 +36,40 @@ import org.jsoup.nodes.Element
|
|||
import org.jsoup.nodes.Node
|
||||
import org.jsoup.nodes.TextNode
|
||||
import org.jsoup.select.Selector
|
||||
import java.util.ArrayDeque
|
||||
import java.util.IdentityHashMap
|
||||
|
||||
private val unsupportedPseudoElementRegex = Regex("::?(before|after|first-letter|first-line|marker|selection)", RegexOption.IGNORE_CASE)
|
||||
private const val MAX_SEMANTIC_TEXT_BLOCK_CHARS = 32_000
|
||||
private const val TEXT_APPEND_SLICE_CHARS = 2_048
|
||||
private val semanticBlockDescendantTags = setOf(
|
||||
"img",
|
||||
"svg",
|
||||
"math-placeholder",
|
||||
"table",
|
||||
"hr",
|
||||
"div",
|
||||
"p",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"figure",
|
||||
"article",
|
||||
"aside",
|
||||
"header",
|
||||
"footer",
|
||||
"nav",
|
||||
"section",
|
||||
"main"
|
||||
)
|
||||
private val forcedStandaloneSemanticTags = setOf("img", "svg", "math-placeholder", "hr", "table")
|
||||
|
||||
interface HtmlResourceResolver {
|
||||
fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String?
|
||||
|
|
@ -91,6 +123,12 @@ private fun String.capitalizeWords(): String =
|
|||
if (word.isNotEmpty()) word.replaceFirstChar { it.titlecase() } else ""
|
||||
}
|
||||
|
||||
private data class SemanticTextChunk(
|
||||
val text: String,
|
||||
val spans: List<SemanticSpan>,
|
||||
val startCharOffsetInSource: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* The public entry point for converting HTML to a list of [SemanticBlock]s.
|
||||
* This function sets up a parsing context and delegates the work to a [SemanticHtmlParser] instance.
|
||||
|
|
@ -144,13 +182,14 @@ private class SemanticHtmlParser(
|
|||
private val adaptThemeColors: Boolean
|
||||
) {
|
||||
private val styleCache = mutableMapOf<String, CssStyle>()
|
||||
private val semanticBlockDescendantCache = IdentityHashMap<Element, Boolean>()
|
||||
private var combinedRules: OptimizedCssRules = cssRules
|
||||
private val currentFontFamilyMap: MutableMap<String, FontFamily> = fontFamilyMap.toMutableMap()
|
||||
private var nextBlockIndex = 0
|
||||
|
||||
fun parse(html: String): List<SemanticBlock> {
|
||||
val document = Jsoup.parse(html, chapterAbsPath)
|
||||
val inlineCssContent = document.head().select("style").joinToString(separator = "\n") { it.data() }
|
||||
val inlineCssContent = document.head().getElementsByTag("style").joinToString(separator = "\n") { it.data() }
|
||||
|
||||
if (inlineCssContent.isNotBlank()) {
|
||||
HtmlParserLog.d("Found inline <style> content in $chapterAbsPath. Parsing...")
|
||||
|
|
@ -177,6 +216,61 @@ private class SemanticHtmlParser(
|
|||
return parseContainer(body, getElementStyle(body))
|
||||
}
|
||||
|
||||
private inline fun Element.anyChildElement(predicate: (Element) -> Boolean): Boolean {
|
||||
childNodes().forEach { child ->
|
||||
if (child is Element && predicate(child)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun Element.hasSemanticBlockDescendant(): Boolean {
|
||||
semanticBlockDescendantCache[this]?.let { return it }
|
||||
|
||||
if (anyChildElement { child -> child.tagName().lowercase() in semanticBlockDescendantTags }) {
|
||||
semanticBlockDescendantCache[this] = true
|
||||
return true
|
||||
}
|
||||
|
||||
val stack = ArrayDeque<Element>()
|
||||
stack.add(this)
|
||||
val expanded = IdentityHashMap<Element, Boolean>()
|
||||
|
||||
while (stack.isNotEmpty()) {
|
||||
val current = stack.peekLast()
|
||||
if (semanticBlockDescendantCache.containsKey(current)) {
|
||||
stack.removeLast()
|
||||
continue
|
||||
}
|
||||
|
||||
if (expanded.put(current, true) == null) {
|
||||
current.childNodes().forEach { child ->
|
||||
if (child is Element && !semanticBlockDescendantCache.containsKey(child)) {
|
||||
stack.add(child)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
stack.removeLast()
|
||||
val hasSemanticDescendant = current.anyChildElement { child ->
|
||||
child.tagName().lowercase() in semanticBlockDescendantTags ||
|
||||
semanticBlockDescendantCache[child] == true
|
||||
}
|
||||
semanticBlockDescendantCache[current] = hasSemanticDescendant
|
||||
}
|
||||
|
||||
return semanticBlockDescendantCache[this] == true
|
||||
}
|
||||
|
||||
private fun Element.isEffectivelySemanticBlock(): Boolean {
|
||||
val tagName = tagName().lowercase()
|
||||
return isBlock ||
|
||||
tagName in forcedStandaloneSemanticTags ||
|
||||
(!isBlock && hasSemanticBlockDescendant())
|
||||
}
|
||||
|
||||
private fun parseNodeToSemanticBlocks(
|
||||
element: Element,
|
||||
inheritedStyle: CssStyle
|
||||
|
|
@ -322,7 +416,7 @@ private class SemanticHtmlParser(
|
|||
"math-placeholder" -> parseMathPlaceholderToSemantic(element, elementStyle)
|
||||
"img" -> parseImageElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
|
||||
"h1", "h2", "h3", "h4", "h5", "h6" -> {
|
||||
val hasNonTextChildren = element.select("img, svg, math-placeholder, table, hr, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()
|
||||
val hasNonTextChildren = element.hasSemanticBlockDescendant()
|
||||
if (hasNonTextChildren) {
|
||||
val level = tagName.substring(1).toIntOrNull() ?: 1
|
||||
val fontSizeMultiplier = when (level) {
|
||||
|
|
@ -371,7 +465,7 @@ private class SemanticHtmlParser(
|
|||
"hr" -> listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, blockIndex = nextBlockIndex++))
|
||||
"ul", "ol" -> parseListElementToSemantic(element, elementStyle)
|
||||
else -> {
|
||||
val hasBlockDescendant = !element.isBlock && element.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()
|
||||
val hasBlockDescendant = !element.isBlock && element.hasSemanticBlockDescendant()
|
||||
if (element.isBlock || hasBlockDescendant) {
|
||||
parseContainer(element, elementStyle)
|
||||
} else {
|
||||
|
|
@ -397,32 +491,44 @@ private class SemanticHtmlParser(
|
|||
|
||||
fun flushTextBuffer() {
|
||||
if (textNodesBuffer.isEmpty()) return
|
||||
val (text, spans) = buildSemanticTextAndSpansFromNodes(textNodesBuffer, style)
|
||||
if (text.isNotBlank()) {
|
||||
val finalSpans = spans.toMutableList()
|
||||
val textChunks = buildSemanticTextAndSpanChunksFromNodes(textNodesBuffer, style)
|
||||
val containerElementId = element.id().ifBlank { null }
|
||||
val containerCfi = element.getCfiPath()
|
||||
textChunks.forEachIndexed { chunkIndex, chunk ->
|
||||
if (chunk.text.isBlank()) return@forEachIndexed
|
||||
|
||||
val finalSpans = chunk.spans.toMutableList()
|
||||
if (element.tagName().lowercase() == "a") {
|
||||
val href = element.attr("href").ifBlank { null }
|
||||
if (href != null) {
|
||||
finalSpans.add(SemanticSpan(
|
||||
start = 0,
|
||||
end = text.length,
|
||||
end = chunk.text.length,
|
||||
style = style,
|
||||
linkHref = href,
|
||||
tag = "a",
|
||||
elementId = element.id().ifBlank { null }
|
||||
elementId = containerElementId.takeIf { chunkIndex == 0 }
|
||||
))
|
||||
}
|
||||
}
|
||||
children.add(SemanticParagraph(text, finalSpans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++))
|
||||
children.add(
|
||||
SemanticParagraph(
|
||||
text = chunk.text,
|
||||
spans = finalSpans,
|
||||
style = style,
|
||||
elementId = containerElementId.takeIf { chunkIndex == 0 },
|
||||
cfi = containerCfi,
|
||||
startCharOffsetInSource = chunk.startCharOffsetInSource,
|
||||
blockIndex = nextBlockIndex++
|
||||
)
|
||||
)
|
||||
}
|
||||
textNodesBuffer.clear()
|
||||
}
|
||||
|
||||
element.childNodes().forEach { node ->
|
||||
if (node is Element) {
|
||||
val tagName = node.tagName().lowercase()
|
||||
val isEffectivelyBlock = node.isBlock || tagName in listOf("img", "svg", "math-placeholder", "hr") ||
|
||||
(!node.isBlock && node.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty())
|
||||
val isEffectivelyBlock = node.isEffectivelySemanticBlock()
|
||||
|
||||
if (isEffectivelyBlock) {
|
||||
flushTextBuffer()
|
||||
|
|
@ -450,79 +556,190 @@ private class SemanticHtmlParser(
|
|||
nodes: List<Node>,
|
||||
rootStyle: CssStyle
|
||||
): Pair<String, List<SemanticSpan>> {
|
||||
val chunks = buildSemanticTextAndSpanChunksFromNodes(nodes, rootStyle)
|
||||
val firstChunk = chunks.firstOrNull() ?: return "" to emptyList()
|
||||
return firstChunk.text to firstChunk.spans
|
||||
}
|
||||
|
||||
private fun buildSemanticTextAndSpanChunksFromNodes(
|
||||
nodes: List<Node>,
|
||||
rootStyle: CssStyle
|
||||
): List<SemanticTextChunk> {
|
||||
val textBuilder = StringBuilder()
|
||||
val spans = mutableListOf<SemanticSpan>()
|
||||
val chunks = mutableListOf<SemanticTextChunk>()
|
||||
val activeSpans = mutableListOf<ActiveSemanticSpan>()
|
||||
var currentChunkStartOffset = 0
|
||||
|
||||
fun processNode(node: Node, inheritedStyle: CssStyle) {
|
||||
when (node) {
|
||||
is TextNode -> {
|
||||
var text = node.wholeText.replace('\n', ' ')
|
||||
when (inheritedStyle.textTransform) {
|
||||
"uppercase" -> text = text.uppercase()
|
||||
"lowercase" -> text = text.lowercase()
|
||||
"capitalize" -> text = text.capitalizeWords()
|
||||
}
|
||||
textBuilder.append(text)
|
||||
}
|
||||
is Element -> {
|
||||
if (node.tagName().lowercase() == "br") {
|
||||
textBuilder.append('\n'); return
|
||||
}
|
||||
val currentElementStyle = getElementStyle(node)
|
||||
val newStyle = inheritedStyle.merge(currentElementStyle)
|
||||
val startIndex = textBuilder.length
|
||||
node.childNodes().forEach { processNode(it, newStyle) }
|
||||
val endIndex = textBuilder.length
|
||||
|
||||
val elementId = node.id().ifBlank { null }
|
||||
val isAnchor = node.tagName().lowercase() == "a" || elementId != null
|
||||
|
||||
// Capture span if it has content OR if it has an ID (anchor)
|
||||
if (startIndex < endIndex || elementId != null) {
|
||||
val href = if (node.tagName().lowercase() == "a") node.attr("href").ifBlank { null } else null
|
||||
spans.add(SemanticSpan(
|
||||
start = startIndex,
|
||||
end = endIndex,
|
||||
style = newStyle,
|
||||
linkHref = href,
|
||||
tag = node.tagName().lowercase(),
|
||||
elementId = elementId // Pass the ID here
|
||||
))
|
||||
}
|
||||
}
|
||||
fun addSpan(
|
||||
start: Int,
|
||||
end: Int,
|
||||
style: CssStyle,
|
||||
linkHref: String?,
|
||||
tag: String,
|
||||
elementId: String?
|
||||
) {
|
||||
if (start < end || elementId != null) {
|
||||
spans.add(
|
||||
SemanticSpan(
|
||||
start = start.coerceAtLeast(0),
|
||||
end = end.coerceAtLeast(start),
|
||||
style = style,
|
||||
linkHref = linkHref,
|
||||
tag = tag,
|
||||
elementId = elementId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
nodes.forEach { processNode(it, rootStyle) }
|
||||
|
||||
var processedText = textBuilder.toString()
|
||||
if (processedText.isNotEmpty() && processedText.last().isWhitespace()) {
|
||||
// 1. Find the index where trailing whitespace begins
|
||||
var newLength = processedText.length
|
||||
while (newLength > 0 && processedText[newLength - 1].isWhitespace()) {
|
||||
fun trimTrailingWhitespace(
|
||||
text: String,
|
||||
sourceSpans: List<SemanticSpan>
|
||||
): Pair<String, List<SemanticSpan>> {
|
||||
var newLength = text.length
|
||||
while (newLength > 0 && text[newLength - 1].isWhitespace()) {
|
||||
newLength--
|
||||
}
|
||||
|
||||
// 2. Cut the text
|
||||
processedText = processedText.substring(0, newLength)
|
||||
if (newLength == text.length) return text to sourceSpans
|
||||
|
||||
// 3. Filter or Cap spans so they don't point to indices that no longer exist
|
||||
val adjustedSpans = spans.mapNotNull { span ->
|
||||
val adjustedSpans = sourceSpans.mapNotNull { span ->
|
||||
if (span.start >= newLength) {
|
||||
// Span started in the whitespace area, remove it
|
||||
null
|
||||
} else if (span.end > newLength) {
|
||||
// Span ended in the whitespace area, cap it
|
||||
span.copy(end = newLength)
|
||||
} else {
|
||||
span
|
||||
}
|
||||
}
|
||||
return processedText to adjustedSpans
|
||||
return text.substring(0, newLength) to adjustedSpans
|
||||
}
|
||||
|
||||
return processedText to spans
|
||||
fun flushChunk(trimTrailing: Boolean) {
|
||||
if (textBuilder.isEmpty()) return
|
||||
|
||||
activeSpans.forEach { active ->
|
||||
addSpan(
|
||||
start = active.startInChunk,
|
||||
end = textBuilder.length,
|
||||
style = active.style,
|
||||
linkHref = active.linkHref,
|
||||
tag = active.tag,
|
||||
elementId = active.elementId
|
||||
)
|
||||
}
|
||||
|
||||
val rawText = textBuilder.toString()
|
||||
val rawLength = rawText.length
|
||||
val (trimmedText, trimmedSpans) = if (trimTrailing) {
|
||||
trimTrailingWhitespace(rawText, spans)
|
||||
} else {
|
||||
rawText to spans.toList()
|
||||
}
|
||||
if (trimmedText.isNotBlank()) {
|
||||
chunks.add(
|
||||
SemanticTextChunk(
|
||||
text = trimmedText,
|
||||
spans = trimmedSpans,
|
||||
startCharOffsetInSource = currentChunkStartOffset
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
currentChunkStartOffset += rawLength
|
||||
textBuilder.clear()
|
||||
spans.clear()
|
||||
activeSpans.forEach { it.startInChunk = 0 }
|
||||
}
|
||||
|
||||
fun appendText(text: String) {
|
||||
var offset = 0
|
||||
while (offset < text.length) {
|
||||
if (textBuilder.length >= MAX_SEMANTIC_TEXT_BLOCK_CHARS) {
|
||||
flushChunk(trimTrailing = false)
|
||||
}
|
||||
val available = (MAX_SEMANTIC_TEXT_BLOCK_CHARS - textBuilder.length).coerceAtLeast(1)
|
||||
val end = (offset + available).coerceAtMost(text.length)
|
||||
textBuilder.append(text, offset, end)
|
||||
offset = end
|
||||
if (textBuilder.length >= MAX_SEMANTIC_TEXT_BLOCK_CHARS) {
|
||||
flushChunk(trimTrailing = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun appendTransformedText(rawText: String, textTransform: String?) {
|
||||
var start = 0
|
||||
while (start < rawText.length) {
|
||||
val end = (start + TEXT_APPEND_SLICE_CHARS).coerceAtMost(rawText.length)
|
||||
val normalizedSlice = buildString(end - start) {
|
||||
for (i in start until end) {
|
||||
append(if (rawText[i] == '\n') ' ' else rawText[i])
|
||||
}
|
||||
}
|
||||
val transformedSlice = when (textTransform) {
|
||||
"uppercase" -> normalizedSlice.uppercase()
|
||||
"lowercase" -> normalizedSlice.lowercase()
|
||||
"capitalize" -> normalizedSlice.capitalizeWords()
|
||||
else -> normalizedSlice
|
||||
}
|
||||
appendText(transformedSlice)
|
||||
start = end
|
||||
}
|
||||
}
|
||||
|
||||
fun processNode(node: Node, inheritedStyle: CssStyle) {
|
||||
when (node) {
|
||||
is TextNode -> {
|
||||
appendTransformedText(node.wholeText, inheritedStyle.textTransform)
|
||||
}
|
||||
is Element -> {
|
||||
if (node.tagName().lowercase() == "br") {
|
||||
appendText("\n"); return
|
||||
}
|
||||
val currentElementStyle = getElementStyle(node)
|
||||
val newStyle = inheritedStyle.merge(currentElementStyle)
|
||||
val tag = node.tagName().lowercase()
|
||||
val href = if (tag == "a") node.attr("href").ifBlank { null } else null
|
||||
val elementId = node.id().ifBlank { null }
|
||||
val activeSpan = ActiveSemanticSpan(
|
||||
startInChunk = textBuilder.length,
|
||||
style = newStyle,
|
||||
linkHref = href,
|
||||
tag = tag,
|
||||
elementId = elementId
|
||||
)
|
||||
activeSpans.add(activeSpan)
|
||||
node.childNodes().forEach { processNode(it, newStyle) }
|
||||
activeSpans.removeAt(activeSpans.lastIndex)
|
||||
val endIndex = textBuilder.length
|
||||
|
||||
// Capture span if it has content OR if it has an ID (anchor)
|
||||
addSpan(
|
||||
start = activeSpan.startInChunk,
|
||||
end = endIndex,
|
||||
style = newStyle,
|
||||
linkHref = href,
|
||||
tag = tag,
|
||||
elementId = elementId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes.forEach { processNode(it, rootStyle) }
|
||||
flushChunk(trimTrailing = true)
|
||||
return chunks
|
||||
}
|
||||
|
||||
private data class ActiveSemanticSpan(
|
||||
var startInChunk: Int,
|
||||
val style: CssStyle,
|
||||
val linkHref: String?,
|
||||
val tag: String,
|
||||
val elementId: String?
|
||||
)
|
||||
|
||||
private fun parseMathPlaceholderToSemantic(element: Element, style: CssStyle): List<SemanticBlock> {
|
||||
val uniqueId = element.id()
|
||||
val svgContent = mathSvgCache[uniqueId]
|
||||
|
|
@ -532,7 +749,7 @@ private class SemanticHtmlParser(
|
|||
var svgViewBox: String? = null
|
||||
if (svgContent != null) {
|
||||
val svgDoc = Jsoup.parse(svgContent)
|
||||
svgDoc.selectFirst("svg")?.let {
|
||||
svgDoc.getElementsByTag("svg").firstOrNull()?.let {
|
||||
svgWidth = it.attr("width")
|
||||
svgHeight = it.attr("height")
|
||||
svgViewBox = it.attr("viewBox")
|
||||
|
|
@ -564,7 +781,7 @@ private class SemanticHtmlParser(
|
|||
|
||||
return SemanticImage(
|
||||
path = imagePath,
|
||||
altText = svgElement.selectFirst("title")?.text() ?: "Cover Image",
|
||||
altText = svgElement.getElementsByTag("title").firstOrNull()?.text() ?: "Cover Image",
|
||||
intrinsicWidth = width,
|
||||
intrinsicHeight = height,
|
||||
style = style,
|
||||
|
|
@ -575,8 +792,8 @@ private class SemanticHtmlParser(
|
|||
}
|
||||
|
||||
HtmlParserLog.d("Parsing genuine SVG content into SemanticMath block.")
|
||||
val title = svgElement.selectFirst("title")?.text()
|
||||
val desc = svgElement.selectFirst("desc")?.text()
|
||||
val title = svgElement.getElementsByTag("title").firstOrNull()?.text()
|
||||
val desc = svgElement.getElementsByTag("desc").firstOrNull()?.text()
|
||||
val altText = title ?: desc ?: "SVG Image"
|
||||
|
||||
return SemanticMath(
|
||||
|
|
@ -644,7 +861,7 @@ private class SemanticHtmlParser(
|
|||
}
|
||||
|
||||
private fun parseTableElementToSemantic(tableElement: Element, tableStyle: CssStyle): SemanticTable? {
|
||||
val rows = tableElement.select("tr").mapNotNull { rowElement ->
|
||||
val rows = tableElement.getElementsByTag("tr").mapNotNull { rowElement ->
|
||||
val rowStyle = getElementStyle(rowElement)
|
||||
if (rowStyle.display == "none") return@mapNotNull null
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.parser.Parser
|
||||
import org.jsoup.parser.Tag
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.zip.CRC32
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
data class SharedEpubMetadataUpdate(
|
||||
val title: String?,
|
||||
val author: String?,
|
||||
val description: String?,
|
||||
val seriesName: String?,
|
||||
val seriesIndex: Double?
|
||||
)
|
||||
|
||||
data class SharedEpubMetadataSnapshot(
|
||||
val title: String?,
|
||||
val author: String?,
|
||||
val description: String?,
|
||||
val seriesName: String?,
|
||||
val seriesIndex: Double?
|
||||
)
|
||||
|
||||
object SharedEpubMetadataEditor {
|
||||
fun readMetadata(source: File): SharedEpubMetadataSnapshot? {
|
||||
if (!source.isFile) return null
|
||||
return runCatching {
|
||||
ZipFile(source).use { zip ->
|
||||
val opfPath = findOpfPath(zip) ?: return null
|
||||
val opf = zip.getEntry(opfPath)?.let { zip.readUtf8(it) } ?: return null
|
||||
val metadata = parseOpfMetadata(opf) ?: return null
|
||||
metadata.toSnapshot()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun rewrite(
|
||||
source: File,
|
||||
destination: File,
|
||||
update: SharedEpubMetadataUpdate
|
||||
): SharedEpubMetadataSnapshot {
|
||||
require(source.isFile) { "EPUB source does not exist: ${source.path}" }
|
||||
destination.parentFile?.mkdirs()
|
||||
|
||||
ZipFile(source).use { zip ->
|
||||
val opfPath = findOpfPath(zip) ?: error("EPUB package document was not found.")
|
||||
val opfEntry = zip.getEntry(opfPath) ?: error("EPUB package document entry is missing.")
|
||||
val originalOpf = zip.readUtf8(opfEntry)
|
||||
val updatedOpf = rewriteOpf(originalOpf, update)
|
||||
val updatedOpfBytes = updatedOpf.toByteArray(StandardCharsets.UTF_8)
|
||||
|
||||
if (destination.exists()) destination.delete()
|
||||
ZipOutputStream(destination.outputStream().buffered()).use { output ->
|
||||
val entries = zip.entries().asSequence().toList()
|
||||
entries.firstOrNull { it.name == "mimetype" }?.let { mimetype ->
|
||||
output.putStoredEntry(mimetype.name, zip.readBytes(mimetype), mimetype.time)
|
||||
}
|
||||
|
||||
entries.forEach { entry ->
|
||||
when {
|
||||
entry.name == "mimetype" -> Unit
|
||||
entry.name == opfPath -> output.putDeflatedEntry(entry.name, updatedOpfBytes, entry.time)
|
||||
entry.isDirectory -> output.putDirectoryEntry(entry)
|
||||
else -> output.putCopiedEntry(entry, zip.readBytes(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return readMetadata(destination) ?: error("Rewritten EPUB failed metadata validation.")
|
||||
}
|
||||
|
||||
fun rewriteInPlace(
|
||||
source: File,
|
||||
backup: File?,
|
||||
update: SharedEpubMetadataUpdate
|
||||
): SharedEpubMetadataSnapshot {
|
||||
require(source.isFile) { "EPUB source does not exist: ${source.path}" }
|
||||
val temp = File(source.parentFile ?: source.absoluteFile.parentFile, "${source.name}.metadata.tmp")
|
||||
return try {
|
||||
val snapshot = rewrite(source, temp, update)
|
||||
if (backup != null && !backup.exists()) {
|
||||
backup.parentFile?.mkdirs()
|
||||
Files.copy(source.toPath(), backup.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
runCatching {
|
||||
Files.move(
|
||||
temp.toPath(),
|
||||
source.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE
|
||||
)
|
||||
}.getOrElse {
|
||||
Files.move(temp.toPath(), source.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
snapshot
|
||||
} finally {
|
||||
if (temp.exists()) temp.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteOpf(opf: String, update: SharedEpubMetadataUpdate): String {
|
||||
val document = Jsoup.parse(opf, "", Parser.xmlParser())
|
||||
document.outputSettings()
|
||||
.syntax(Document.OutputSettings.Syntax.xml)
|
||||
.prettyPrint(false)
|
||||
.charset(StandardCharsets.UTF_8)
|
||||
|
||||
val packageElement = document.children().firstOrNull { it.localNameEquals("package") }
|
||||
packageElement?.let { ensureDcNamespace(it) }
|
||||
|
||||
val metadata = document.getAllElements().firstOrNull { it.localNameEquals("metadata") }
|
||||
?: error("EPUB package metadata section is missing.")
|
||||
|
||||
metadata.upsertDcText("title", update.title)
|
||||
metadata.upsertDcText("creator", update.author)
|
||||
metadata.upsertDcText("description", update.description)
|
||||
metadata.upsertMetaContent("calibre:series", update.seriesName)
|
||||
metadata.upsertMetaContent("calibre:series_index", update.seriesIndex?.formatSeriesIndex())
|
||||
|
||||
return document.outerHtml()
|
||||
}
|
||||
|
||||
private fun Element.toSnapshot(): SharedEpubMetadataSnapshot {
|
||||
return SharedEpubMetadataSnapshot(
|
||||
title = firstChildText("title"),
|
||||
author = firstChildText("creator"),
|
||||
description = firstChildText("description"),
|
||||
seriesName = firstMetaContent("calibre:series"),
|
||||
seriesIndex = firstMetaContent("calibre:series_index")?.toDoubleOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseOpfMetadata(opf: String): Element? {
|
||||
val document = Jsoup.parse(opf, "", Parser.xmlParser())
|
||||
return document.getAllElements().firstOrNull { it.localNameEquals("metadata") }
|
||||
}
|
||||
|
||||
private fun findOpfPath(zip: ZipFile): String? {
|
||||
val container = zip.getEntry("META-INF/container.xml")
|
||||
?.let { zip.readUtf8(it) }
|
||||
val declared = container
|
||||
?.let { rootfilePathRegex.find(it)?.groupValues?.getOrNull(1) }
|
||||
?.trim()
|
||||
?.trimStart('/')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (declared != null && zip.getEntry(declared) != null) return declared
|
||||
|
||||
return zip.entries()
|
||||
.asSequence()
|
||||
.map { it.name }
|
||||
.firstOrNull { it.endsWith(".opf", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private val rootfilePathRegex = Regex(
|
||||
"""<rootfile\b[^>]*\bfull-path=["']([^"']+)["'][^>]*>""",
|
||||
RegexOption.IGNORE_CASE
|
||||
)
|
||||
|
||||
private fun ensureDcNamespace(packageElement: Element) {
|
||||
if (packageElement.attributes().asList().none { it.key.equals("xmlns:dc", ignoreCase = true) }) {
|
||||
packageElement.attr("xmlns:dc", "http://purl.org/dc/elements/1.1/")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Element.upsertDcText(localName: String, value: String?) {
|
||||
val normalized = value?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val existing = children().filter { it.localNameEquals(localName) }
|
||||
if (normalized == null) {
|
||||
existing.forEach { it.remove() }
|
||||
return
|
||||
}
|
||||
val target = existing.firstOrNull() ?: Element(Tag.valueOf("dc:$localName"), "").also { appendChild(it) }
|
||||
target.text(normalized)
|
||||
existing.drop(1).forEach { it.remove() }
|
||||
}
|
||||
|
||||
private fun Element.upsertMetaContent(name: String, value: String?) {
|
||||
val normalized = value?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val existing = children().filter { child ->
|
||||
child.localNameEquals("meta") && child.attr("name").equals(name, ignoreCase = true)
|
||||
}
|
||||
if (normalized == null) {
|
||||
existing.forEach { it.remove() }
|
||||
return
|
||||
}
|
||||
val target = existing.firstOrNull() ?: Element(Tag.valueOf("meta"), "").also { appendChild(it) }
|
||||
target.attr("name", name)
|
||||
target.attr("content", normalized)
|
||||
existing.drop(1).forEach { it.remove() }
|
||||
}
|
||||
|
||||
private fun Element.firstChildText(localName: String): String? {
|
||||
return children()
|
||||
.firstOrNull { it.localNameEquals(localName) }
|
||||
?.text()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun Element.firstMetaContent(name: String): String? {
|
||||
return children()
|
||||
.firstOrNull { it.localNameEquals("meta") && it.attr("name").equals(name, ignoreCase = true) }
|
||||
?.attr("content")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun Element.localNameEquals(expected: String): Boolean {
|
||||
return tagName().substringAfter(':').equals(expected, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun Double.formatSeriesIndex(): String {
|
||||
return if (this % 1.0 == 0.0) {
|
||||
toInt().toString()
|
||||
} else {
|
||||
toString().trimEnd('0').trimEnd('.')
|
||||
}
|
||||
}
|
||||
|
||||
private fun ZipFile.readUtf8(entry: ZipEntry): String {
|
||||
return readBytes(entry).toString(StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun ZipFile.readBytes(entry: ZipEntry): ByteArray {
|
||||
return getInputStream(entry).use { it.readBytes() }
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putDirectoryEntry(entry: ZipEntry) {
|
||||
val copy = ZipEntry(entry.name)
|
||||
if (entry.time >= 0L) copy.time = entry.time
|
||||
copy.comment = entry.comment
|
||||
copy.extra = entry.extra
|
||||
putNextEntry(copy)
|
||||
closeEntry()
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putCopiedEntry(entry: ZipEntry, bytes: ByteArray) {
|
||||
if (entry.method == ZipEntry.STORED) {
|
||||
putStoredEntry(entry.name, bytes, entry.time)
|
||||
} else {
|
||||
putDeflatedEntry(entry.name, bytes, entry.time)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putStoredEntry(name: String, bytes: ByteArray, time: Long) {
|
||||
val crc = CRC32().apply { update(bytes) }.value
|
||||
val entry = ZipEntry(name).apply {
|
||||
method = ZipEntry.STORED
|
||||
size = bytes.size.toLong()
|
||||
compressedSize = bytes.size.toLong()
|
||||
this.crc = crc
|
||||
if (time >= 0L) this.time = time
|
||||
}
|
||||
putNextEntry(entry)
|
||||
write(bytes)
|
||||
closeEntry()
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.putDeflatedEntry(name: String, bytes: ByteArray, time: Long) {
|
||||
val entry = ZipEntry(name).apply {
|
||||
method = ZipEntry.DEFLATED
|
||||
if (time >= 0L) this.time = time
|
||||
}
|
||||
putNextEntry(entry)
|
||||
write(bytes)
|
||||
closeEntry()
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
package com.aryan.reader.shared.reader
|
||||
|
||||
import com.aryan.reader.paginatedreader.SemanticBlock
|
||||
import com.aryan.reader.paginatedreader.semanticBlockModule
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromByteArray
|
||||
import kotlinx.serialization.encodeToByteArray
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
private const val SharedEpubPaginationCacheSchemaVersion = 1
|
||||
private const val SharedEpubPaginationProcessingVersion = 6
|
||||
private const val SharedEpubPaginationPageCacheVersion = 1
|
||||
|
||||
data class SharedEpubPaginationCacheKey(
|
||||
val bookHash: String,
|
||||
val bookFingerprint: Int,
|
||||
val configHash: Int,
|
||||
val chapterVersions: List<Int>
|
||||
) {
|
||||
val cacheId: String = "${bookHash}_${configHash.toUInt().toString(16)}"
|
||||
}
|
||||
|
||||
class SharedEpubPaginationCache(
|
||||
private val cacheRoot: File = defaultCacheRoot()
|
||||
) {
|
||||
private val proto = ProtoBuf {
|
||||
serializersModule = semanticBlockModule
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val memoryCache = SharedJvmLruMemoryCache<String, List<ReaderPage>>(maxEntries = 10)
|
||||
|
||||
suspend fun load(
|
||||
book: SharedEpubBook,
|
||||
settings: ReaderSettings,
|
||||
viewport: ReaderViewportSpec,
|
||||
density: Float = 1f,
|
||||
fontScale: Float = 1f
|
||||
): List<ReaderPage>? = withContext(Dispatchers.IO) {
|
||||
val key = keyFor(book, settings, viewport, density, fontScale)
|
||||
synchronized(memoryCache) {
|
||||
memoryCache[key.cacheId]?.let { return@withContext it }
|
||||
}
|
||||
|
||||
val file = cacheFile(key)
|
||||
if (!file.isFile) return@withContext null
|
||||
|
||||
runCatching {
|
||||
val record = proto.decodeFromByteArray<CachedReaderPages>(file.readBytes())
|
||||
if (!record.matches(key)) return@runCatching null
|
||||
val pages = record.pages.mapIndexed { index, page -> page.toReaderPage(index) }
|
||||
if (pages.isEmpty() || pages.size != record.pageCount) return@runCatching null
|
||||
synchronized(memoryCache) {
|
||||
memoryCache[key.cacheId] = pages
|
||||
}
|
||||
pages
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun save(
|
||||
book: SharedEpubBook,
|
||||
settings: ReaderSettings,
|
||||
viewport: ReaderViewportSpec,
|
||||
pages: List<ReaderPage>,
|
||||
density: Float = 1f,
|
||||
fontScale: Float = 1f
|
||||
): Unit = withContext(Dispatchers.IO) {
|
||||
if (pages.isEmpty()) return@withContext
|
||||
val key = keyFor(book, settings, viewport, density, fontScale)
|
||||
val record = CachedReaderPages(
|
||||
schemaVersion = SharedEpubPaginationCacheSchemaVersion,
|
||||
processingVersion = SharedEpubPaginationProcessingVersion,
|
||||
pageCacheVersion = SharedEpubPaginationPageCacheVersion,
|
||||
bookFingerprint = key.bookFingerprint,
|
||||
configHash = key.configHash,
|
||||
chapterVersions = key.chapterVersions,
|
||||
pageCount = pages.size,
|
||||
pages = pages.map(CachedReaderPage::from)
|
||||
)
|
||||
val file = cacheFile(key)
|
||||
runCatching {
|
||||
writeAtomically(file, proto.encodeToByteArray(record))
|
||||
synchronized(memoryCache) {
|
||||
memoryCache[key.cacheId] = pages.mapIndexed { index, page -> page.copy(pageIndex = index) }
|
||||
}
|
||||
cleanupOldConfigurations(key.bookHash)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
fun keyFor(
|
||||
book: SharedEpubBook,
|
||||
settings: ReaderSettings,
|
||||
viewport: ReaderViewportSpec,
|
||||
density: Float = 1f,
|
||||
fontScale: Float = 1f
|
||||
): SharedEpubPaginationCacheKey {
|
||||
val chapterVersions = book.chapters.map(::chapterContentVersion)
|
||||
val bookFingerprint = bookFingerprint(book, chapterVersions)
|
||||
val configHash = stableHash(
|
||||
SharedEpubPaginationProcessingVersion,
|
||||
SharedEpubPaginationPageCacheVersion,
|
||||
viewport.widthPx,
|
||||
viewport.heightPx,
|
||||
density.roundCacheValue(),
|
||||
fontScale.roundCacheValue(),
|
||||
settings.fontSize,
|
||||
settings.lineSpacing.roundCacheValue(),
|
||||
settings.resolvedHorizontalMargin,
|
||||
settings.resolvedVerticalMargin,
|
||||
settings.readingMode.name,
|
||||
settings.textAlign.name,
|
||||
settings.pageWidth,
|
||||
settings.fontFamily,
|
||||
settings.paragraphSpacing.roundCacheValue(),
|
||||
settings.imageScale.roundCacheValue(),
|
||||
settings.pageSpreadMode.name,
|
||||
settings.customFontPath.orEmpty()
|
||||
)
|
||||
return SharedEpubPaginationCacheKey(
|
||||
bookHash = sha256Hex("${book.id}|${book.fileName}|$bookFingerprint").take(32),
|
||||
bookFingerprint = bookFingerprint,
|
||||
configHash = configHash,
|
||||
chapterVersions = chapterVersions
|
||||
)
|
||||
}
|
||||
|
||||
fun clearBook(book: SharedEpubBook) {
|
||||
val chapterVersions = book.chapters.map(::chapterContentVersion)
|
||||
val bookFingerprint = bookFingerprint(book, chapterVersions)
|
||||
val bookHash = sha256Hex("${book.id}|${book.fileName}|$bookFingerprint").take(32)
|
||||
File(cacheRoot, bookHash).deleteRecursively()
|
||||
synchronized(memoryCache) {
|
||||
memoryCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
cacheRoot.deleteRecursively()
|
||||
cacheRoot.mkdirs()
|
||||
synchronized(memoryCache) {
|
||||
memoryCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cacheFile(key: SharedEpubPaginationCacheKey): File {
|
||||
return File(File(cacheRoot, key.bookHash), "${key.configHash.toUInt().toString(16)}.pages.pb")
|
||||
}
|
||||
|
||||
private fun cleanupOldConfigurations(bookHash: String) {
|
||||
val bookDir = File(cacheRoot, bookHash)
|
||||
val files = bookDir.listFiles { file -> file.isFile && file.name.endsWith(".pages.pb") }
|
||||
?.sortedByDescending { it.lastModified() }
|
||||
.orEmpty()
|
||||
files.drop(3).forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun CachedReaderPages.matches(key: SharedEpubPaginationCacheKey): Boolean {
|
||||
return schemaVersion == SharedEpubPaginationCacheSchemaVersion &&
|
||||
processingVersion == SharedEpubPaginationProcessingVersion &&
|
||||
pageCacheVersion == SharedEpubPaginationPageCacheVersion &&
|
||||
bookFingerprint == key.bookFingerprint &&
|
||||
configHash == key.configHash &&
|
||||
chapterVersions == key.chapterVersions
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun defaultCacheRoot(): File {
|
||||
val overridePath = System.getProperty("reader.epub.pagination.cache.dir")
|
||||
if (!overridePath.isNullOrBlank()) return File(overridePath).apply { mkdirs() }
|
||||
return File(sharedJvmEpistemeCacheRoot(), "epub_page_cache").apply { mkdirs() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CachedReaderPages(
|
||||
@ProtoNumber(1) val schemaVersion: Int,
|
||||
@ProtoNumber(2) val processingVersion: Int,
|
||||
@ProtoNumber(3) val pageCacheVersion: Int,
|
||||
@ProtoNumber(4) val bookFingerprint: Int,
|
||||
@ProtoNumber(5) val configHash: Int,
|
||||
@ProtoNumber(6) val chapterVersions: List<Int>,
|
||||
@ProtoNumber(7) val pageCount: Int,
|
||||
@ProtoNumber(8) val pages: List<CachedReaderPage>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class CachedReaderPage(
|
||||
@ProtoNumber(1) val chapterIndex: Int,
|
||||
@ProtoNumber(2) val chapterTitle: String,
|
||||
@ProtoNumber(3) val text: String,
|
||||
@ProtoNumber(4) val startOffset: Int,
|
||||
@ProtoNumber(5) val endOffset: Int,
|
||||
@ProtoNumber(6) val semanticBlocks: List<SemanticBlock>
|
||||
) {
|
||||
fun toReaderPage(pageIndex: Int): ReaderPage {
|
||||
return ReaderPage(
|
||||
pageIndex = pageIndex,
|
||||
chapterIndex = chapterIndex,
|
||||
chapterTitle = chapterTitle,
|
||||
text = text,
|
||||
startOffset = startOffset,
|
||||
endOffset = endOffset,
|
||||
semanticBlocks = semanticBlocks
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(page: ReaderPage): CachedReaderPage {
|
||||
return CachedReaderPage(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
text = page.text,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
semanticBlocks = page.semanticBlocks
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sharedEpubChapterContentVersion(chapter: SharedEpubChapter): Int {
|
||||
return chapterContentVersion(chapter)
|
||||
}
|
||||
|
||||
private fun chapterContentVersion(chapter: SharedEpubChapter): Int {
|
||||
return stableHash(
|
||||
chapter.id,
|
||||
chapter.title,
|
||||
chapter.baseHref.orEmpty(),
|
||||
chapter.plainText.length,
|
||||
chapter.plainText.hashCode(),
|
||||
chapter.htmlContent.length,
|
||||
chapter.htmlContent.hashCode(),
|
||||
chapter.semanticBlocks.hashCode()
|
||||
)
|
||||
}
|
||||
|
||||
private fun bookFingerprint(book: SharedEpubBook, chapterVersions: List<Int>): Int {
|
||||
return stableHash(
|
||||
SharedEpubPaginationProcessingVersion,
|
||||
book.id,
|
||||
book.fileName,
|
||||
book.title,
|
||||
book.author.orEmpty(),
|
||||
book.css.hashCode(),
|
||||
chapterVersions.joinToString(",")
|
||||
)
|
||||
}
|
||||
|
||||
internal fun stableHash(vararg parts: Any?): Int {
|
||||
return parts.joinToString(separator = "\u001F") { part ->
|
||||
when (part) {
|
||||
null -> "<null>"
|
||||
is Float -> part.roundCacheValue()
|
||||
is Double -> part.toFloat().roundCacheValue()
|
||||
else -> part.toString()
|
||||
}
|
||||
}.hashCode()
|
||||
}
|
||||
|
||||
internal fun sha256Hex(value: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { byte -> "%02x".format(Locale.US, byte.toInt() and 0xFF) }
|
||||
}
|
||||
|
||||
private fun Float.roundCacheValue(): String {
|
||||
return "%.4f".format(Locale.US, this)
|
||||
}
|
||||
|
||||
private fun writeAtomically(file: File, bytes: ByteArray) {
|
||||
file.parentFile?.mkdirs()
|
||||
val parent = file.parentFile ?: file.absoluteFile.parentFile ?: File(".")
|
||||
val temp = File(parent, "${file.name}.tmp")
|
||||
temp.writeBytes(bytes)
|
||||
if (file.exists() && !file.delete()) {
|
||||
temp.delete()
|
||||
return
|
||||
}
|
||||
if (!temp.renameTo(file)) {
|
||||
file.writeBytes(bytes)
|
||||
temp.delete()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
package com.aryan.reader.shared.reader
|
||||
|
||||
import com.aryan.reader.paginatedreader.SemanticBlock
|
||||
import com.aryan.reader.paginatedreader.semanticBlockModule
|
||||
import com.aryan.reader.shared.FileType
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromByteArray
|
||||
import kotlinx.serialization.encodeToByteArray
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
import java.io.File
|
||||
|
||||
private const val SharedJvmBookLoadCacheSchemaVersion = 1
|
||||
private const val SharedJvmBookLoadCacheProcessingVersion = 3
|
||||
|
||||
data class SharedJvmBookLoadCacheKey(
|
||||
val canonicalPath: String,
|
||||
val type: FileType,
|
||||
val length: Long,
|
||||
val lastModified: Long
|
||||
) {
|
||||
val cacheId: String = sha256Hex(
|
||||
"$SharedJvmBookLoadCacheProcessingVersion|$canonicalPath|${type.name}|$length|$lastModified"
|
||||
).take(32)
|
||||
}
|
||||
|
||||
class SharedJvmBookLoadCache(
|
||||
private val cacheRoot: File = defaultCacheRoot()
|
||||
) {
|
||||
private val proto = ProtoBuf {
|
||||
serializersModule = semanticBlockModule
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun load(key: SharedJvmBookLoadCacheKey): SharedEpubBook? {
|
||||
val file = cacheFile(key)
|
||||
if (!file.isFile) return null
|
||||
return runCatching {
|
||||
val record = proto.decodeFromByteArray<CachedSharedEpubBook>(file.readBytes())
|
||||
if (!record.matches(key)) return@runCatching null
|
||||
record.toBook()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun save(key: SharedJvmBookLoadCacheKey, book: SharedEpubBook) {
|
||||
val record = CachedSharedEpubBook.from(key, book)
|
||||
runCatching {
|
||||
writeBookLoadCacheAtomically(cacheFile(key), proto.encodeToByteArray(record))
|
||||
cleanupOldEntries()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
cacheRoot.deleteRecursively()
|
||||
cacheRoot.mkdirs()
|
||||
}
|
||||
|
||||
private fun cacheFile(key: SharedJvmBookLoadCacheKey): File {
|
||||
return File(cacheRoot, "${key.cacheId}.book.pb")
|
||||
}
|
||||
|
||||
private fun cleanupOldEntries() {
|
||||
val files = cacheRoot.listFiles { file -> file.isFile && file.name.endsWith(".book.pb") }
|
||||
?.sortedByDescending { it.lastModified() }
|
||||
.orEmpty()
|
||||
files.drop(80).forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun CachedSharedEpubBook.matches(key: SharedJvmBookLoadCacheKey): Boolean {
|
||||
return schemaVersion == SharedJvmBookLoadCacheSchemaVersion &&
|
||||
processingVersion == SharedJvmBookLoadCacheProcessingVersion &&
|
||||
canonicalPath == key.canonicalPath &&
|
||||
type == key.type.name &&
|
||||
length == key.length &&
|
||||
lastModified == key.lastModified
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun defaultCacheRoot(): File {
|
||||
val overridePath = System.getProperty("reader.book.load.cache.dir")
|
||||
if (!overridePath.isNullOrBlank()) return File(overridePath).apply { mkdirs() }
|
||||
return File(sharedJvmEpistemeCacheRoot(), "book_load_cache").apply { mkdirs() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CachedSharedEpubBook(
|
||||
@ProtoNumber(1) val schemaVersion: Int,
|
||||
@ProtoNumber(2) val processingVersion: Int,
|
||||
@ProtoNumber(3) val canonicalPath: String,
|
||||
@ProtoNumber(4) val type: String,
|
||||
@ProtoNumber(5) val length: Long,
|
||||
@ProtoNumber(6) val lastModified: Long,
|
||||
@ProtoNumber(7) val id: String,
|
||||
@ProtoNumber(8) val fileName: String,
|
||||
@ProtoNumber(9) val title: String,
|
||||
@ProtoNumber(10) val author: String?,
|
||||
@ProtoNumber(11) val css: Map<String, String>,
|
||||
@ProtoNumber(12) val chapters: List<CachedSharedEpubChapter>,
|
||||
@ProtoNumber(13) val tableOfContents: List<CachedSharedEpubTocEntry> = emptyList()
|
||||
) {
|
||||
fun toBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = id,
|
||||
fileName = fileName,
|
||||
title = title,
|
||||
author = author,
|
||||
css = css,
|
||||
chapters = chapters.map { it.toChapter() },
|
||||
tableOfContents = tableOfContents.map { it.toEntry() }
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(key: SharedJvmBookLoadCacheKey, book: SharedEpubBook): CachedSharedEpubBook {
|
||||
return CachedSharedEpubBook(
|
||||
schemaVersion = SharedJvmBookLoadCacheSchemaVersion,
|
||||
processingVersion = SharedJvmBookLoadCacheProcessingVersion,
|
||||
canonicalPath = key.canonicalPath,
|
||||
type = key.type.name,
|
||||
length = key.length,
|
||||
lastModified = key.lastModified,
|
||||
id = book.id,
|
||||
fileName = book.fileName,
|
||||
title = book.title,
|
||||
author = book.author,
|
||||
css = book.css,
|
||||
chapters = book.chapters.map(CachedSharedEpubChapter::from),
|
||||
tableOfContents = book.tableOfContents.map(CachedSharedEpubTocEntry::from)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CachedSharedEpubTocEntry(
|
||||
@ProtoNumber(1) val label: String,
|
||||
@ProtoNumber(2) val href: String,
|
||||
@ProtoNumber(3) val fragmentId: String?,
|
||||
@ProtoNumber(4) val depth: Int
|
||||
) {
|
||||
fun toEntry(): SharedEpubTocEntry {
|
||||
return SharedEpubTocEntry(
|
||||
label = label,
|
||||
href = href,
|
||||
fragmentId = fragmentId,
|
||||
depth = depth
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(entry: SharedEpubTocEntry): CachedSharedEpubTocEntry {
|
||||
return CachedSharedEpubTocEntry(
|
||||
label = entry.label,
|
||||
href = entry.href,
|
||||
fragmentId = entry.fragmentId,
|
||||
depth = entry.depth
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CachedSharedEpubChapter(
|
||||
@ProtoNumber(1) val id: String,
|
||||
@ProtoNumber(2) val title: String,
|
||||
@ProtoNumber(3) val plainText: String,
|
||||
@ProtoNumber(4) val semanticBlocks: List<SemanticBlock>,
|
||||
@ProtoNumber(5) val htmlContent: String,
|
||||
@ProtoNumber(6) val baseHref: String?
|
||||
) {
|
||||
fun toChapter(): SharedEpubChapter {
|
||||
return SharedEpubChapter(
|
||||
id = id,
|
||||
title = title,
|
||||
plainText = plainText,
|
||||
semanticBlocks = semanticBlocks,
|
||||
htmlContent = htmlContent,
|
||||
baseHref = baseHref
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(chapter: SharedEpubChapter): CachedSharedEpubChapter {
|
||||
return CachedSharedEpubChapter(
|
||||
id = chapter.id,
|
||||
title = chapter.title,
|
||||
plainText = chapter.plainText,
|
||||
semanticBlocks = chapter.semanticBlocks,
|
||||
htmlContent = chapter.htmlContent,
|
||||
baseHref = chapter.baseHref
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeBookLoadCacheAtomically(file: File, bytes: ByteArray) {
|
||||
file.parentFile?.mkdirs()
|
||||
val parent = file.parentFile ?: file.absoluteFile.parentFile ?: File(".")
|
||||
val temp = File(parent, "${file.name}.tmp")
|
||||
temp.writeBytes(bytes)
|
||||
if (file.exists() && !file.delete()) {
|
||||
temp.delete()
|
||||
return
|
||||
}
|
||||
if (!temp.renameTo(file)) {
|
||||
file.writeBytes(bytes)
|
||||
temp.delete()
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,17 @@ import androidx.compose.ui.unit.Constraints
|
|||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.paginatedreader.CssParser
|
||||
import com.aryan.reader.paginatedreader.HtmlResourceResolver
|
||||
import com.aryan.reader.paginatedreader.OptimizedCssRules
|
||||
import com.aryan.reader.paginatedreader.SemanticBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticFlexContainer
|
||||
import com.aryan.reader.paginatedreader.SemanticImage
|
||||
import com.aryan.reader.paginatedreader.SemanticList
|
||||
import com.aryan.reader.paginatedreader.SemanticMath
|
||||
import com.aryan.reader.paginatedreader.SemanticSpacer
|
||||
import com.aryan.reader.paginatedreader.SemanticTable
|
||||
import com.aryan.reader.paginatedreader.SemanticTextBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
|
||||
import com.aryan.reader.paginatedreader.UserAgentStylesheet
|
||||
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
|
||||
import com.aryan.reader.shared.FileType
|
||||
|
|
@ -17,24 +27,17 @@ import org.jsoup.parser.Parser
|
|||
import java.io.ByteArrayOutputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.Charset
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipFile
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
object SharedJvmBookLoader {
|
||||
private data class LoaderCacheKey(
|
||||
val canonicalPath: String,
|
||||
val type: FileType,
|
||||
val length: Long,
|
||||
val lastModified: Long
|
||||
)
|
||||
|
||||
private val loadedBookCache = object : LinkedHashMap<LoaderCacheKey, SharedEpubBook>(12, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<LoaderCacheKey, SharedEpubBook>?): Boolean {
|
||||
return size > 12
|
||||
}
|
||||
}
|
||||
private val persistentBookCache = SharedJvmBookLoadCache()
|
||||
private val loadedBookCache = SharedJvmLruMemoryCache<SharedJvmBookLoadCacheKey, SharedEpubBook>(maxEntries = 12)
|
||||
|
||||
fun load(
|
||||
file: File,
|
||||
|
|
@ -43,31 +46,44 @@ object SharedJvmBookLoader {
|
|||
authorOverride: String? = null
|
||||
): SharedEpubBook {
|
||||
require(file.isFile) { "Missing reader file: ${file.absolutePath}" }
|
||||
val key = LoaderCacheKey(
|
||||
val key = SharedJvmBookLoadCacheKey(
|
||||
canonicalPath = file.canonicalPath,
|
||||
type = type,
|
||||
length = file.length(),
|
||||
lastModified = file.lastModified()
|
||||
)
|
||||
val loaded = synchronized(loadedBookCache) {
|
||||
loadedBookCache.getOrPut(key) {
|
||||
when (type) {
|
||||
FileType.EPUB -> loadEpub(file)
|
||||
FileType.HTML -> loadHtml(file)
|
||||
FileType.TXT,
|
||||
FileType.MD -> loadPlainText(file)
|
||||
FileType.FB2 -> loadFb2(file)
|
||||
FileType.DOCX -> loadDocx(file)
|
||||
FileType.ODT -> loadOdt(file, isFlat = false)
|
||||
FileType.FODT -> loadOdt(file, isFlat = true)
|
||||
FileType.MOBI -> loadMobi(file)
|
||||
else -> error("${type.name} is not supported by the shared JVM reader loader.")
|
||||
}
|
||||
}
|
||||
synchronized(loadedBookCache) {
|
||||
loadedBookCache[key]?.let { return it.withOverrides(titleOverride = titleOverride, authorOverride = authorOverride) }
|
||||
}
|
||||
|
||||
val loaded = persistentBookCache.load(key) ?: when (type) {
|
||||
FileType.EPUB -> loadEpub(file)
|
||||
FileType.HTML -> loadHtml(file)
|
||||
FileType.TXT,
|
||||
FileType.MD -> loadPlainText(file)
|
||||
FileType.FB2 -> loadFb2(file)
|
||||
FileType.DOCX -> loadDocx(file)
|
||||
FileType.ODT -> loadOdt(file, isFlat = false)
|
||||
FileType.FODT -> loadOdt(file, isFlat = true)
|
||||
FileType.MOBI -> loadMobi(file)
|
||||
else -> error("${type.name} is not supported by the shared JVM reader loader.")
|
||||
}.also { parsed ->
|
||||
persistentBookCache.save(key, parsed)
|
||||
}
|
||||
|
||||
synchronized(loadedBookCache) {
|
||||
loadedBookCache[key] = loaded
|
||||
}
|
||||
return loaded.withOverrides(titleOverride = titleOverride, authorOverride = authorOverride)
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
persistentBookCache.clear()
|
||||
synchronized(loadedBookCache) {
|
||||
loadedBookCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadEpub(file: File): SharedEpubBook {
|
||||
ZipFile(file).use { zip ->
|
||||
val container = zip.readTextOrNull("META-INF/container.xml")
|
||||
|
|
@ -88,6 +104,7 @@ object SharedJvmBookLoader {
|
|||
val manifest = parseEpubManifest(opf)
|
||||
val cssByPath = loadEpubCss(zip, manifest, basePath)
|
||||
val cssRules = parseCssRules(cssByPath)
|
||||
val tableOfContents = parseEpubTableOfContents(zip, manifest, basePath)
|
||||
val spine = Regex("<itemref[^>]*idref=[\"']([^\"']+)[\"'][^>]*/?>")
|
||||
.findAll(opf)
|
||||
.mapNotNull { match -> manifest[match.groupValues[1]] }
|
||||
|
|
@ -102,21 +119,18 @@ object SharedJvmBookLoader {
|
|||
val html = zip.readTextOrNull(path) ?: return@mapIndexedNotNull null
|
||||
val resourceReadyHtml = html.sanitizeReaderHtml().withEmbeddedResources(zip, path)
|
||||
val text = html.htmlToText()
|
||||
if (text.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
chapterFromHtml(
|
||||
id = "chapter_$index",
|
||||
title = html.tagText("h1")
|
||||
.ifBlank { html.tagText("h2") }
|
||||
.ifBlank { html.tagText("title") }
|
||||
.ifBlank { "Chapter ${index + 1}" },
|
||||
html = resourceReadyHtml,
|
||||
plainText = text,
|
||||
baseHref = path,
|
||||
cssRules = cssRules
|
||||
)
|
||||
}
|
||||
val chapter = chapterFromHtml(
|
||||
id = "chapter_$index",
|
||||
title = html.tagText("h1")
|
||||
.ifBlank { html.tagText("h2") }
|
||||
.ifBlank { html.tagText("title") }
|
||||
.ifBlank { "Chapter ${index + 1}" },
|
||||
html = resourceReadyHtml,
|
||||
plainText = text,
|
||||
baseHref = path,
|
||||
cssRules = cssRules
|
||||
)
|
||||
chapter.takeIf { text.isNotBlank() || it.semanticBlocks.isNotEmpty() }
|
||||
}
|
||||
|
||||
return SharedEpubBook(
|
||||
|
|
@ -125,6 +139,7 @@ object SharedJvmBookLoader {
|
|||
title = title,
|
||||
author = author,
|
||||
css = cssByPath,
|
||||
tableOfContents = tableOfContents,
|
||||
chapters = chapters.ifEmpty {
|
||||
listOf(
|
||||
SharedEpubChapter(
|
||||
|
|
@ -339,13 +354,14 @@ object SharedJvmBookLoader {
|
|||
extractionBasePath = "",
|
||||
density = Density(1f),
|
||||
fontFamilyMap = emptyMap(),
|
||||
constraints = Constraints(maxWidth = 980, maxHeight = 720)
|
||||
constraints = Constraints(maxWidth = 980, maxHeight = 720),
|
||||
resourceResolver = SharedJvmHtmlResourceResolver
|
||||
)
|
||||
}.getOrDefault(emptyList())
|
||||
return SharedEpubChapter(
|
||||
id = id,
|
||||
title = title,
|
||||
plainText = plainText,
|
||||
plainText = plainText.takeUnlessBlank() ?: semanticBlocks.semanticFallbackText().ifBlank { title },
|
||||
semanticBlocks = semanticBlocks,
|
||||
htmlContent = html.extractBodyOrSelf(),
|
||||
baseHref = baseHref
|
||||
|
|
@ -1065,6 +1081,62 @@ object SharedJvmBookLoader {
|
|||
}.toMap()
|
||||
}
|
||||
|
||||
private fun parseEpubTableOfContents(
|
||||
zip: ZipFile,
|
||||
manifest: Map<String, String>,
|
||||
basePath: String
|
||||
): List<SharedEpubTocEntry> {
|
||||
val manifestNcxHref = manifest.values.firstOrNull { it.endsWith(".ncx", ignoreCase = true) }
|
||||
val ncxPath = manifestNcxHref
|
||||
?.let { normalizeZipPath(basePath + it) }
|
||||
?: zip.entries().asSequence()
|
||||
.map { it.name }
|
||||
.firstOrNull { it.endsWith(".ncx", ignoreCase = true) }
|
||||
?: return emptyList()
|
||||
val document = zip.readBytesOrNull(ncxPath)?.let(::xmlDocument) ?: return emptyList()
|
||||
val navMap = document.allElementsByLocalTag("navmap").firstOrNull() ?: return emptyList()
|
||||
val ncxBasePath = ncxPath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
val entries = mutableListOf<SharedEpubTocEntry>()
|
||||
|
||||
fun visit(parent: Element, depth: Int) {
|
||||
parent.childrenByLocalTag("navpoint").forEach { navPoint ->
|
||||
val label = navPoint.childrenByLocalTag("navlabel")
|
||||
.firstOrNull()
|
||||
?.allElementsByLocalTag("text")
|
||||
?.firstOrNull()
|
||||
?.text()
|
||||
?.normalizeReaderWhitespace()
|
||||
.takeUnlessBlank()
|
||||
?: "Section ${entries.size + 1}"
|
||||
val src = navPoint.childrenByLocalTag("content")
|
||||
.firstOrNull()
|
||||
?.xmlAttr("src")
|
||||
.orEmpty()
|
||||
.trim()
|
||||
val href = src.substringBefore('#').substringBefore('?').percentDecodedOrSelf()
|
||||
val fragmentId = src.substringAfter('#', missingDelimiterValue = "")
|
||||
.substringBefore('?')
|
||||
.takeUnlessBlank()
|
||||
?.percentDecodedOrSelf()
|
||||
if (href.isNotBlank()) {
|
||||
val absoluteHref = normalizeZipPath(
|
||||
if (ncxBasePath.isBlank()) href else "$ncxBasePath/$href"
|
||||
)
|
||||
entries += SharedEpubTocEntry(
|
||||
label = label,
|
||||
href = absoluteHref,
|
||||
fragmentId = fragmentId,
|
||||
depth = depth.coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
visit(navPoint, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
visit(navMap, depth = 0)
|
||||
return entries
|
||||
}
|
||||
|
||||
private fun loadEpubCss(zip: ZipFile, manifest: Map<String, String>, basePath: String): Map<String, String> {
|
||||
return manifest.values
|
||||
.filter { it.endsWith(".css", ignoreCase = true) }
|
||||
|
|
@ -1178,6 +1250,10 @@ object SharedJvmBookLoader {
|
|||
return parts.joinToString("/")
|
||||
}
|
||||
|
||||
private fun String.percentDecodedOrSelf(): String {
|
||||
return runCatching { URLDecoder.decode(this, Charsets.UTF_8.name()) }.getOrDefault(this)
|
||||
}
|
||||
|
||||
private fun String.withEmbeddedResources(zip: ZipFile, chapterPath: String): String {
|
||||
return replace(Regex("""(?i)\b(src|href)=["']([^"']+)["']""")) { match ->
|
||||
val attr = match.groupValues[1]
|
||||
|
|
@ -1290,6 +1366,82 @@ object SharedJvmBookLoader {
|
|||
)
|
||||
}
|
||||
|
||||
private object SharedJvmHtmlResourceResolver : HtmlResourceResolver {
|
||||
override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? {
|
||||
val raw = src.trim().takeIf { it.isNotBlank() } ?: return null
|
||||
if (raw.startsWith("data:", ignoreCase = true)) return raw
|
||||
if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) return raw
|
||||
if (raw.startsWith("file:", ignoreCase = true)) return raw
|
||||
|
||||
val clean = raw.substringBefore('#').substringBefore('?').takeIf { it.isNotBlank() } ?: return null
|
||||
val decoded = runCatching { URLDecoder.decode(clean, Charsets.UTF_8.name()) }.getOrDefault(clean)
|
||||
val direct = File(decoded)
|
||||
if (direct.isAbsolute && direct.isFile) return direct.toURI().toString()
|
||||
|
||||
val chapterFile = chapterAbsPath.trim().takeIf { it.isNotBlank() }?.let(::File)
|
||||
val chapterRelative = chapterFile?.parentFile?.let { File(it, decoded) }
|
||||
if (chapterRelative?.isFile == true) return chapterRelative.toURI().toString()
|
||||
|
||||
val extractionRelative = extractionBasePath.trim().takeIf { it.isNotBlank() }?.let { File(it, decoded) }
|
||||
if (extractionRelative?.isFile == true) return extractionRelative.toURI().toString()
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun readText(path: String): String? {
|
||||
dataUriBytes(path)?.let { bytes -> return bytes.toString(Charsets.UTF_8) }
|
||||
return path.toFileOrNull()?.takeIf { it.isFile }?.readText()
|
||||
}
|
||||
|
||||
override fun imageDimensions(path: String): Pair<Float?, Float?>? {
|
||||
val image = runCatching {
|
||||
dataUriBytes(path)?.let { bytes ->
|
||||
ImageIO.read(ByteArrayInputStream(bytes))
|
||||
} ?: path.toFileOrNull()?.takeIf { it.isFile }?.let { file -> ImageIO.read(file) }
|
||||
}.getOrNull() ?: return null
|
||||
return image.width.toFloat() to image.height.toFloat()
|
||||
}
|
||||
|
||||
private fun dataUriBytes(value: String): ByteArray? {
|
||||
if (!value.startsWith("data:", ignoreCase = true)) return null
|
||||
val commaIndex = value.indexOf(',')
|
||||
if (commaIndex < 0) return null
|
||||
val metadata = value.substring(0, commaIndex)
|
||||
val payload = value.substring(commaIndex + 1)
|
||||
return if (";base64" in metadata.lowercase()) {
|
||||
runCatching { Base64.getDecoder().decode(payload) }.getOrNull()
|
||||
} else {
|
||||
runCatching { URLDecoder.decode(payload, Charsets.UTF_8.name()).toByteArray(Charsets.UTF_8) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toFileOrNull(): File? {
|
||||
return when {
|
||||
startsWith("file:", ignoreCase = true) -> runCatching { File(URI(this)) }.getOrNull()
|
||||
else -> File(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SemanticBlock>.semanticFallbackText(): String {
|
||||
return flatMap { it.semanticTextParts() }
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString("\n\n")
|
||||
}
|
||||
|
||||
private fun SemanticBlock.semanticTextParts(): List<String> {
|
||||
return when (this) {
|
||||
is SemanticTextBlock -> listOf(text)
|
||||
is SemanticList -> items.flatMap { it.semanticTextParts() }
|
||||
is SemanticTable -> rows.flatMap { row -> row.flatMap { cell -> cell.content.flatMap { it.semanticTextParts() } } }
|
||||
is SemanticFlexContainer -> children.flatMap { it.semanticTextParts() }
|
||||
is SemanticWrappingBlock -> floatedImage.semanticTextParts() + paragraphsToWrap.flatMap { it.semanticTextParts() }
|
||||
is SemanticImage -> listOf(altText.orEmpty())
|
||||
is SemanticMath -> listOf(altText.orEmpty())
|
||||
is SemanticSpacer -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.u16(offset: Int): Int {
|
||||
if (offset + 2 > size) return 0
|
||||
return ((this[offset].toInt() and 0xFF) shl 8) or (this[offset + 1].toInt() and 0xFF)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
internal class SharedJvmLruMemoryCache<K, V>(
|
||||
private val maxEntries: Int
|
||||
) {
|
||||
private val entries = LinkedHashMap<K, V>(maxEntries, 0.75f, true)
|
||||
|
||||
operator fun get(key: K): V? {
|
||||
return entries[key]
|
||||
}
|
||||
|
||||
operator fun set(key: K, value: V) {
|
||||
entries[key] = value
|
||||
trimToMaxEntries()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
entries.clear()
|
||||
}
|
||||
|
||||
private fun trimToMaxEntries() {
|
||||
while (entries.size > maxEntries) {
|
||||
val iterator = entries.entries.iterator()
|
||||
if (!iterator.hasNext()) return
|
||||
iterator.next()
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
internal fun sharedJvmEpistemeCacheRoot(
|
||||
env: (String) -> String? = System::getenv,
|
||||
userHome: String = System.getProperty("user.home").orEmpty(),
|
||||
osName: String = System.getProperty("os.name").orEmpty()
|
||||
): File {
|
||||
val normalizedOs = osName.trim().lowercase(Locale.ROOT)
|
||||
return when {
|
||||
normalizedOs.startsWith("windows") -> {
|
||||
val baseDir = env("APPDATA")?.takeIf { it.isNotBlank() }
|
||||
?: File(userHome, "AppData/Roaming").absolutePath
|
||||
File(baseDir, "Episteme")
|
||||
}
|
||||
normalizedOs == "linux" || normalizedOs.contains("linux") -> {
|
||||
val baseDir = env("XDG_CACHE_HOME")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
?.takeIf { it.isAbsolute }
|
||||
?: File(userHome, ".cache")
|
||||
File(baseDir, "episteme")
|
||||
}
|
||||
normalizedOs.startsWith("mac") || normalizedOs.contains("darwin") -> {
|
||||
File(userHome, "Library/Caches/Episteme")
|
||||
}
|
||||
else -> File(userHome, ".episteme/cache")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue