* Centralize library management logic and introduce support for plain text and HTML formats

* Centralize library management logic and introduce support for plain text and HTML formats

* Expand unit test coverage for library state management, UI models, and MainViewModel features.

* Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence

* Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges.

* Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin

* Add comprehensive unit tests

* Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic.

* Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version

* Folder import support for desktop app

* Introduce Smart Shelves with rule-based filtering in desktop version

* Implement shared EPUB annotation serialization and highlight rendering

* Centralize file type capabilities and platform-specific support logic

* Refactor reader state management to use a central reducer

* Implement customizable reader toolbar and advanced formatting settings in shared

* Implement locator-based navigation and customizable highlight palette for desktop app

* Enhance reader customization and expand search functionality in desktop app

* Redesign reader settings and tools into a tabbed control panel in desktop app

* Enhance reader navigation and highlight precision in desktop app

* Implement bidirectional position synchronization and dynamic highlights in the desktop reader

* Implement shared state management and enhanced search for the PDF reader in desktop app

* Add vertical scroll support to the desktop PDF reader

* Implement ink, text, and eraser annotation support in desktop PDF viewer

* Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app

* Implement link handling and navigation for PDF and EPUB readers in desktop app

* Implement PDF jump history for navigation in desktop app

* Enhance PDF ink rendering and annotation capabilities in desktop app

* Implement advanced PDF text annotations with inline editing and rich styling in desktop app

* Add move handle and movement logic for PDF text annotations in desktop app

* Implement local folder synchronization and metadata sidecar support in desktop app

* Implement book metadata extraction and drag-and-drop import for Desktop

* Implement dynamic and custom app theme management for desktop

* Introduce canonical PDF annotation codec and support for multi-segment highlights

* Implement rich text editing and pagination support for the PDF reader in desktop app

* Improve PDF rich text pagination, synchronization, and observability in desktop

* Hide trailing structural page breaks in rich text editor

* Implement a unified JVM book loader and expand supported formats on Desktop

* Add comic archive support for Desktop and enhance MOBI parsing

* Implement shared OPDS catalog support and UI for Android and Desktop

* Improve native WebView lifecycle and surface transition management on Desktop

* Enable Compose Swing interop blending and simplify Desktop WebView management

* Integrate BYOK AI features and Cloud TTS for desktop

* Enhance Desktop TTS with streaming audio and improved secure storage for AI key

* Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app

* Implement custom font management and utility screens in desktop app

* Implement PDFium-based PDF annotation export

* Remove PdfBox dependency and standardize PDF export via Pdfium

* Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app

* Implement reader themes and custom texture support in desktop app

* Redesign non-reader UI with responsive navigation and enhanced library management in desktop app

* Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app

* Exclude manual-only files from automated sync and import

* Implement customizable Text-to-Speech (TTS) word replacements

* Optimize reader performance with persistent layout caching and decoupled theme rendering

* Improve position restoration during reader reconfiguration in epub pagination

* Use independent thickness for eraser tool and stylus override
This commit is contained in:
Aryan 2026-05-10 10:07:37 +05:30 committed by GitHub
parent 88c7fa7b5c
commit 8366d76dcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 53372 additions and 4702 deletions

View file

@ -178,7 +178,8 @@ object CssParser {
constraints: Constraints,
isDarkTheme: Boolean,
themeBackgroundColor: Color = Color.Unspecified,
themeTextColor: Color = Color.Unspecified
themeTextColor: Color = Color.Unspecified,
adaptThemeColors: Boolean = true
): OptimizedCssParseResult {
val byTag = mutableMapOf<String, MutableList<CssRule>>()
val byClass = mutableMapOf<String, MutableList<CssRule>>()
@ -193,7 +194,7 @@ object CssParser {
val mediaQueryRegex = Regex("@media[^{]+\\{((?>[^{}]+|\\{[^{}]*\\})*)\\}")
mediaQueryRegex.findAll(cleanedCss).forEach { match ->
val condition = match.groups[0]?.value?.trim() ?: ""
if (isDarkTheme && condition.contains("prefers-color-scheme: dark")) {
if (adaptThemeColors && isDarkTheme && condition.contains("prefers-color-scheme: dark")) {
val darkCss = match.groups[1]?.value ?: ""
cleanedCss += "\n$darkCss"
}
@ -231,12 +232,26 @@ object CssParser {
}
val specificity = calculateSpecificity(originalSelector)
val normalStyle = parseProperties(
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = false,
isDarkTheme, themeBackgroundColor, themeTextColor
properties = propertiesGroup,
baseFontSizeSp = baseFontSizeSp,
density = density,
constraints = constraints,
onlyImportant = false,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor,
adaptThemeColors = adaptThemeColors
)
val importantStyle = parseProperties(
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = true,
isDarkTheme, themeBackgroundColor, themeTextColor
properties = propertiesGroup,
baseFontSizeSp = baseFontSizeSp,
density = density,
constraints = constraints,
onlyImportant = true,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor,
adaptThemeColors = adaptThemeColors
)
fun addRule(style: CssStyle, spec: Int) {
@ -376,7 +391,8 @@ object CssParser {
onlyImportant: Boolean,
isDarkTheme: Boolean,
themeBackgroundColor: Color = Color.Unspecified,
themeTextColor: Color = Color.Unspecified
themeTextColor: Color = Color.Unspecified,
adaptThemeColors: Boolean = true
): CssStyle {
var spanStyle = SpanStyle()
var paragraphStyle = ParagraphStyle()
@ -451,6 +467,14 @@ object CssParser {
var borderBottomRightRadius: Dp = 0.dp
var borderBottomLeftRadius: Dp = 0.dp
fun maybeAdaptColor(color: Color, isBackground: Boolean): Color {
return if (adaptThemeColors) {
this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground, themeBackgroundColor, themeTextColor)
} else {
color
}
}
splitDeclarations(properties).filter { it.isNotBlank() }.forEach { prop ->
val parts = prop.split(':', limit = 2).map { it.trim() }
if (parts.size == 2) {
@ -473,7 +497,7 @@ object CssParser {
styleStr: String?
) {
val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp
val parsedColor = colorStr?.let { parseColor(it) }?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) }
val parsedColor = colorStr?.let { parseColor(it) }?.let { maybeAdaptColor(it, isBackground = false) }
val isExplicitWidth = widthStr != null
@ -528,7 +552,7 @@ object CssParser {
}
"color" -> {
parseColor(value)?.let {
spanStyle = spanStyle.copy(color = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor))
spanStyle = spanStyle.copy(color = maybeAdaptColor(it, isBackground = false))
}
}
"text-align" -> {
@ -585,7 +609,7 @@ object CssParser {
val styles = listOf("solid", "double", "dotted", "dashed", "wavy")
parts.firstOrNull { it in styles }?.let { textDecorationStyle = it }
parts.firstNotNullOfOrNull { parseColor(it) }?.let { color ->
textDecorationColor = this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
textDecorationColor = maybeAdaptColor(color, isBackground = false)
}
}
"word-spacing" -> {
@ -601,7 +625,7 @@ object CssParser {
}
"text-decoration-color" -> {
parseColor(value)?.let {
textDecorationColor = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
textDecorationColor = maybeAdaptColor(it, isBackground = false)
}
}
"text-underline-offset" -> {
@ -661,7 +685,7 @@ object CssParser {
"background-color" -> {
val originalColor = parseColor(value) ?: Color.Unspecified
backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true, themeBackgroundColor, themeTextColor)
backgroundColor = maybeAdaptColor(originalColor, isBackground = true)
}
// Border Properties
@ -801,7 +825,7 @@ object CssParser {
textEmphasisStyleString = value
}
"text-emphasis-color", "-epub-text-emphasis-color" -> {
textEmphasisColor = parseColor(value)?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) }
textEmphasisColor = parseColor(value)?.let { maybeAdaptColor(it, isBackground = false) }
}
"text-emphasis-position", "-epub-text-emphasis-position" -> {
if (value in listOf("over", "under")) {
@ -859,7 +883,7 @@ object CssParser {
val finalStyle = style ?: "none"
val finalColor = color ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black
val adaptedColor = this@CssParser.adaptColorForTheme(finalColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
val adaptedColor = maybeAdaptColor(finalColor, isBackground = false)
if (finalWidth > 0.dp && finalStyle != "none" && finalStyle != "hidden") {
return BorderStyle(finalWidth, adaptedColor, finalStyle)

View file

@ -1,5 +1,9 @@
package com.aryan.reader.shared
import androidx.compose.ui.graphics.Color
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.ReaderSearchOptions
sealed interface LibraryAction {
data class SearchChanged(val query: String) : LibraryAction
data class SortChanged(val sortOrder: SortOrder) : LibraryAction
@ -16,15 +20,36 @@ sealed interface ReaderAction {
data object NextPage : ReaderAction
data object PreviousPage : ReaderAction
data class GoToPage(val pageIndex: Int) : ReaderAction
data class GoToPageNumber(val pageNumber: Int) : ReaderAction
data class GoToProgress(val progress: Float) : ReaderAction
data class GoToChapter(val chapterIndex: Int) : ReaderAction
data class GoToLocator(val locator: ReaderLocator) : ReaderAction
data class VisiblePageChanged(val pageIndex: Int, val locator: ReaderLocator? = null) : ReaderAction
data class GoToSearchResult(val resultIndex: Int) : ReaderAction
data class SearchChanged(val query: String) : ReaderAction
data object SearchOpened : ReaderAction
data object SearchClosed : ReaderAction
data object SearchResultsPanelToggled : ReaderAction
data class SearchOptionsChanged(val options: ReaderSearchOptions) : ReaderAction
data object NextSearchResult : ReaderAction
data object PreviousSearchResult : ReaderAction
data object ToggleBookmark : ReaderAction
data class ToggleBookmarkAtLocator(
val locator: ReaderLocator,
val title: String? = null,
val preview: String? = null
) : ReaderAction
data class SettingsChanged(val settings: ReaderSettings) : ReaderAction
data class RenderModeChanged(val renderMode: RenderMode) : ReaderAction
data class ThemeChanged(val theme: ReaderTheme) : ReaderAction
data class FormatChanged(val settings: FormatSettings) : ReaderAction
data class HighlightCreated(val highlight: UserHighlight) : ReaderAction
data class HighlightUpdated(
val highlightId: String,
val color: HighlightColor? = null,
val note: String? = null
) : ReaderAction
data class HighlightDeleted(val highlightId: String) : ReaderAction
}
sealed interface AppAction {
@ -33,6 +58,25 @@ sealed interface AppAction {
data class NavigationRequested(val event: NavigationEvent) : AppAction
data class AppThemeChanged(val mode: AppThemeMode) : AppAction
data class AppContrastChanged(val option: AppContrastOption) : AppAction
data class AppTextDimFactorLightChanged(val factor: Float) : AppAction
data class AppTextDimFactorDarkChanged(val factor: Float) : AppAction
data class AppSeedColorChanged(val color: Color?) : AppAction
data class CustomAppThemeAdded(val theme: CustomAppTheme) : AppAction
data class CustomAppThemeDeleted(val themeId: String) : AppAction
data class SyncEnabledChanged(val enabled: Boolean) : AppAction
data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction
data class TabsEnabledChanged(val enabled: Boolean) : AppAction
data class BookTabOpened(val bookId: String) : AppAction
data class BookTabClosed(val bookId: String) : AppAction
data object AllTabsClosed : AppAction
data class HomePinToggled(val bookId: String) : AppAction
data class LibraryPinToggled(val bookId: String) : 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 ReaderTtsReplacementPreferencesChanged(
val preferences: ReaderTtsReplacementPreferences,
) : AppAction
}

View file

@ -118,5 +118,8 @@ data class SharedReaderScreenState(
val appSeedColor: Color? = null,
val customAppThemes: List<CustomAppTheme> = emptyList(),
val allTags: List<Tag> = emptyList(),
val showTagSelectionDialogFor: Set<String> = emptySet()
val showTagSelectionDialogFor: Set<String> = emptySet(),
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
)

View file

@ -0,0 +1,12 @@
package com.aryan.reader.shared
data class CustomFontItem(
val id: String,
val displayName: String,
val fileName: String,
val fileExtension: String,
val path: String,
val timestamp: Long,
val isDeleted: Boolean = false
)

View file

@ -0,0 +1,180 @@
package com.aryan.reader.shared
enum class ReaderPlatform {
ANDROID,
DESKTOP
}
enum class ReaderFeatureSurface {
PDF_VIEWER,
EPUB_READER,
TEXT_READER
}
data class FileTypeCapability(
val type: FileType,
val displayName: String,
val extensions: Set<String>,
val androidSurface: ReaderFeatureSurface?,
val desktopSurface: ReaderFeatureSurface?,
val syncEligible: Boolean = true
) {
val isReadableOnAndroid: Boolean get() = androidSurface != null
val isReadableOnDesktop: Boolean get() = desktopSurface != null
fun surfaceFor(platform: ReaderPlatform): ReaderFeatureSurface? {
return when (platform) {
ReaderPlatform.ANDROID -> androidSurface
ReaderPlatform.DESKTOP -> desktopSurface
}
}
}
object SharedFileCapabilities {
val all: List<FileTypeCapability> = listOf(
FileTypeCapability(
type = FileType.EPUB,
displayName = "EPUB",
extensions = setOf("epub"),
androidSurface = ReaderFeatureSurface.EPUB_READER,
desktopSurface = ReaderFeatureSurface.EPUB_READER
),
FileTypeCapability(
type = FileType.PDF,
displayName = "PDF",
extensions = setOf("pdf"),
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
),
FileTypeCapability(
type = FileType.TXT,
displayName = "TXT",
extensions = setOf("txt"),
androidSurface = ReaderFeatureSurface.EPUB_READER,
desktopSurface = ReaderFeatureSurface.TEXT_READER
),
FileTypeCapability(
type = FileType.MD,
displayName = "Markdown",
extensions = setOf("md", "markdown"),
androidSurface = ReaderFeatureSurface.EPUB_READER,
desktopSurface = ReaderFeatureSurface.TEXT_READER
),
FileTypeCapability(
type = FileType.HTML,
displayName = "HTML",
extensions = setOf("html", "htm", "xhtml"),
androidSurface = ReaderFeatureSurface.EPUB_READER,
desktopSurface = ReaderFeatureSurface.TEXT_READER
),
FileTypeCapability(
type = FileType.MOBI,
displayName = "MOBI",
extensions = setOf("mobi", "azw", "azw3", "prc"),
androidSurface = ReaderFeatureSurface.EPUB_READER,
desktopSurface = ReaderFeatureSurface.TEXT_READER
),
FileTypeCapability(
type = FileType.FB2,
displayName = "FB2",
extensions = setOf("fb2"),
androidSurface = ReaderFeatureSurface.EPUB_READER,
desktopSurface = ReaderFeatureSurface.TEXT_READER
),
FileTypeCapability(
type = FileType.CBZ,
displayName = "CBZ",
extensions = setOf("cbz"),
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
),
FileTypeCapability(
type = FileType.CBR,
displayName = "CBR",
extensions = setOf("cbr"),
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
),
FileTypeCapability(
type = FileType.CB7,
displayName = "CB7",
extensions = setOf("cb7"),
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
),
FileTypeCapability(
type = FileType.DOCX,
displayName = "DOCX",
extensions = setOf("docx"),
androidSurface = ReaderFeatureSurface.EPUB_READER,
desktopSurface = ReaderFeatureSurface.TEXT_READER
),
FileTypeCapability(
type = FileType.ODT,
displayName = "ODT",
extensions = setOf("odt"),
androidSurface = ReaderFeatureSurface.EPUB_READER,
desktopSurface = ReaderFeatureSurface.TEXT_READER
),
FileTypeCapability(
type = FileType.FODT,
displayName = "FODT",
extensions = setOf("fodt"),
androidSurface = ReaderFeatureSurface.EPUB_READER,
desktopSurface = ReaderFeatureSurface.TEXT_READER
)
)
private val capabilitiesByType: Map<FileType, FileTypeCapability> = all.associateBy { it.type }
private val typesByExtension: Map<String, FileType> = all
.flatMap { capability -> capability.extensions.map { it.lowercase() to capability.type } }
.toMap()
fun capabilityFor(type: FileType): FileTypeCapability? {
return capabilitiesByType[type]
}
fun displayNameFor(type: FileType): String {
return capabilityFor(type)?.displayName ?: type.name
}
fun fileTypeForName(fileName: String): FileType {
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "")
.substringBefore('?')
.substringBefore('#')
.lowercase()
return typesByExtension[extension] ?: FileType.UNKNOWN
}
fun surfaceFor(type: FileType, platform: ReaderPlatform): ReaderFeatureSurface? {
return capabilityFor(type)?.surfaceFor(platform)
}
fun canOpen(type: FileType, platform: ReaderPlatform): Boolean {
return surfaceFor(type, platform) != null
}
fun readableTypesFor(platform: ReaderPlatform): Set<FileType> {
return all.mapNotNullTo(mutableSetOf()) { capability ->
capability.type.takeIf { capability.surfaceFor(platform) != null }
}
}
fun syncableTypesFor(platform: ReaderPlatform): Set<FileType> {
return all.mapNotNullTo(mutableSetOf()) { capability ->
capability.type.takeIf { capability.syncEligible && capability.surfaceFor(platform) != null }
}
}
fun supportedFormatsLabel(platform: ReaderPlatform): String {
return all
.filter { it.surfaceFor(platform) != null }
.joinToString(", ") { it.displayName }
}
fun desktopParityGaps(): List<FileType> {
return all
.filter { it.isReadableOnAndroid && !it.isReadableOnDesktop }
.map { it.type }
}
}

View file

@ -1,5 +1,8 @@
package com.aryan.reader.shared
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
}
@ -45,6 +48,8 @@ enum class ReadStatusFilter {
COMPLETED
}
const val IN_APP_STORAGE_SOURCE = "IN_APP_STORAGE"
enum class ShelfType {
MANUAL,
SMART,
@ -72,15 +77,21 @@ data class BookItem(
val type: FileType,
val displayName: String,
val timestamp: Long,
val coverImagePath: String? = null,
val title: String? = null,
val author: String? = null,
val progressPercentage: Float? = null,
val isRecent: Boolean = true,
val fileSize: Long = 0L,
val sourceFolder: String? = null,
val folderTextMetadataParsed: Boolean = false,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val tags: List<Tag> = emptyList()
val tags: List<Tag> = emptyList(),
val lastPageIndex: Int? = null,
val readerSettings: ReaderSettings? = null,
val readerBookmarks: List<ReaderBookmark> = emptyList(),
val readerHighlights: List<UserHighlight> = emptyList()
)
data class Shelf(

View file

@ -0,0 +1,293 @@
package com.aryan.reader.shared
data class SharedLibraryMutationResult(
val state: SharedReaderScreenState,
val shelfRecords: List<ShelfRecord>,
val shelfRefs: List<BookShelfRef>
)
object SharedLibraryEditor {
fun cleanShelfName(name: String): String? {
return name.trim().takeIf { it.isNotBlank() }
}
fun canMutateShelf(shelfId: String?): Boolean {
val trimmed = shelfId?.trim()
return !trimmed.isNullOrBlank() && trimmed != "unshelved"
}
fun createShelfRecord(
name: String,
id: String,
isSmart: Boolean = false,
smartRulesJson: String? = null
): ShelfRecord? {
val trimmed = cleanShelfName(name) ?: return null
val trimmedId = id.trim().takeIf { it.isNotBlank() } ?: return null
return ShelfRecord(
id = trimmedId,
name = trimmed,
isSmart = isSmart,
smartRulesJson = smartRulesJson
)
}
fun cleanTagName(name: String): String? {
return name.trim().takeIf { it.isNotBlank() }
}
fun createTag(
name: String,
id: String,
color: Int? = 0xFF64B5F6.toInt()
): Tag? {
val trimmed = cleanTagName(name) ?: return null
val trimmedId = id.trim().takeIf { it.isNotBlank() } ?: return null
return Tag(
id = trimmedId,
name = trimmed,
color = color
)
}
fun cleanBookIds(bookIds: Iterable<String>): Set<String> {
return bookIds.mapTo(mutableSetOf()) { it.trim() }.filterTo(mutableSetOf()) { it.isNotBlank() }
}
fun removeSelectedBooks(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>
): SharedLibraryMutationResult? {
val selected = state.selectedBookIds
if (selected.isEmpty()) return null
return SharedLibraryMutationResult(
state = state.copy(
rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in selected },
selectedBookIds = emptySet(),
bannerMessage = BannerMessage("Removed ${selected.size} book(s) from the library.")
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs.filterNot { it.bookId in selected }
)
}
fun createShelf(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
name: String,
nowMillis: Long = currentTimestamp()
): SharedLibraryMutationResult? {
val trimmed = cleanShelfName(name) ?: return null
return SharedLibraryMutationResult(
state = state.copy(bannerMessage = BannerMessage("Created shelf \"$trimmed\".")),
shelfRecords = shelfRecords + ShelfRecord(id = "shelf_$nowMillis", name = trimmed),
shelfRefs = shelfRefs
)
}
fun createSmartShelf(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
name: String,
definition: SmartCollectionDefinition,
nowMillis: Long = currentTimestamp()
): SharedLibraryMutationResult? {
val trimmed = cleanShelfName(name) ?: return null
val cleanedRules = definition.rules.mapNotNull { rule ->
rule.value.trim().takeIf { it.isNotBlank() }?.let { value -> rule.copy(value = value) }
}
if (cleanedRules.isEmpty()) return null
val cleanedDefinition = definition.copy(rules = cleanedRules)
return SharedLibraryMutationResult(
state = state.copy(bannerMessage = BannerMessage("Created smart shelf \"$trimmed\".")),
shelfRecords = shelfRecords + ShelfRecord(
id = "smart_$nowMillis",
name = trimmed,
isSmart = true,
smartRulesJson = SmartCollectionEngine.toJson(cleanedDefinition)
),
shelfRefs = shelfRefs
)
}
fun renameShelf(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
shelf: Shelf,
name: String
): SharedLibraryMutationResult? {
val trimmed = cleanShelfName(name) ?: return null
return SharedLibraryMutationResult(
state = state.copy(bannerMessage = BannerMessage("Renamed shelf to \"$trimmed\".")),
shelfRecords = shelfRecords.map { if (it.id == shelf.id) it.copy(name = trimmed) else it },
shelfRefs = shelfRefs
)
}
fun deleteShelf(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
shelf: Shelf
): SharedLibraryMutationResult {
return SharedLibraryMutationResult(
state = state.copy(bannerMessage = BannerMessage("Deleted shelf \"${shelf.name}\".")),
shelfRecords = shelfRecords.filterNot { it.id == shelf.id },
shelfRefs = shelfRefs.filterNot { it.shelfId == shelf.id }
)
}
fun removeFolder(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
folder: Shelf
): SharedLibraryMutationResult? {
if (folder.type != ShelfType.FOLDER) return null
val folderBookIds = cleanBookIds(folder.books.map { it.id })
if (folderBookIds.isEmpty()) return null
val rootSourceFolder = folder.books.firstNotNullOfOrNull { it.sourceFolder }
val remainingTabs = state.openTabIds.filterNot { it in folderBookIds }
return SharedLibraryMutationResult(
state = state.copy(
rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in folderBookIds },
selectedBookIds = state.selectedBookIds - folderBookIds,
pinnedHomeBookIds = state.pinnedHomeBookIds - folderBookIds,
pinnedLibraryBookIds = state.pinnedLibraryBookIds - folderBookIds,
openTabIds = remainingTabs,
activeTabBookId = state.activeTabBookId?.takeUnless { it in folderBookIds },
syncedFolders = if (folder.parentShelfId == null && rootSourceFolder != null) {
state.syncedFolders.filterNot { it.uriString == rootSourceFolder }
} else {
state.syncedFolders
},
libraryFilters = if (rootSourceFolder != null) {
state.libraryFilters.copy(sourceFolders = state.libraryFilters.sourceFolders - rootSourceFolder)
} else {
state.libraryFilters
},
bannerMessage = BannerMessage("Removed folder \"${folder.name}\" and ${folderBookIds.size} book(s) from the app.")
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs.filterNot { it.bookId in folderBookIds }
)
}
fun markBookOpened(
state: SharedReaderScreenState,
bookId: String,
nowMillis: Long = currentTimestamp()
): SharedReaderScreenState {
val cleanedBookId = bookId.trim()
if (cleanedBookId.isBlank()) return state
return state.copy(
rawLibraryBooks = state.rawLibraryBooks.map { book ->
if (book.id == cleanedBookId) {
book.copy(isRecent = true, timestamp = nowMillis)
} else {
book
}
}
)
}
fun addSelectedBooksToShelf(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
shelfId: String,
nowMillis: Long = currentTimestamp()
): SharedLibraryMutationResult? {
val selected = state.selectedBookIds
if (selected.isEmpty()) return null
val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId }
val additions = selected.mapNotNull { bookId ->
if (!existing.add(bookId to shelfId)) null else BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
}
return SharedLibraryMutationResult(
state = state.copy(
selectedBookIds = emptySet(),
bannerMessage = BannerMessage("Added ${additions.size} book(s) to shelf.")
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs + additions
)
}
fun tagSelectedBooks(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
tagName: String,
nowMillis: Long = currentTimestamp()
): SharedLibraryMutationResult? {
val selected = cleanBookIds(state.selectedBookIds)
val trimmed = cleanTagName(tagName) ?: return null
if (selected.isEmpty()) return null
val existingTag = state.allTags.firstOrNull { it.name.equals(trimmed, ignoreCase = true) }
val tag = existingTag ?: Tag(
id = trimmed.toStableTagId("tag_$nowMillis"),
name = trimmed,
color = 0xFF64B5F6.toInt()
)
val allTags = (state.allTags + tag).distinctBy { it.id }.sortedBy { it.name.lowercase() }
val books = state.rawLibraryBooks.map { book ->
if (book.id in selected && book.tags.none { it.id == tag.id }) {
book.copy(tags = (book.tags + tag).sortedBy { it.name.lowercase() })
} else {
book
}
}
return SharedLibraryMutationResult(
state = state.copy(
rawLibraryBooks = books,
allTags = allTags,
selectedBookIds = emptySet(),
bannerMessage = BannerMessage("Tagged ${selected.size} book(s) with \"${tag.name}\".")
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs
)
}
fun updateBookMetadata(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
updated: BookItem,
nowMillis: Long = currentTimestamp()
): SharedLibraryMutationResult {
return SharedLibraryMutationResult(
state = state.copy(
rawLibraryBooks = state.rawLibraryBooks.map { if (it.id == updated.id) updated.copy(timestamp = nowMillis) else it },
allTags = (state.allTags + updated.tags).distinctBy { it.id }.sortedBy { it.name.lowercase() },
bannerMessage = BannerMessage("Updated \"${updated.cardTitle()}\".")
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs
)
}
}
fun parseTagList(input: String, knownTags: List<Tag>, nowMillis: Long = currentTimestamp()): List<Tag> {
return input.split(',')
.map { it.trim() }
.filter { it.isNotBlank() }
.distinctBy { it.lowercase() }
.mapIndexed { index, name ->
knownTags.firstOrNull { it.name.equals(name, ignoreCase = true) }
?: Tag(
id = name.toStableTagId("tag_${nowMillis + index}"),
name = name,
color = 0xFF64B5F6.toInt()
)
}
}
private fun String.toStableTagId(fallback: String): String {
return lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_').ifBlank { fallback }
}

View file

@ -43,7 +43,8 @@ class LibraryProjector {
timestamp = now + index,
title = file.name.substringBeforeLast('.'),
fileSize = file.size,
sourceFolder = file.path?.parentPath()
sourceFolder = file.sourceFolder ?: file.path?.parentPath(),
isRecent = false
)
}
}
@ -57,7 +58,7 @@ class LibraryProjector {
return when (sortOrder) {
SortOrder.RECENT -> books.sortedByDescending { it.timestamp }
SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
SortOrder.AUTHOR_ASC -> books.sortedBy { it.author?.lowercase() ?: "" }
SortOrder.AUTHOR_ASC -> books.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f }
SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f }
SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize }
@ -79,7 +80,7 @@ class LibraryProjector {
fun applyFilters(books: List<BookItem>, filters: LibraryFilters): List<BookItem> {
return books.filter { book ->
val matchesType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes
val matchesFolder = filters.sourceFolders.isEmpty() || book.sourceFolder in filters.sourceFolders
val matchesFolder = book.matchesSourceFolders(filters.sourceFolders)
val progress = book.progressPercentage ?: 0f
val matchesStatus = when (filters.readStatus) {
ReadStatusFilter.ALL -> true
@ -148,26 +149,12 @@ private fun String.folderDisplayName(): String {
data class ImportedFile(
val name: String,
val path: String?,
val size: Long
val size: Long,
val sourceFolder: String? = null
)
expect fun currentTimestamp(): Long
fun String.toFileType(): FileType {
return when (substringAfterLast('.', "").lowercase()) {
"pdf" -> FileType.PDF
"epub" -> FileType.EPUB
"mobi" -> FileType.MOBI
"md" -> FileType.MD
"txt" -> FileType.TXT
"html", "htm" -> FileType.HTML
"fb2" -> FileType.FB2
"cbz" -> FileType.CBZ
"cbr" -> FileType.CBR
"cb7" -> FileType.CB7
"docx" -> FileType.DOCX
"odt" -> FileType.ODT
"fodt" -> FileType.FODT
else -> FileType.UNKNOWN
}
return SharedFileCapabilities.fileTypeForName(this)
}

View file

@ -38,11 +38,16 @@ class SharedLibraryStateProjector(
val queried = filterBySearch(allLibraryBooks, current.searchQuery)
val filtered = applyLibraryFilters(queried, current.libraryFilters)
val sortedLibraryBooks = sortBooks(filtered, current.sortOrder)
.withPinnedFirst(current.pinnedLibraryBookIds)
val visibleRecentBooks = sortBooks(
allLibraryBooks.filter { it.isRecent },
current.sortOrder
).take(if (current.recentFilesLimit > 0) current.recentFilesLimit else Int.MAX_VALUE)
)
.withPinnedFirst(current.pinnedHomeBookIds)
.take(if (current.recentFilesLimit > 0) current.recentFilesLimit else Int.MAX_VALUE)
val openTabs = current.openTabIds.mapNotNull { tabId -> allLibraryBooks.find { it.id == tabId } }
val openTabIds = openTabs.map { it.id }
val activeTabBookId = current.activeTabBookId?.takeIf { it in openTabIds }
val shelfProjection = buildShelves(
allLibraryBooks = allLibraryBooks,
shelfRecords = input.shelfRecords,
@ -81,6 +86,8 @@ class SharedLibraryStateProjector(
},
shelves = shelfProjection.shelves,
openTabs = openTabs,
openTabIds = openTabIds,
activeTabBookId = activeTabBookId,
booksAvailableForAdding = booksAvailableForAdding,
allTags = input.tags
)
@ -99,13 +106,22 @@ class SharedLibraryStateProjector(
val booksById = allLibraryBooks.associateBy { it.id }
shelfRecords.forEach { shelf ->
val bookIds = shelfRefs
.filter { it.shelfId == shelf.id }
.sortedBy { it.addedAt }
.map { it.bookId }
val books = bookIds.mapNotNull { booksById[it] }
shelves.add(Shelf(shelf.id, shelf.name, ShelfType.MANUAL, sortBooks(books, sortOrder)))
shelvedBookIds.addAll(bookIds)
if (shelf.isSmart && shelf.smartRulesJson != null) {
val definition = SmartCollectionEngine.fromJson(shelf.smartRulesJson)
if (definition != null) {
val matchingBooks = allLibraryBooks.filter { SmartCollectionEngine.evaluate(it, definition) }
shelves.add(Shelf(shelf.id, shelf.name, ShelfType.SMART, sortBooks(matchingBooks, sortOrder)))
shelvedBookIds.addAll(matchingBooks.map { it.id })
}
} else {
val bookIds = shelfRefs
.filter { it.shelfId == shelf.id }
.sortedBy { it.addedAt }
.map { it.bookId }
val books = bookIds.mapNotNull { booksById[it] }
shelves.add(Shelf(shelf.id, shelf.name, ShelfType.MANUAL, sortBooks(books, sortOrder)))
shelvedBookIds.addAll(bookIds)
}
}
val tagShelves = tags.mapNotNull { tag ->
@ -257,7 +273,7 @@ fun filterBySearch(books: List<BookItem>, searchQuery: String): List<BookItem> {
fun applyLibraryFilters(books: List<BookItem>, filters: LibraryFilters): List<BookItem> {
return books.filter { book ->
val matchType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes
val matchFolder = filters.sourceFolders.isEmpty() || book.sourceFolder in filters.sourceFolders
val matchFolder = book.matchesSourceFolders(filters.sourceFolders)
val progress = book.progressPercentage ?: 0f
val matchStatus = when (filters.readStatus) {
ReadStatusFilter.ALL -> true
@ -274,7 +290,7 @@ fun sortBooks(books: List<BookItem>, sortOrder: SortOrder): List<BookItem> {
return when (sortOrder) {
SortOrder.RECENT -> books.sortedByDescending { it.timestamp }
SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
SortOrder.AUTHOR_ASC -> books.sortedBy { it.author?.lowercase() ?: "" }
SortOrder.AUTHOR_ASC -> books.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f }
SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f }
SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize }
@ -301,7 +317,8 @@ fun SharedReaderScreenState.withImportedFiles(
timestamp = now + index,
title = file.name.substringBeforeLast('.'),
fileSize = file.size,
sourceFolder = file.localPath?.parentPath()
sourceFolder = file.sourceFolder ?: file.localPath?.parentPath(),
isRecent = false
)
}
}
@ -322,3 +339,13 @@ private fun String.parentPath(): String? {
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()
.sortedWith(
compareByDescending<IndexedValue<BookItem>> { it.value.id in pinnedBookIds }
.thenBy { it.index }
)
.map { it.value }
}

View file

@ -0,0 +1,507 @@
package com.aryan.reader.shared
import com.aryan.reader.shared.reader.ReaderBookmark
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData"
const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations"
internal expect fun localFolderSyncSha256ShortHex(value: String): String
data class SharedFolderBookMetadata(
val bookId: String,
val title: String?,
val author: String?,
val displayName: String,
val type: String,
val lastChapterIndex: Int?,
val lastPage: Int?,
val lastPositionCfi: String?,
val progressPercentage: Float,
val isRecent: Boolean,
val lastModifiedTimestamp: Long,
val bookmarksJson: String?,
val locatorBlockIndex: Int?,
val locatorCharOffset: Int?,
val customName: String?,
val highlightsJson: String?
) {
fun toJsonString(): String {
return folderSyncJson.encodeToString(
JsonElement.serializer(),
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),
"lastPage" to JsonPrimitive(lastPage ?: -1),
"lastPositionCfi" to lastPositionCfi.asJson(),
"progressPercentage" to JsonPrimitive(progressPercentage.toDouble()),
"isRecent" to JsonPrimitive(isRecent),
"lastModifiedTimestamp" to JsonPrimitive(lastModifiedTimestamp),
"bookmarksJson" to bookmarksJson.asJson(),
"locatorBlockIndex" to JsonPrimitive(locatorBlockIndex ?: -1),
"locatorCharOffset" to JsonPrimitive(locatorCharOffset ?: -1),
"customName" to customName.asJson(),
"highlightsJson" to highlightsJson.asJson()
)
)
)
}
fun toBookItem(
file: SharedFolderScannedFile,
existing: BookItem? = null,
nowMillis: Long = currentTimestamp()
): BookItem {
val parsedHighlights = highlightsJson
?.let(EpubAnnotationSerializer::parseHighlightsJson)
?.takeIf { it.isNotEmpty() }
val parsedBookmarks = parseReaderBookmarks(bookId)
.takeIf { it.isNotEmpty() }
val parsedType = runCatching { FileType.valueOf(type) }.getOrNull() ?: file.type
val metadataTimestamp = lastModifiedTimestamp.takeIf { it > 0L } ?: nowMillis
return (existing ?: BookItem(
id = bookId,
path = file.path,
type = parsedType,
displayName = displayName.ifBlank { file.name },
timestamp = metadataTimestamp,
title = title ?: displayName.ifBlank { file.name },
author = author,
fileSize = file.size,
sourceFolder = file.sourceFolder,
isRecent = isRecent
)).copy(
id = bookId,
path = file.path,
type = parsedType,
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,
progressPercentage = progressPercentage,
isRecent = isRecent || (existing?.isRecent ?: false),
fileSize = file.size.takeIf { it > 0L } ?: existing?.fileSize ?: 0L,
sourceFolder = file.sourceFolder,
folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false,
lastPageIndex = lastPage,
readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(),
readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty()
)
}
private fun parseReaderBookmarks(bookId: String): List<ReaderBookmark> {
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
.mapIndexed { index, bookmark ->
val locator = bookmark.locator.withFallbacks(
chapterIndex = bookmark.chapterIndex,
cfi = bookmark.cfi,
pageIndex = bookmark.pageInChapter?.minus(1),
textQuote = bookmark.snippet
)
val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0
ReaderBookmark(
id = "bookmark_${localFolderSyncSha256ShortHex("$bookId:$index:${bookmark.cfi}")}",
pageIndex = pageIndex.coerceAtLeast(0),
chapterTitle = bookmark.chapterTitle,
preview = bookmark.snippet,
locator = locator
)
}
}
companion object {
fun fromJsonString(rawJson: String): SharedFolderBookMetadata? {
val obj = runCatching { folderSyncJson.parseToJsonElement(rawJson).jsonObject }.getOrNull()
?: return null
val bookId = obj.string("bookId")?.takeIf { it.isNotBlank() } ?: return null
return SharedFolderBookMetadata(
bookId = bookId,
title = obj.string("title"),
author = obj.string("author"),
displayName = obj.string("displayName") ?: "Unknown",
type = obj.string("type") ?: FileType.PDF.name,
lastChapterIndex = obj.sentinelInt("lastChapterIndex"),
lastPage = obj.sentinelInt("lastPage"),
lastPositionCfi = obj.string("lastPositionCfi"),
progressPercentage = obj.double("progressPercentage")?.toFloat() ?: 0f,
isRecent = obj.boolean("isRecent") ?: true,
lastModifiedTimestamp = obj.long("lastModifiedTimestamp") ?: 0L,
bookmarksJson = obj.string("bookmarksJson"),
locatorBlockIndex = obj.sentinelInt("locatorBlockIndex"),
locatorCharOffset = obj.sentinelInt("locatorCharOffset"),
customName = obj.string("customName"),
highlightsJson = obj.string("highlightsJson")
)
}
}
}
data class SharedFolderScannedFile(
val name: String,
val path: String,
val sourceFolder: String,
val relativePath: String,
val type: FileType,
val size: Long,
val lastModified: Long
) {
val stableBookId: String
get() = LocalFolderSyncEngine.buildStableBookId(name, relativePath)
}
data class LocalFolderSyncStats(
val scannedFiles: Int = 0,
val supportedFiles: Int = 0,
val newBooks: Int = 0,
val updatedBooks: Int = 0,
val unchangedBooks: Int = 0,
val removedBooks: Int = 0,
val migratedBooks: Int = 0,
val remoteMetadataUpdates: Int = 0
) {
operator fun plus(other: LocalFolderSyncStats): LocalFolderSyncStats {
return LocalFolderSyncStats(
scannedFiles = scannedFiles + other.scannedFiles,
supportedFiles = supportedFiles + other.supportedFiles,
newBooks = newBooks + other.newBooks,
updatedBooks = updatedBooks + other.updatedBooks,
unchangedBooks = unchangedBooks + other.unchangedBooks,
removedBooks = removedBooks + other.removedBooks,
migratedBooks = migratedBooks + other.migratedBooks,
remoteMetadataUpdates = remoteMetadataUpdates + other.remoteMetadataUpdates
)
}
}
data class LocalFolderSyncResult(
val state: SharedReaderScreenState,
val idMigrations: Map<String, String>,
val removedBookIds: Set<String>,
val stats: LocalFolderSyncStats
)
object LocalFolderSyncEngine {
fun buildStableBookId(name: String, relativePath: String): String {
val normalizedRelativePath = relativePath.toSyncRelativePath().ifBlank { name }
return if (normalizedRelativePath.equals(name, ignoreCase = true)) {
"local_$name"
} else {
"local_${name}_${localFolderSyncSha256ShortHex(normalizedRelativePath.lowercase())}"
}
}
fun syncFolder(
state: SharedReaderScreenState,
folder: SyncedFolder,
files: List<SharedFolderScannedFile>,
remoteMetadata: Map<String, SharedFolderBookMetadata>,
nowMillis: Long = currentTimestamp(),
metadataOnly: Boolean = false
): LocalFolderSyncResult {
val folderRoot = folder.uriString
val allowedTypes = folder.allowedFileTypes
val booksById = linkedMapOf<String, BookItem>()
state.rawLibraryBooks.forEach { booksById[it.id] = it }
val idMigrations = linkedMapOf<String, String>()
var stats = LocalFolderSyncStats(
scannedFiles = files.size,
supportedFiles = files.count { it.type in allowedTypes }
)
var removedIds = emptySet<String>()
val existingFolderBookIds = booksById.values
.filter { it.sourceFolder == folderRoot }
.mapTo(linkedSetOf()) { it.id }
remoteMetadata.forEach { (bookId, metadata) ->
val existing = booksById[bookId]?.takeIf { it.sourceFolder == folderRoot }
if (existing != null && metadata.lastModifiedTimestamp > existing.localFolderModifiedTimestamp()) {
booksById[bookId] = existing.withAppliedFolderMetadata(metadata, nowMillis)
stats = stats.copy(remoteMetadataUpdates = stats.remoteMetadataUpdates + 1)
}
}
if (!metadataOnly) {
val foundBookIds = linkedSetOf<String>()
val folderBooksByPath = booksById.values
.filter { it.sourceFolder == folderRoot && !it.path.isNullOrBlank() }
.associateBy { it.path.orEmpty() }
.toMutableMap()
val legacyItemsByName = booksById.values
.asSequence()
.filter { it.sourceFolder == folderRoot }
.filter { it.id.startsWith("local_${it.displayName}_") || it.id == it.path }
.groupBy { it.displayName }
.mapValues { (_, books) -> ArrayDeque<BookItem>().apply { addAll(books) } }
.toMutableMap()
files
.asSequence()
.filter { it.type in allowedTypes }
.sortedBy { it.relativePath.lowercase() }
.forEach { file ->
val stableId = file.stableBookId
foundBookIds += stableId
var existing = booksById[stableId]?.takeIf { it.sourceFolder == folderRoot }
if (existing == null) {
val migrated = folderBooksByPath[file.path]?.takeIf { it.id != stableId }
?: legacyItemsByName[file.name]?.firstOrNull { it.id != stableId }
if (migrated != null) {
val oldId = migrated.id
val migratedBook = migrated.copy(id = stableId).withScannedFile(file)
booksById.remove(oldId)
booksById[stableId] = migratedBook
idMigrations[oldId] = stableId
legacyItemsByName[file.name]?.remove(migrated)
existing = migratedBook
stats = stats.copy(migratedBooks = stats.migratedBooks + 1)
}
}
val metadata = remoteMetadata[stableId]
if (existing == null) {
booksById[stableId] = metadata?.toBookItem(file, nowMillis = nowMillis)
?: file.toBookItem(stableId, nowMillis)
stats = stats.copy(newBooks = stats.newBooks + 1)
} else {
val updatedForFile = existing.withScannedFile(file)
val updated = metadata
?.takeIf { it.lastModifiedTimestamp > updatedForFile.localFolderModifiedTimestamp() }
?.toBookItem(file = file, existing = updatedForFile, nowMillis = nowMillis)
?: updatedForFile
booksById[stableId] = updated
if (updated != existing) {
stats = stats.copy(updatedBooks = stats.updatedBooks + 1)
} else {
stats = stats.copy(unchangedBooks = stats.unchangedBooks + 1)
}
}
}
removedIds = existingFolderBookIds
.map { idMigrations[it] ?: it }
.filter { it !in foundBookIds }
.toSet()
removedIds.forEach(booksById::remove)
stats = stats.copy(removedBooks = removedIds.size)
}
val syncedFolder = folder.copy(lastScanTime = nowMillis)
val syncedFolders = (state.syncedFolders.filterNot { it.uriString == folderRoot } + syncedFolder)
.sortedBy { it.name.lowercase() }
val migratedState = state
.withMigratedBookIds(idMigrations)
val nextState = migratedState
.withoutBookIds(removedIds)
.copy(
rawLibraryBooks = booksById.values.toList(),
syncedFolders = syncedFolders,
lastFolderScanTime = nowMillis
)
return LocalFolderSyncResult(
state = nextState,
idMigrations = idMigrations,
removedBookIds = removedIds,
stats = stats
)
}
fun applyIdMigrationsToShelfRefs(
shelfRefs: List<BookShelfRef>,
migrations: Map<String, String>
): List<BookShelfRef> {
if (migrations.isEmpty()) return shelfRefs
return shelfRefs.map { ref ->
migrations[ref.bookId]?.let { ref.copy(bookId = it) } ?: ref
}.distinctBy { it.bookId to it.shelfId }
}
}
fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
if (sourceFolder.isNullOrBlank()) return null
val bookmarksJson = readerBookmarks
.mapNotNull { it.toEpubBookmarkOrNull() }
.takeIf { it.isNotEmpty() }
?.let(EpubAnnotationSerializer::bookmarksToJson)
val highlightsJson = readerHighlights
.takeIf { it.isNotEmpty() }
?.let(EpubAnnotationSerializer::highlightsToJson)
val hasProgress = (progressPercentage ?: 0f) > 0f || lastPageIndex != null
val isDirty = isRecent || hasProgress || !bookmarksJson.isNullOrBlank() || !highlightsJson.isNullOrBlank()
if (!isDirty) return null
return SharedFolderBookMetadata(
bookId = id,
title = title,
author = author,
displayName = displayName,
type = type.name,
lastChapterIndex = null,
lastPage = lastPageIndex,
lastPositionCfi = null,
progressPercentage = progressPercentage ?: 0f,
isRecent = isRecent,
lastModifiedTimestamp = localFolderModifiedTimestamp(),
bookmarksJson = bookmarksJson,
locatorBlockIndex = null,
locatorCharOffset = null,
customName = null,
highlightsJson = highlightsJson
)
}
private val folderSyncJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private fun BookItem.withAppliedFolderMetadata(
metadata: SharedFolderBookMetadata,
nowMillis: Long
): BookItem {
val file = SharedFolderScannedFile(
name = displayName,
path = path.orEmpty(),
sourceFolder = sourceFolder.orEmpty(),
relativePath = displayName,
type = runCatching { FileType.valueOf(metadata.type) }.getOrNull() ?: type,
size = fileSize,
lastModified = 0L
)
return metadata.toBookItem(file = file, existing = this, nowMillis = nowMillis)
}
private fun SharedFolderScannedFile.toBookItem(bookId: String, nowMillis: Long): BookItem {
return BookItem(
id = bookId,
path = path,
type = type,
displayName = name,
timestamp = nowMillis,
title = name.substringBeforeLast('.', missingDelimiterValue = name),
fileSize = size,
sourceFolder = sourceFolder,
isRecent = false
)
}
private fun BookItem.withScannedFile(file: SharedFolderScannedFile): BookItem {
val sizeChanged = fileSize > 0L && file.size > 0L && fileSize != file.size
return copy(
path = file.path,
type = file.type,
displayName = file.name,
coverImagePath = if (sizeChanged) null else coverImagePath,
fileSize = file.size.takeIf { it > 0L } ?: fileSize,
sourceFolder = file.sourceFolder,
folderTextMetadataParsed = if (sizeChanged) false else folderTextMetadataParsed
)
}
private fun BookItem.localFolderModifiedTimestamp(): Long {
return timestamp
}
private fun ReaderBookmark.toEpubBookmarkOrNull(): EpubBookmark? {
val chapterIndex = locator.chapterIndex ?: 0
val cfi = locator.cfi ?: "desktop:$chapterIndex:$pageIndex"
return EpubBookmark(
cfi = cfi,
chapterTitle = chapterTitle,
label = null,
snippet = preview,
pageInChapter = pageIndex + 1,
totalPagesInChapter = null,
chapterIndex = chapterIndex,
locator = locator.withFallbacks(
chapterIndex = chapterIndex,
cfi = cfi,
pageIndex = pageIndex,
textQuote = preview
)
)
}
private fun SharedReaderScreenState.withMigratedBookIds(
migrations: Map<String, String>
): SharedReaderScreenState {
if (migrations.isEmpty()) return this
fun String.migrated(): String = migrations[this] ?: this
fun Set<String>.migrated(): Set<String> = mapTo(linkedSetOf()) { it.migrated() }
return copy(
selectedBookIds = selectedBookIds.migrated(),
booksSelectedForAdding = booksSelectedForAdding.migrated(),
pinnedHomeBookIds = pinnedHomeBookIds.migrated(),
pinnedLibraryBookIds = pinnedLibraryBookIds.migrated(),
openTabIds = openTabIds.map { it.migrated() }.distinct(),
activeTabBookId = activeTabBookId?.migrated(),
selectedBookId = selectedBookId?.migrated()
)
}
private fun SharedReaderScreenState.withoutBookIds(bookIds: Set<String>): SharedReaderScreenState {
if (bookIds.isEmpty()) return this
return copy(
selectedBookIds = selectedBookIds - bookIds,
booksSelectedForAdding = booksSelectedForAdding - bookIds,
pinnedHomeBookIds = pinnedHomeBookIds - bookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds - bookIds,
openTabIds = openTabIds.filterNot { it in bookIds },
activeTabBookId = activeTabBookId?.takeUnless { it in bookIds },
selectedBookId = selectedBookId?.takeUnless { it in bookIds }
)
}
private fun String.toSyncRelativePath(): String {
return replace('\\', '/')
.split('/')
.filter { it.isNotBlank() && it != "." }
.joinToString("/")
}
private fun JsonObject.string(name: String): String? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull }.getOrNull()
}
private fun JsonObject.long(name: String): Long? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
}
private fun JsonObject.double(name: String): Double? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull }.getOrNull()
}
private fun JsonObject.boolean(name: String): Boolean? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.booleanOrNull }.getOrNull()
}
private fun JsonObject.sentinelInt(name: String): Int? {
val value = runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
return value?.takeUnless { it == -1 }
}
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull

View file

@ -9,7 +9,13 @@ data class EpubBookmark(
val snippet: String,
val pageInChapter: Int?,
val totalPagesInChapter: Int?,
val chapterIndex: Int
val chapterIndex: Int,
val locator: ReaderLocator = ReaderLocator.fromLegacy(
chapterIndex = chapterIndex,
cfi = cfi,
pageIndex = pageInChapter?.minus(1),
textQuote = snippet
)
)
enum class HighlightColor(val id: String, val color: Color, val cssClass: String) {
@ -29,13 +35,142 @@ enum class HighlightColor(val id: String, val color: Color, val cssClass: String
WHITE("white", Color(0xFFF5F5F5), "user-highlight-white")
}
data class ReaderLocator(
val chapterIndex: Int? = null,
val chapterId: String? = null,
val href: String? = null,
val pageIndex: Int? = null,
val startOffset: Int? = null,
val endOffset: Int? = null,
val textQuote: String? = null,
val cfi: String? = null
) {
val hasTextRange: Boolean
get() = startOffset != null && endOffset != null && endOffset >= startOffset
fun withFallbacks(
chapterIndex: Int? = null,
chapterId: String? = null,
href: String? = null,
pageIndex: Int? = null,
startOffset: Int? = null,
endOffset: Int? = null,
textQuote: String? = null,
cfi: String? = null
): ReaderLocator {
return copy(
chapterIndex = this.chapterIndex ?: chapterIndex,
chapterId = this.chapterId ?: chapterId,
href = this.href ?: href,
pageIndex = this.pageIndex ?: pageIndex,
startOffset = this.startOffset ?: startOffset,
endOffset = this.endOffset ?: endOffset,
textQuote = this.textQuote ?: textQuote,
cfi = this.cfi ?: cfi
)
}
fun sameLocation(other: ReaderLocator): Boolean {
val sameChapter = chapterIndex == null || other.chapterIndex == null || chapterIndex == other.chapterIndex
if (!sameChapter) return false
if (hasTextRange && other.hasTextRange) {
return startOffset == other.startOffset && endOffset == other.endOffset
}
if (pageIndex != null && other.pageIndex != null) {
return pageIndex == other.pageIndex
}
return cfi != null && cfi == other.cfi
}
companion object {
fun fromLegacy(
chapterIndex: Int? = null,
cfi: String? = null,
pageIndex: Int? = null,
textQuote: String? = null
): ReaderLocator {
val desktopParts = cfi
?.takeIf { it.startsWith("desktop:") }
?.split(':')
.orEmpty()
val parsedChapterIndex = desktopParts.getOrNull(1)?.toIntOrNull()
val possibleStartOffset = desktopParts.getOrNull(2)?.toIntOrNull()
val possibleEndOffset = desktopParts.getOrNull(3)?.toIntOrNull()
val hasOffsetRange = desktopParts.size == 4 &&
possibleStartOffset != null &&
possibleEndOffset != null &&
possibleStartOffset >= 0 &&
possibleEndOffset >= possibleStartOffset &&
possibleEndOffset - possibleStartOffset <= 100_000
val parsedStartOffset = if (hasOffsetRange) possibleStartOffset else null
val parsedEndOffset = if (hasOffsetRange) possibleEndOffset else null
val parsedPageIndex = when {
pageIndex != null -> pageIndex
desktopParts.size == 3 || desktopParts.size >= 5 || (desktopParts.size == 4 && !hasOffsetRange) ->
desktopParts.getOrNull(2)?.toIntOrNull()
else -> null
}
return ReaderLocator(
chapterIndex = chapterIndex ?: parsedChapterIndex,
pageIndex = parsedPageIndex,
startOffset = parsedStartOffset,
endOffset = parsedEndOffset,
textQuote = textQuote,
cfi = cfi
)
}
}
}
data class ReaderHighlightPalette(
val colors: List<HighlightColor> = defaultColors
) {
fun sanitized(): ReaderHighlightPalette {
val distinct = colors.distinct().filter { it in HighlightColor.entries }
return copy(colors = distinct.ifEmpty { defaultColors })
}
fun contains(color: HighlightColor): Boolean {
return color in sanitized().colors
}
fun withColor(color: HighlightColor, enabled: Boolean): ReaderHighlightPalette {
val next = if (enabled) {
colors + color
} else {
colors - color
}
return copy(colors = next).sanitized()
}
companion object {
val defaultColors: List<HighlightColor>
get() = listOf(
HighlightColor.YELLOW,
HighlightColor.GREEN,
HighlightColor.BLUE,
HighlightColor.RED,
HighlightColor.PURPLE,
HighlightColor.ORANGE
)
}
}
data class UserHighlight(
val id: String,
val cfi: String,
val text: String,
val color: HighlightColor,
val chapterIndex: Int,
val note: String? = null
val note: String? = null,
val locator: ReaderLocator = ReaderLocator.fromLegacy(
chapterIndex = chapterIndex,
cfi = cfi,
textQuote = text
)
)
fun escapeJsString(value: String): String {

View file

@ -0,0 +1,268 @@
package com.aryan.reader.shared
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
object EpubAnnotationSerializer {
private val json = Json {
ignoreUnknownKeys = true
}
fun parseBookmarksJson(rawJson: String?, chapterTitles: List<String> = emptyList()): Set<EpubBookmark> {
if (rawJson.isNullOrBlank()) return emptySet()
val root = runCatching { json.parseToJsonElement(rawJson).jsonArray }.getOrNull() ?: return emptySet()
return root.mapNotNull { element ->
when (element) {
is JsonObject -> element.asBookmarkOrNull(chapterTitles)
else -> element.contentOrNull()
?.let { rawBookmark -> parseBookmarkObject(rawBookmark, chapterTitles) }
}
}.toSet()
}
fun parseBookmarkEntries(entries: Collection<String>, chapterTitles: List<String> = emptyList()): Set<EpubBookmark> {
return entries.mapNotNull { parseBookmarkObject(it, chapterTitles) }.toSet()
}
fun bookmarksToJson(bookmarks: Collection<EpubBookmark>): String {
val bookmarkEntries = bookmarks.map { JsonPrimitive(it.toJsonString()) }
return json.encodeToString(JsonElement.serializer(), JsonArray(bookmarkEntries))
}
fun parseHighlightsJson(rawJson: String?): List<UserHighlight> {
if (rawJson.isNullOrBlank()) return emptyList()
val root = runCatching { json.parseToJsonElement(rawJson).jsonArray }.getOrNull() ?: return emptyList()
return root.mapNotNull { element ->
runCatching { element.jsonObject.asHighlightOrNull() }.getOrNull()
}
}
fun parseHighlightJson(rawJson: String?): UserHighlight? {
if (rawJson.isNullOrBlank()) return null
return runCatching { json.parseToJsonElement(rawJson).jsonObject.asHighlightOrNull() }.getOrNull()
}
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)
}
fun highlightsToJson(highlights: Collection<UserHighlight>): String {
return json.encodeToString(
JsonElement.serializer(),
JsonArray(highlights.map { it.toJsonObject() })
)
}
fun processAndAddHighlight(
newCfi: String,
newText: String,
newColor: HighlightColor,
chapterIndex: Int,
currentList: MutableList<UserHighlight>,
locator: ReaderLocator = ReaderLocator.fromLegacy(
chapterIndex = chapterIndex,
cfi = newCfi,
textQuote = newText
)
): String {
val normalizedLocator = locator.withFallbacks(
chapterIndex = chapterIndex,
cfi = newCfi,
textQuote = newText
)
val exactMatchIndex = currentList.indexOfFirst {
it.chapterIndex == chapterIndex &&
(it.cfi == newCfi || it.locator.sameLocation(normalizedLocator))
}
if (exactMatchIndex != -1) {
val existing = currentList[exactMatchIndex]
currentList[exactMatchIndex] = existing.copy(
cfi = newCfi,
color = newColor,
text = newText,
locator = existing.locator.copy(cfi = newCfi, textQuote = newText).withFallbacks(
chapterIndex = chapterIndex,
cfi = newCfi,
textQuote = newText
)
)
return newCfi
}
currentList.add(
UserHighlight(
id = stableHighlightId(newCfi, chapterIndex),
cfi = newCfi,
text = newText,
color = newColor,
chapterIndex = chapterIndex,
note = null,
locator = normalizedLocator
)
)
return newCfi
}
private fun parseBookmarkObject(rawJson: String, chapterTitles: List<String>): EpubBookmark? {
return runCatching { json.parseToJsonElement(rawJson).jsonObject.asBookmarkOrNull(chapterTitles) }.getOrNull()
}
private fun EpubBookmark.toJsonString(): String {
return json.encodeToString(JsonElement.serializer(), toJsonObject())
}
private fun EpubBookmark.toJsonObject(): JsonObject {
return JsonObject(
buildMap {
put("cfi", JsonPrimitive(cfi))
put("chapterTitle", JsonPrimitive(chapterTitle))
put("label", label.asJson())
put("snippet", JsonPrimitive(snippet))
pageInChapter?.let { put("pageInChapter", JsonPrimitive(it)) }
totalPagesInChapter?.let { put("totalPagesInChapter", JsonPrimitive(it)) }
put("chapterIndex", JsonPrimitive(chapterIndex))
put("locator", locator.toJsonObject())
}
)
}
private fun JsonObject.asBookmarkOrNull(chapterTitles: List<String>): EpubBookmark? {
val cfi = string("cfi") ?: return null
val chapterTitle = string("chapterTitle") ?: return null
val chapterIndex = int("chapterIndex")
?: chapterTitles.indexOfFirst { it == chapterTitle }.coerceAtLeast(0)
return EpubBookmark(
cfi = cfi,
chapterTitle = chapterTitle,
label = string("label"),
snippet = string("snippet") ?: "",
pageInChapter = int("pageInChapter"),
totalPagesInChapter = int("totalPagesInChapter"),
chapterIndex = chapterIndex,
locator = this["locator"]
?.takeUnless { it is JsonNull }
?.asReaderLocatorOrNull()
?.withFallbacks(
chapterIndex = chapterIndex,
cfi = cfi,
pageIndex = int("pageInChapter")?.minus(1),
textQuote = string("snippet") ?: ""
)
?: ReaderLocator.fromLegacy(
chapterIndex = chapterIndex,
cfi = cfi,
pageIndex = int("pageInChapter")?.minus(1),
textQuote = string("snippet") ?: ""
)
)
}
private fun JsonObject.asHighlightOrNull(): UserHighlight? {
val cfi = string("cfi") ?: return null
val text = string("text") ?: return null
val chapterIndex = int("chapterIndex") ?: return null
val colorId = string("colorId")
val color = HighlightColor.entries.firstOrNull { it.id == colorId } ?: HighlightColor.YELLOW
val note = string("note")?.takeIf { it.isNotBlank() }
return UserHighlight(
id = string("id")?.takeIf { it.isNotBlank() } ?: stableHighlightId(cfi, chapterIndex),
cfi = cfi,
text = text,
color = color,
chapterIndex = chapterIndex,
note = note,
locator = this["locator"]
?.takeUnless { it is JsonNull }
?.asReaderLocatorOrNull()
?.withFallbacks(
chapterIndex = chapterIndex,
cfi = cfi,
textQuote = text
)
?: ReaderLocator.fromLegacy(
chapterIndex = chapterIndex,
cfi = cfi,
textQuote = text
)
)
}
private fun UserHighlight.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"cfi" to JsonPrimitive(cfi),
"text" to JsonPrimitive(text),
"colorId" to JsonPrimitive(color.id),
"chapterIndex" to JsonPrimitive(chapterIndex),
"note" to (note ?: "").asJson(),
"locator" to locator.toJsonObject()
)
)
}
private fun ReaderLocator.toJsonObject(): JsonObject {
return JsonObject(
buildMap {
chapterIndex?.let { put("chapterIndex", JsonPrimitive(it)) }
chapterId?.let { put("chapterId", JsonPrimitive(it)) }
href?.let { put("href", JsonPrimitive(it)) }
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
cfi?.let { put("cfi", JsonPrimitive(it)) }
}
)
}
private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return ReaderLocator(
chapterIndex = obj.int("chapterIndex"),
chapterId = obj.string("chapterId"),
href = obj.string("href"),
pageIndex = obj.int("pageIndex"),
startOffset = obj.int("startOffset"),
endOffset = obj.int("endOffset"),
textQuote = obj.string("textQuote"),
cfi = obj.string("cfi")
)
}
private fun stableHighlightId(cfi: String, chapterIndex: Int): String {
val key = "$chapterIndex:$cfi"
var hash = 1125899906842597L
key.forEach { char -> hash = 31 * hash + char.code }
return "highlight_${hash.toString(16)}"
}
private fun JsonObject.string(name: String): String? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
}
private fun JsonObject.int(name: String): Int? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
}
private fun JsonElement.contentOrNull(): String? {
return runCatching { takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
}
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
}

View file

@ -1,6 +1,13 @@
package com.aryan.reader.shared
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.SharedReaderTextAlign
import kotlin.math.max
import kotlin.math.roundToInt
enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) {
ORIGINAL("original", "Original", "Original"),
@ -29,6 +36,11 @@ enum class PageInfoMode(val id: Int, val title: String) {
HIDDEN(2, "Always Hide")
}
enum class PageInfoPosition(val id: Int, val title: String) {
BOTTOM(0, "Bottom"),
TOP(1, "Top")
}
data class FormatSettings(
val fontSize: Float,
val lineHeight: Float,
@ -37,16 +49,26 @@ data class FormatSettings(
val horizontalMargin: Float,
val font: ReaderFont,
val customPath: String?,
val textAlign: ReaderTextAlign
val textAlign: ReaderTextAlign,
val verticalMargin: Float = 1.0f
)
enum class ReaderTexture(val id: String, val displayName: String) {
PAPER("paper", "Paper"),
CANVAS("canvas", "Canvas"),
EINK("eink", "E-Ink"),
SLATE("slate", "Slate")
enum class ReaderTexture(val id: String, val displayName: String, val assetPath: String) {
NATURAL_WHITE("asset:ep_naturalwhite.webp", "Natural White", "textures/ep_naturalwhite.webp"),
NATURAL_BLACK("asset:ep_naturalblack.webp", "Natural Black", "textures/ep_naturalblack.webp"),
LIGHT_VENEER("asset:light-veneer.webp", "Light Veneer", "textures/light-veneer.webp"),
RETINA_WOOD("asset:retina_wood.webp", "Retina Wood", "textures/retina_wood.webp"),
GREY_WASH("asset:grey_wash_wall.webp", "Grey Wash", "textures/grey_wash_wall.webp"),
CLASSY_FABRIC("asset:classy_fabric.webp", "Classy Fabric", "textures/classy_fabric.webp"),
RETRO_INTRO("asset:retro_intro.webp", "Retro Intro", "textures/retro_intro.webp"),
PAPER("paper", "Paper", "textures/texture_paper.png"),
CANVAS("canvas", "Canvas", "textures/texture_canvas.png"),
EINK("eink", "E-Ink", "textures/texture_eink.webp"),
SLATE("slate", "Slate", "textures/texture_slate.png")
}
const val ReaderTextureFilePrefix = "file:"
data class ReaderTheme(
val id: String,
val name: String,
@ -63,5 +85,124 @@ val BuiltInReaderThemes = listOf(
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
ReaderTheme("natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
ReaderTheme("retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
ReaderTheme("veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
ReaderTheme("grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
ReaderTheme("fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
)
val BuiltInPdfReaderThemes = listOf(
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
)
fun FormatSettings.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings {
val horizontalMarginPx = (ReaderAppearanceDefaults.marginPx * horizontalMargin).roundToInt()
.coerceIn(ReaderAppearanceDefaults.minMarginPx, ReaderAppearanceDefaults.maxMarginPx)
val verticalMarginPx = (ReaderAppearanceDefaults.marginPx * verticalMargin).roundToInt()
.coerceIn(ReaderAppearanceDefaults.minMarginPx, ReaderAppearanceDefaults.maxMarginPx)
return base.copy(
fontSize = (ReaderAppearanceDefaults.fontSizePx * fontSize).roundToInt()
.coerceIn(ReaderAppearanceDefaults.minFontSizePx, ReaderAppearanceDefaults.maxFontSizePx),
lineSpacing = (ReaderAppearanceDefaults.lineSpacing * lineHeight)
.coerceIn(ReaderAppearanceDefaults.minLineSpacing, ReaderAppearanceDefaults.maxLineSpacing),
margin = max(horizontalMarginPx, verticalMarginPx),
horizontalMargin = horizontalMarginPx,
verticalMargin = verticalMarginPx,
textAlign = textAlign.toSharedReaderTextAlign(),
fontFamily = customPath?.takeIf { it.isNotBlank() } ?: font.toReaderSettingsFontFamily(),
customFontPath = customPath?.takeIf { it.isNotBlank() },
paragraphSpacing = paragraphGap.coerceIn(
ReaderAppearanceDefaults.minParagraphSpacing,
ReaderAppearanceDefaults.maxParagraphSpacing
),
imageScale = imageSize.coerceIn(
ReaderAppearanceDefaults.minImageScale,
ReaderAppearanceDefaults.maxImageScale
)
)
}
fun ReaderTheme.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings {
return base.copy(
darkMode = isDark,
themeId = id,
textureId = textureId,
backgroundColorArgb = backgroundColor.takeIf { it.isSpecified }?.toArgb()?.toLong(),
textColorArgb = textColor.takeIf { it.isSpecified }?.toArgb()?.toLong()
)
}
fun readerThemeById(themeId: String?): ReaderTheme? {
return BuiltInReaderThemes.firstOrNull { it.id == themeId }
}
fun readerTextureDisplayName(textureId: String?): String {
return if (textureId == null) {
"None"
} else {
ReaderTexture.entries.firstOrNull { it.id == textureId }?.displayName
?: textureId
.removePrefix(ReaderTextureFilePrefix)
.substringAfterLast('/')
.substringAfterLast('\\')
.let { fileName -> fileName.substringBeforeLast('.', missingDelimiterValue = fileName) }
.ifBlank { "Custom Image" }
}
}
fun RenderMode.toReaderReadingMode(): ReaderReadingMode {
return when (this) {
RenderMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL
RenderMode.PAGINATED -> ReaderReadingMode.PAGINATED
}
}
fun ReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
return when (this) {
ReaderTextAlign.DEFAULT,
ReaderTextAlign.LEFT -> SharedReaderTextAlign.START
ReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
}
}
fun ReaderFont.toReaderSettingsFontFamily(): String {
return when (this) {
ReaderFont.ORIGINAL -> "Default"
ReaderFont.MERRIWEATHER,
ReaderFont.LORA -> "Serif"
ReaderFont.LATO,
ReaderFont.LEXEND -> "Sans"
ReaderFont.ROBOTO_MONO -> "Mono"
}
}
private object ReaderAppearanceDefaults {
const val fontSizePx = 18f
const val minFontSizePx = 12
const val maxFontSizePx = 42
const val lineSpacing = 1.45f
const val minLineSpacing = 1.0f
const val maxLineSpacing = 2.8f
const val marginPx = 48f
const val minMarginPx = 0
const val maxMarginPx = 160
const val minParagraphSpacing = 0.5f
const val maxParagraphSpacing = 2.5f
const val minImageScale = 0.5f
const val maxImageScale = 2.0f
}

View file

@ -0,0 +1,734 @@
package com.aryan.reader.shared
import com.aryan.reader.paginatedreader.SemanticBlock
import com.aryan.reader.paginatedreader.SemanticFlexContainer
import com.aryan.reader.paginatedreader.SemanticList
import com.aryan.reader.paginatedreader.SemanticTable
import com.aryan.reader.paginatedreader.SemanticTextBlock
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
import com.aryan.reader.shared.reader.ReaderPage
import com.aryan.reader.shared.reader.ReaderSessionState
import com.aryan.reader.shared.reader.SharedEpubBook
import com.aryan.reader.shared.reader.SharedEpubChapter
const val GEMINI_CLOUD_TTS_MODEL = "gemini-3.1-flash-live-preview"
const val GEMINI_CLOUD_TTS_MODEL_ID = "gemini:$GEMINI_CLOUD_TTS_MODEL"
const val DEFAULT_CLOUD_TTS_SPEAKER_ID = "Aoede"
const val READER_TTS_CHUNK_MAX_LENGTH = 250
data class ReaderCloudTtsVoice(
val id: String,
val name: String,
val description: String
)
enum class ReaderAiFeature(val displayName: String) {
DEFINE("Smart dictionary"),
SUMMARIZE("Summaries"),
RECAP("Recaps")
}
data class ReaderAiModelOption(
val provider: String,
val name: String,
val label: String = "${provider.replaceFirstChar { it.uppercaseChar() }} - $name"
) {
val id: String = "$provider:$name"
}
data class ReaderAiByokSettings(
val geminiKey: String = "",
val groqKey: String = "",
val useOneModel: Boolean = true,
val modelForAll: String = "",
val defineModel: String = "",
val summarizeModel: String = "",
val recapModel: String = "",
val ttsModel: String = "",
val hideReaderAiFeatures: Boolean = false,
val ttsSpeakerId: String = DEFAULT_CLOUD_TTS_SPEAKER_ID
) {
fun sanitized(): ReaderAiByokSettings {
val knownTextModelIds = ReaderAiModelOptions.mapTo(mutableSetOf()) { it.id }
return copy(
geminiKey = geminiKey.trim(),
groqKey = groqKey.trim(),
modelForAll = modelForAll.takeIf { it in knownTextModelIds }.orEmpty(),
defineModel = defineModel.takeIf { it in knownTextModelIds }.orEmpty(),
summarizeModel = summarizeModel.takeIf { it in knownTextModelIds }.orEmpty(),
recapModel = recapModel.takeIf { it in knownTextModelIds }.orEmpty(),
ttsModel = ttsModel.takeIf { it == GEMINI_CLOUD_TTS_MODEL_ID }.orEmpty(),
ttsSpeakerId = ttsSpeakerId.ifBlank { DEFAULT_CLOUD_TTS_SPEAKER_ID }
)
}
fun modelIdFor(feature: ReaderAiFeature): String {
return if (useOneModel) {
modelForAll
} else {
when (feature) {
ReaderAiFeature.DEFINE -> defineModel
ReaderAiFeature.SUMMARIZE -> summarizeModel
ReaderAiFeature.RECAP -> recapModel
}
}
}
fun apiKeyFor(provider: String): String {
return when (provider) {
"gemini" -> geminiKey
"groq" -> groqKey
else -> ""
}.trim()
}
val hasAnyAiKey: Boolean get() = geminiKey.isNotBlank() || groqKey.isNotBlank()
val areReaderAiFeaturesAvailable: Boolean get() = !hideReaderAiFeatures && hasAnyAiKey
val isCloudTtsAvailable: Boolean get() = geminiKey.isNotBlank() && ttsModel == GEMINI_CLOUD_TTS_MODEL_ID
}
val ReaderAiModelOptions = listOf(
ReaderAiModelOption("groq", "qwen/qwen3-32b"),
ReaderAiModelOption("groq", "llama-3.3-70b-versatile"),
ReaderAiModelOption("groq", "llama-3.1-8b-instant"),
ReaderAiModelOption("gemini", "gemma-4-26b-a4b-it"),
ReaderAiModelOption("gemini", "gemma-4-31b-it"),
ReaderAiModelOption("gemini", "gemini-flash-lite-latest"),
ReaderAiModelOption("gemini", "gemini-2.5-flash-lite"),
ReaderAiModelOption("gemini", "gemini-3.1-flash-lite-preview")
)
val ReaderCloudTtsVoices = listOf(
ReaderCloudTtsVoice("Zephyr", "Zephyr", "Bright, Higher pitch"),
ReaderCloudTtsVoice("Puck", "Puck", "Upbeat, Middle pitch"),
ReaderCloudTtsVoice("Charon", "Charon", "Informative, Lower pitch"),
ReaderCloudTtsVoice("Kore", "Kore", "Firm, Middle pitch"),
ReaderCloudTtsVoice("Fenrir", "Fenrir", "Excitable, Lower middle pitch"),
ReaderCloudTtsVoice("Leda", "Leda", "Youthful, Higher pitch"),
ReaderCloudTtsVoice("Orus", "Orus", "Firm, Lower middle pitch"),
ReaderCloudTtsVoice("Aoede", "Aoede", "Breezy, Middle pitch"),
ReaderCloudTtsVoice("Callirrhoe", "Callirrhoe", "Easy-going, Middle pitch"),
ReaderCloudTtsVoice("Autonoe", "Autonoe", "Bright, Middle pitch"),
ReaderCloudTtsVoice("Enceladus", "Enceladus", "Breathy, Lower pitch"),
ReaderCloudTtsVoice("Iapetus", "Iapetus", "Clear, Lower middle pitch"),
ReaderCloudTtsVoice("Umbriel", "Umbriel", "Easy-going, Lower middle pitch"),
ReaderCloudTtsVoice("Algieba", "Algieba", "Smooth, Lower pitch"),
ReaderCloudTtsVoice("Despina", "Despina", "Smooth, Middle pitch"),
ReaderCloudTtsVoice("Erinome", "Erinome", "Clear, Middle pitch"),
ReaderCloudTtsVoice("Algenib", "Algenib", "Gravelly, Lower pitch"),
ReaderCloudTtsVoice("Rasalgethi", "Rasalgethi", "Informative, Middle pitch"),
ReaderCloudTtsVoice("Laomedeia", "Laomedeia", "Upbeat, Higher pitch"),
ReaderCloudTtsVoice("Achernar", "Achernar", "Soft, Higher pitch"),
ReaderCloudTtsVoice("Alnilam", "Alnilam", "Firm, Lower middle pitch"),
ReaderCloudTtsVoice("Schedar", "Schedar", "Even, Lower middle pitch"),
ReaderCloudTtsVoice("Gacrux", "Gacrux", "Mature, Middle pitch"),
ReaderCloudTtsVoice("Pulcherrima", "Pulcherrima", "Forward, Middle pitch"),
ReaderCloudTtsVoice("Achird", "Achird", "Friendly, Lower middle pitch"),
ReaderCloudTtsVoice("Zubenelgenubi", "Zubenelgenubi", "Casual, Lower middle pitch"),
ReaderCloudTtsVoice("Vindemiatrix", "Vindemiatrix", "Gentle, Middle pitch"),
ReaderCloudTtsVoice("Sadachbia", "Sadachbia", "Lively, Lower pitch"),
ReaderCloudTtsVoice("Sadaltager", "Sadaltager", "Lively, Lower pitch"),
ReaderCloudTtsVoice("Sulafat", "Sulafat", "Warm, Middle pitch")
)
val ReaderCloudTtsSpeakers = ReaderCloudTtsVoices.map { it.id }
fun readerCloudTtsVoiceById(id: String): ReaderCloudTtsVoice? {
return ReaderCloudTtsVoices.firstOrNull { it.id == id }
}
fun formatReaderTtsBytes(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = listOf("KB", "MB", "GB", "TB", "PB")
var value = bytes.toDouble() / 1024.0
var unitIndex = 0
while (value >= 1024.0 && unitIndex < units.lastIndex) {
value /= 1024.0
unitIndex++
}
return "${(value * 10).toInt() / 10.0} ${units[unitIndex]}"
}
fun splitReaderTextIntoTtsChunks(
text: String,
maxLength: Int = READER_TTS_CHUNK_MAX_LENGTH
): List<String> {
if (text.isBlank()) return emptyList()
val sentenceBoundaryRegex = Regex("""(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!\n])\s+""")
val sentences = text.trim()
.split(sentenceBoundaryRegex)
.map { it.trim() }
.filter { it.isNotBlank() }
if (sentences.isEmpty()) return emptyList()
val chunks = mutableListOf<String>()
val currentChunk = StringBuilder()
fun flush() {
if (currentChunk.isNotEmpty()) {
chunks += currentChunk.toString()
currentChunk.clear()
}
}
sentences.forEach { sentence ->
if (sentence.length > maxLength) {
flush()
chunks += sentence
return@forEach
}
if (currentChunk.isNotEmpty() && currentChunk.length + sentence.length + 1 > maxLength) {
flush()
}
if (currentChunk.isNotEmpty()) currentChunk.append(' ')
currentChunk.append(sentence)
}
flush()
return chunks
}
fun readerAiModelById(id: String): ReaderAiModelOption? {
return ReaderAiModelOptions.firstOrNull { it.id == id }
}
fun maskedReaderAiKey(value: String): String {
val trimmed = value.trim()
return when {
trimmed.isBlank() -> ""
trimmed.length <= 6 -> "***"
else -> "${trimmed.take(3)}...${trimmed.takeLast(3)}"
}
}
enum class ReaderExternalLookupAction(val title: String) {
DICTIONARY("Dictionary"),
TRANSLATE("Translate"),
SEARCH("Search")
}
fun externalLookupUrl(action: ReaderExternalLookupAction, text: String): String {
val encoded = text.trim().urlEncoded()
return when (action) {
ReaderExternalLookupAction.DICTIONARY -> "https://www.google.com/search?q=define+$encoded"
ReaderExternalLookupAction.TRANSLATE -> "https://translate.google.com/?sl=auto&tl=en&text=$encoded&op=translate"
ReaderExternalLookupAction.SEARCH -> "https://www.google.com/search?q=$encoded"
}
}
data class ReaderAutoScrollState(
val enabled: Boolean = false,
val speed: Float = 36f
) {
fun sanitized(): ReaderAutoScrollState {
return copy(speed = speed.coerceIn(12f, 160f))
}
}
enum class ReaderTtsReadScope(val label: String) {
PAGE("Page"),
CHAPTER("Chapter"),
BOOK("From here")
}
data class ReaderTtsChunk(
val index: Int,
val pageIndex: Int,
val chapterIndex: Int,
val chapterTitle: String,
val text: String,
val startOffset: Int,
val endOffset: Int,
val sourceCfi: String? = null,
val spokenText: String = text
) {
fun toLocator(): ReaderLocator {
val boundedEnd = endOffset.coerceAtLeast(startOffset)
return ReaderLocator(
chapterIndex = chapterIndex,
pageIndex = pageIndex,
startOffset = startOffset,
endOffset = boundedEnd,
textQuote = text,
cfi = sourceCfi ?: "desktop:$chapterIndex:$startOffset:$boundedEnd"
)
}
fun toHighlight(sessionId: Long): UserHighlight {
val locator = toLocator()
return UserHighlight(
id = "tts_${sessionId}_$index",
cfi = locator.cfi.orEmpty(),
text = text,
color = HighlightColor.YELLOW,
chapterIndex = chapterIndex,
locator = locator
)
}
}
data class ReaderTtsProgress(
val sessionId: Long = 0L,
val scope: ReaderTtsReadScope = ReaderTtsReadScope.PAGE,
val chunks: List<ReaderTtsChunk> = emptyList(),
val currentChunkIndex: Int = -1
) {
val currentChunk: ReaderTtsChunk?
get() = chunks.getOrNull(currentChunkIndex)
val isActive: Boolean
get() = currentChunk != null
val currentPositionLabel: String?
get() = currentChunk?.let { chunk ->
"Part ${currentChunkIndex + 1}/${chunks.size} - ${chunk.chapterTitle.ifBlank { scope.label }}"
}
}
data class ReaderTtsCacheSummary(
val cachedChapterCount: Int = 0,
val cachedChunkCount: Int = 0,
val currentVoiceChunkCount: Int = 0,
val totalSizeBytes: Long = 0L,
val currentVoiceSizeBytes: Long = 0L
) {
val hasCachedAudio: Boolean get() = cachedChunkCount > 0
val hasCurrentVoiceCachedAudio: Boolean get() = currentVoiceChunkCount > 0
val currentVoiceLabel: String
get() = if (hasCurrentVoiceCachedAudio) {
"$currentVoiceChunkCount chunks, ${formatReaderTtsBytes(currentVoiceSizeBytes)}"
} else {
"No cached chunks for this voice"
}
}
object ReaderTtsPlanner {
fun chunksForCurrentPage(session: ReaderSessionState): List<ReaderTtsChunk> {
val page = session.reader.currentPage ?: return emptyList()
return chunksForPages(session.reader.book, listOf(page))
}
fun chunksForCurrentChapter(session: ReaderSessionState): List<ReaderTtsChunk> {
val page = session.reader.currentPage ?: return emptyList()
return chunksForPages(
session.reader.book,
session.reader.pages
.asSequence()
.filter { it.pageIndex >= page.pageIndex && it.chapterIndex == page.chapterIndex }
.toList()
)
}
fun chunksFromCurrentLocation(session: ReaderSessionState): List<ReaderTtsChunk> {
val pageIndex = session.reader.currentPageIndex
return chunksForPages(session.reader.book, session.reader.pages.drop(pageIndex.coerceAtLeast(0)))
}
fun chunksForText(
text: String,
pageIndex: Int,
chapterIndex: Int,
chapterTitle: String,
sourceStartOffset: Int = 0
): List<ReaderTtsChunk> {
return splitTextIntoRanges(text).mapIndexed { index, range ->
ReaderTtsChunk(
index = index,
pageIndex = pageIndex,
chapterIndex = chapterIndex,
chapterTitle = chapterTitle,
text = range.text,
startOffset = sourceStartOffset + range.start,
endOffset = sourceStartOffset + range.end
)
}
}
private fun chunksForPages(book: SharedEpubBook, pages: List<ReaderPage>): List<ReaderTtsChunk> {
var nextIndex = 0
return pages
.groupBy { it.chapterIndex }
.entries
.sortedBy { (chapterIndex, _) ->
pages.indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 } ?: Int.MAX_VALUE
}
.flatMap { chapterPages ->
val chapter = book.chapters.getOrNull(chapterPages.key)
val semanticChunks = chapter
?.let { chunksForSemanticPages(it, chapterPages.value) }
.orEmpty()
if (semanticChunks.isNotEmpty()) {
semanticChunks
} else {
chunksForPlainPages(book, chapterPages.value)
}
}
.distinctBy { "${it.sourceCfi}:${it.startOffset}:${it.endOffset}:${it.text}" }
.map { it.copy(index = nextIndex++) }
.toList()
}
private fun chunksForPlainPages(book: SharedEpubBook, pages: List<ReaderPage>): List<ReaderTtsChunk> {
return pages.flatMap { page ->
val chapterText = book.chapters
.getOrNull(page.chapterIndex)
?.normalizedTtsSourceText()
.orEmpty()
val sourceStartOffset = page.sourceTextStartOffset(chapterText)
splitTextIntoRanges(page.text).map { range ->
ReaderTtsChunk(
index = 0,
pageIndex = page.pageIndex,
chapterIndex = page.chapterIndex,
chapterTitle = page.chapterTitle,
text = range.text,
startOffset = sourceStartOffset + range.start,
endOffset = sourceStartOffset + range.end
)
}
}
}
private fun chunksForSemanticPages(
chapter: SharedEpubChapter,
pages: List<ReaderPage>
): List<ReaderTtsChunk> {
if (chapter.semanticBlocks.isEmpty() || pages.isEmpty()) return emptyList()
val ranges = pages.map { it.startOffset to it.endOffset }
val textBlocks = chapter.semanticBlocks.semanticTextBlocks()
.filter { block ->
block.cfi != null &&
block.text.isNotBlank() &&
ranges.any { (start, end) -> block.intersects(start, end) }
}
return textBlocks.flatMap { block ->
val blockStart = block.startCharOffsetInSource.coerceAtLeast(0)
splitTextIntoRanges(block.text).mapNotNull { range ->
val chunkStart = blockStart + range.start
val chunkEnd = blockStart + range.end
if (ranges.none { (start, end) -> chunkStart < end && chunkEnd > start }) return@mapNotNull null
val page = pages.firstOrNull { it.intersects(chunkStart, chunkEnd) }
?: pages.minByOrNull { kotlin.math.abs(it.startOffset - chunkStart) }
?: return@mapNotNull null
ReaderTtsChunk(
index = 0,
pageIndex = page.pageIndex,
chapterIndex = page.chapterIndex,
chapterTitle = page.chapterTitle,
text = range.text,
startOffset = chunkStart,
endOffset = chunkEnd,
sourceCfi = block.cfi
)
}
}
}
private fun List<SemanticBlock>.semanticTextBlocks(): List<SemanticTextBlock> {
val blocks = mutableListOf<SemanticTextBlock>()
fun visit(block: SemanticBlock) {
when (block) {
is SemanticTextBlock -> blocks += block
is SemanticFlexContainer -> block.children.forEach(::visit)
is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::visit) } }
is SemanticList -> block.items.forEach(::visit)
is SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::visit)
else -> Unit
}
}
forEach(::visit)
return blocks
}
private fun SemanticTextBlock.intersects(startOffset: Int, endOffset: Int): Boolean {
val start = startCharOffsetInSource
val end = start + text.length
return start < endOffset && end > startOffset
}
private fun ReaderPage.intersects(startOffset: Int, endOffset: Int): Boolean {
return startOffset < endOffset && startOffset < this.endOffset && endOffset > this.startOffset
}
private fun ReaderPage.sourceTextStartOffset(chapterText: String): Int {
if (chapterText.isBlank()) return startOffset
val boundedStart = startOffset.coerceIn(0, chapterText.length)
val boundedEnd = endOffset.coerceIn(boundedStart, chapterText.length)
val pageSlice = chapterText.substring(boundedStart, boundedEnd)
val trimAdjustedStart = boundedStart + pageSlice.leadingWhitespaceLength()
val exactTextStart = text
.takeIf { it.isNotBlank() }
?.let { needle ->
chapterText.indexOf(needle, startIndex = boundedStart)
.takeIf { found -> found >= boundedStart && found + needle.length <= boundedEnd }
}
if (exactTextStart != null) return exactTextStart
val trimmedTextStart = text
.trim()
.takeIf { it.isNotBlank() }
?.let { needle ->
chapterText.indexOf(needle, startIndex = boundedStart)
.takeIf { found -> found >= boundedStart && found + needle.length <= boundedEnd }
}
return trimmedTextStart ?: trimAdjustedStart
}
private fun String.leadingWhitespaceLength(): Int {
return length - trimStart().length
}
private fun SharedEpubChapter.normalizedTtsSourceText(): String {
return plainText
.replace("\r\n", "\n")
.replace(Regex("\\n{3,}"), "\n\n")
.trim()
}
private fun splitTextIntoRanges(
text: String,
maxLength: Int = READER_TTS_CHUNK_MAX_LENGTH
): List<ReaderTtsTextRange> {
val sourceStart = text.indexOfFirst { !it.isWhitespace() }
if (sourceStart < 0) return emptyList()
val sourceEnd = text.indexOfLast { !it.isWhitespace() } + 1
val source = text.substring(sourceStart, sourceEnd)
val sentenceRanges = androidStyleSentenceRanges(source, sourceStart)
if (sentenceRanges.isEmpty()) return emptyList()
val chunks = mutableListOf<ReaderTtsTextRange>()
var currentText = StringBuilder()
var currentStart = -1
var currentEnd = -1
fun flushCurrent() {
if (currentText.isNotEmpty() && currentStart >= 0 && currentEnd >= currentStart) {
chunks += ReaderTtsTextRange(
text = currentText.toString(),
start = currentStart,
end = currentStart + currentText.length
)
}
currentText = StringBuilder()
currentStart = -1
currentEnd = -1
}
for (sentence in sentenceRanges) {
if (sentence.text.length > maxLength) {
flushCurrent()
chunks += sentence
continue
}
if (currentText.isNotEmpty() && currentText.length + sentence.text.length + 1 > maxLength) {
flushCurrent()
currentText.append(sentence.text)
currentStart = sentence.start
currentEnd = sentence.end
} else {
if (currentText.isNotEmpty()) currentText.append(" ")
currentText.append(sentence.text)
if (currentStart < 0) currentStart = sentence.start
currentEnd = sentence.end
}
}
flushCurrent()
return chunks
}
private fun androidStyleSentenceRanges(source: String, sourceOffset: Int): List<ReaderTtsTextRange> {
val sentenceBoundaryRegex = Regex("""(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!\n])\s+""")
val ranges = mutableListOf<ReaderTtsTextRange>()
var start = 0
sentenceBoundaryRegex.findAll(source).forEach { match ->
val end = match.range.first
if (end > start) {
source.substring(start, end)
.takeIf { it.isNotBlank() }
?.let { sentence ->
ranges += ReaderTtsTextRange(
text = sentence,
start = sourceOffset + start,
end = sourceOffset + end
)
}
}
start = match.range.last + 1
}
if (start < source.length) {
val sentence = source.substring(start)
if (sentence.isNotBlank()) {
ranges += ReaderTtsTextRange(
text = sentence,
start = sourceOffset + start,
end = sourceOffset + source.length
)
}
}
return ranges
}
private data class ReaderTtsTextRange(
val text: String,
val start: Int,
val end: Int
)
}
data class ReaderCloudTtsState(
val isAvailable: Boolean = false,
val isPlaying: Boolean = false,
val isLoading: Boolean = false,
val isPaused: Boolean = false,
val statusMessage: String? = null,
val errorMessage: String? = null,
val progress: ReaderTtsProgress = ReaderTtsProgress(),
val cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary()
)
data class ReaderAiResultState(
val title: String? = null,
val text: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null
) {
val hasContent: Boolean get() = text.isNotBlank() || errorMessage != null || isLoading
}
data class ReaderExtrasState(
val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(),
val cloudTts: ReaderCloudTtsState = ReaderCloudTtsState(),
val aiResult: ReaderAiResultState = ReaderAiResultState()
)
data class ReaderByokTextRequest(
val model: ReaderAiModelOption,
val apiKey: String,
val systemInstruction: String,
val userPrompt: String,
val temperature: Double,
val maxTokens: Int
)
sealed interface ReaderByokTextRequestResult {
data class Ready(val request: ReaderByokTextRequest) : ReaderByokTextRequestResult
data class MissingModel(val featureName: String) : ReaderByokTextRequestResult
data class MissingKey(val provider: String) : ReaderByokTextRequestResult
data object Hidden : ReaderByokTextRequestResult
}
object ReaderByokTextRequests {
fun build(
settings: ReaderAiByokSettings,
feature: ReaderAiFeature,
text: String,
context: String? = null
): ReaderByokTextRequestResult {
val sanitized = settings.sanitized()
if (sanitized.hideReaderAiFeatures) return ReaderByokTextRequestResult.Hidden
val model = readerAiModelById(sanitized.modelIdFor(feature))
?: return ReaderByokTextRequestResult.MissingModel(feature.displayName)
val apiKey = sanitized.apiKeyFor(model.provider)
if (apiKey.isBlank()) return ReaderByokTextRequestResult.MissingKey(model.provider)
val prompt = promptFor(feature, text, context)
return ReaderByokTextRequestResult.Ready(
ReaderByokTextRequest(
model = model,
apiKey = apiKey,
systemInstruction = prompt.systemInstruction,
userPrompt = prompt.userPrompt,
temperature = prompt.temperature,
maxTokens = prompt.maxTokens
)
)
}
private fun promptFor(feature: ReaderAiFeature, text: String, context: String?): ReaderPrompt {
return when (feature) {
ReaderAiFeature.DEFINE -> ReaderPrompt(
systemInstruction = "You are a concise reading dictionary. Define the selected word or passage, explain nuance in context, and avoid unrelated commentary.",
userPrompt = buildString {
context?.takeIf { it.isNotBlank() }?.let {
append("Context:\n")
append(it.trim().take(3000))
append("\n\n")
}
append("Selection:\n")
append(text.trim())
},
temperature = 0.15,
maxTokens = 1024
)
ReaderAiFeature.SUMMARIZE -> ReaderPrompt(
systemInstruction = "You are an expert reading assistant. Summarize the provided passage clearly and concisely. Focus on the main ideas, plot points, and useful context. Do not add a preamble.",
userPrompt = text.trim(),
temperature = 0.2,
maxTokens = 4096
)
ReaderAiFeature.RECAP -> ReaderPrompt(
systemInstruction = "You are a reading assistant creating a recap up to the reader's current position. Synthesize prior context and current text into a cohesive recap. Conclude exactly where the reader is positioned. Do not add a preamble.",
userPrompt = text.trim(),
temperature = 0.3,
maxTokens = 4096
)
}
}
}
data class ReaderPrompt(
val systemInstruction: String,
val userPrompt: String,
val temperature: Double,
val maxTokens: Int
)
object ReaderContextExtractor {
fun currentPageText(session: ReaderSessionState, maxChars: Int = 6000): String {
return session.reader.currentPage?.text.orEmpty().trim().take(maxChars)
}
fun currentChapterText(session: ReaderSessionState, maxChars: Int = 20_000): String {
val chapterIndex = session.reader.currentPage?.chapterIndex ?: return currentPageText(session, maxChars)
return session.reader.book.chapters
.getOrNull(chapterIndex)
?.plainText
.orEmpty()
.trim()
.take(maxChars)
}
fun textBeforeCurrentLocation(session: ReaderSessionState, maxChars: Int = 24_000): String {
val page = session.reader.currentPage ?: return ""
val builder = StringBuilder()
session.reader.book.chapters.forEachIndexed { chapterIndex, chapter ->
when {
chapterIndex < page.chapterIndex -> {
builder.append(chapter.title).append('\n')
builder.append(chapter.plainText.trim()).append("\n\n")
}
chapterIndex == page.chapterIndex -> {
builder.append(chapter.title).append('\n')
builder.append(chapter.plainText.take(page.endOffset.coerceAtMost(chapter.plainText.length)).trim())
}
}
}
return builder.toString().trim().takeLast(maxChars)
}
}
private fun String.urlEncoded(): String {
val bytes = toByteArray(Charsets.UTF_8)
val builder = StringBuilder()
bytes.forEach { raw ->
val value = raw.toInt() and 0xFF
val char = value.toChar()
when {
value in 'A'.code..'Z'.code ||
value in 'a'.code..'z'.code ||
value in '0'.code..'9'.code ||
char in "-_.~" -> builder.append(char)
char == ' ' -> builder.append('+')
else -> builder.append('%').append(value.toString(16).uppercase().padStart(2, '0'))
}
}
return builder.toString()
}

View file

@ -0,0 +1,114 @@
package com.aryan.reader.shared
data class ReaderMarkdownDocument(
val blocks: List<ReaderMarkdownBlock>
)
sealed interface ReaderMarkdownBlock {
data class Heading(val level: Int, val text: String) : ReaderMarkdownBlock
data class Paragraph(val text: String) : ReaderMarkdownBlock
data class ListItems(val ordered: Boolean, val items: List<String>) : ReaderMarkdownBlock
data class CodeBlock(val text: String) : ReaderMarkdownBlock
data class Quote(val text: String) : ReaderMarkdownBlock
}
object ReaderMarkdownParser {
fun parse(markdown: String): ReaderMarkdownDocument {
val lines = markdown.replace("\r\n", "\n").split('\n')
val blocks = mutableListOf<ReaderMarkdownBlock>()
val paragraph = mutableListOf<String>()
var index = 0
fun flushParagraph() {
if (paragraph.isNotEmpty()) {
blocks += ReaderMarkdownBlock.Paragraph(paragraph.joinToString(" ").trim())
paragraph.clear()
}
}
while (index < lines.size) {
val line = lines[index]
val trimmed = line.trim()
when {
trimmed.isBlank() -> {
flushParagraph()
index += 1
}
trimmed.startsWith("```") -> {
flushParagraph()
val code = mutableListOf<String>()
index += 1
while (index < lines.size && !lines[index].trim().startsWith("```")) {
code += lines[index]
index += 1
}
if (index < lines.size) index += 1
blocks += ReaderMarkdownBlock.CodeBlock(code.joinToString("\n").trimEnd())
}
trimmed.headingLevel() != null -> {
flushParagraph()
val level = trimmed.headingLevel() ?: 1
blocks += ReaderMarkdownBlock.Heading(
level = level,
text = trimmed.drop(level).trim()
)
index += 1
}
trimmed.startsWith(">") -> {
flushParagraph()
val quote = mutableListOf<String>()
while (index < lines.size && lines[index].trim().startsWith(">")) {
quote += lines[index].trim().removePrefix(">").trim()
index += 1
}
blocks += ReaderMarkdownBlock.Quote(quote.joinToString(" ").trim())
}
trimmed.unorderedListText() != null || trimmed.orderedListText() != null -> {
flushParagraph()
val ordered = trimmed.orderedListText() != null
val items = mutableListOf<String>()
while (index < lines.size) {
val itemLine = lines[index].trim()
val item = if (ordered) itemLine.orderedListText() else itemLine.unorderedListText()
if (item == null) break
items += item
index += 1
}
blocks += ReaderMarkdownBlock.ListItems(ordered = ordered, items = items)
}
else -> {
paragraph += trimmed
index += 1
}
}
}
flushParagraph()
return ReaderMarkdownDocument(blocks)
}
}
private fun String.headingLevel(): Int? {
val count = takeWhile { it == '#' }.length
return count.takeIf { it in 1..6 && getOrNull(it) == ' ' }
}
private fun String.unorderedListText(): String? {
return if (length > 2 && first() in listOf('-', '*', '+') && this[1] == ' ') {
drop(2).trim()
} else {
null
}
}
private fun String.orderedListText(): String? {
val dotIndex = indexOf('.')
if (dotIndex <= 0 || dotIndex + 1 >= length || this[dotIndex + 1] != ' ') return null
return take(dotIndex).takeIf { number -> number.all { it.isDigit() } }
?.let { drop(dotIndex + 2).trim() }
}

View file

@ -0,0 +1,91 @@
package com.aryan.reader.shared
private val DefaultReaderBottomToolIds: Set<String>
get() = setOf(
ReaderTool.SLIDER.id,
ReaderTool.TOC.id,
ReaderTool.FORMAT.id,
ReaderTool.SEARCH.id,
ReaderTool.AI_FEATURES.id,
ReaderTool.TTS_CONTROLS.id
)
enum class ReaderTool(
val id: String,
val title: String,
val category: String,
val supportsDesktopQuickAction: Boolean = false
) {
DICTIONARY("dictionary", "External Apps", "Top Bar", supportsDesktopQuickAction = true),
THEME("theme", "Theme Settings", "Top Bar", supportsDesktopQuickAction = true),
SLIDER("slider", "Navigation Slider", "Bottom Bar"),
TOC("toc", "Sidebar", "Bottom Bar"),
FORMAT("format", "Text Formatting", "Bottom Bar"),
SEARCH("search", "Search", "Bottom Bar", supportsDesktopQuickAction = true),
AI_FEATURES("ai_features", "AI Features", "Bottom Bar", supportsDesktopQuickAction = true),
TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar", supportsDesktopQuickAction = true),
READING_MODE("reading_mode", "Reading Mode", "Overflow Menu"),
BOOKMARK("bookmark", "Bookmark", "Overflow Menu", supportsDesktopQuickAction = true),
TAP_TO_TURN("tap_to_turn", "Tap to Turn Pages", "Overflow Menu"),
VOLUME_SCROLL("volume_scroll", "Volume Button Scrolling", "Overflow Menu"),
PAGE_TURN_ANIM("page_turn_anim", "Realistic Page Turns", "Overflow Menu"),
KEEP_SCREEN_ON("keep_screen_on", "Keep Screen On", "Overflow Menu"),
VISUAL_OPTIONS("visual_options", "Visual Options", "Overflow Menu"),
AUTO_SCROLL("auto_scroll", "Auto Scroll", "Overflow Menu", supportsDesktopQuickAction = true),
TTS_SETTINGS("tts_settings", "TTS Voice Settings", "Overflow Menu"),
TTS_REPLACEMENTS("tts_replacements", "TTS Word Replacements", "Overflow Menu");
companion object {
fun fromId(id: String): ReaderTool? {
return entries.firstOrNull { it.id == id || it.name == id }
}
}
}
data class ReaderToolbarPreferences(
val hiddenToolIds: Set<String> = emptySet(),
val toolOrder: List<ReaderTool> = ReaderTool.entries.toList(),
val bottomToolIds: Set<String> = DefaultReaderBottomToolIds
) {
fun sanitized(): ReaderToolbarPreferences {
val orderedTools = (toolOrder + ReaderTool.entries.toList())
.distinct()
.filter { it in ReaderTool.entries }
val knownToolIds = ReaderTool.entries.mapTo(mutableSetOf()) { it.id }
return copy(
hiddenToolIds = hiddenToolIds.filterTo(mutableSetOf()) { it in knownToolIds },
toolOrder = orderedTools,
bottomToolIds = bottomToolIds.filterTo(mutableSetOf()) { it in knownToolIds }
)
}
fun isVisible(tool: ReaderTool): Boolean {
return tool.id !in hiddenToolIds
}
fun isBottom(tool: ReaderTool): Boolean {
return tool.id in bottomToolIds
}
fun withVisibility(tool: ReaderTool, hidden: Boolean): ReaderToolbarPreferences {
val nextHidden = if (hidden) hiddenToolIds + tool.id else hiddenToolIds - tool.id
return copy(hiddenToolIds = nextHidden).sanitized()
}
fun withBottomPlacement(tool: ReaderTool, bottom: Boolean): ReaderToolbarPreferences {
val nextBottom = if (bottom) bottomToolIds + tool.id else bottomToolIds - tool.id
return copy(bottomToolIds = nextBottom).sanitized()
}
fun withToolOrder(order: List<ReaderTool>): ReaderToolbarPreferences {
return copy(toolOrder = order).sanitized()
}
fun orderedVisibleTools(): List<ReaderTool> {
return sanitized().toolOrder.filter(::isVisible)
}
companion object {
val defaultBottomToolIds: Set<String> get() = DefaultReaderBottomToolIds
}
}

View file

@ -0,0 +1,353 @@
package com.aryan.reader.shared
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class ReaderTtsReplacementRule(
val id: String,
val from: String,
val to: String,
val enabled: Boolean = true,
val isRegex: Boolean = false,
val matchCase: Boolean = false,
val wholeWord: Boolean = true,
)
data class ReaderTtsReplacementBookSettings(
val localRulesEnabled: Boolean = true,
val globalRulesEnabled: Boolean = true,
val disabledGlobalRuleIds: Set<String> = emptySet(),
)
data class ReaderTtsReplacementPreferences(
val isEnabled: Boolean = true,
val globalRules: List<ReaderTtsReplacementRule> = emptyList(),
val bookRules: Map<String, List<ReaderTtsReplacementRule>> = emptyMap(),
val bookSettings: Map<String, ReaderTtsReplacementBookSettings> = emptyMap(),
) {
fun settingsForBook(bookId: String?): ReaderTtsReplacementBookSettings {
return bookSettings[bookId.orEmpty()] ?: ReaderTtsReplacementBookSettings()
}
fun rulesForBook(bookId: String?): List<ReaderTtsReplacementRule> {
return bookRules[bookId.orEmpty()].orEmpty()
}
fun activeRulesForBook(bookId: String?): List<ReaderTtsReplacementRule> {
if (!isEnabled) return emptyList()
val settings = settingsForBook(bookId)
val inherited = if (settings.globalRulesEnabled) {
globalRules.filter { it.id !in settings.disabledGlobalRuleIds }
} else {
emptyList()
}
val local = if (settings.localRulesEnabled) rulesForBook(bookId) else emptyList()
return inherited + local
}
fun withBookSettings(
bookId: String?,
settings: ReaderTtsReplacementBookSettings,
): ReaderTtsReplacementPreferences {
return copy(bookSettings = bookSettings + (bookId.orEmpty() to settings))
}
fun withBookRules(
bookId: String?,
rules: List<ReaderTtsReplacementRule>,
): ReaderTtsReplacementPreferences {
return copy(bookRules = bookRules + (bookId.orEmpty() to rules))
}
}
data class ReaderTtsReplacementValidation(
val isValid: Boolean,
val message: String? = null,
)
data class ReaderTtsReplacementError(
val ruleId: String,
val message: String,
)
data class ReaderTtsReplacementApplyResult(
val text: String,
val appliedRuleIds: List<String> = emptyList(),
val errors: List<ReaderTtsReplacementError> = emptyList(),
) {
val hasUnmappableChanges: Boolean
get() = appliedRuleIds.isNotEmpty()
}
object ReaderTtsReplacementEngine {
fun validate(rule: ReaderTtsReplacementRule): ReaderTtsReplacementValidation {
if (rule.from.isBlank()) {
return ReaderTtsReplacementValidation(isValid = false, message = "Enter text to replace.")
}
if (!rule.isRegex) {
return ReaderTtsReplacementValidation(isValid = true)
}
return runCatching { rule.toRegex() }
.fold(
onSuccess = { ReaderTtsReplacementValidation(isValid = true) },
onFailure = {
ReaderTtsReplacementValidation(
isValid = false,
message = it.message ?: "This regex is not valid.",
)
},
)
}
fun apply(
text: String,
preferences: ReaderTtsReplacementPreferences,
bookId: String? = null,
): ReaderTtsReplacementApplyResult {
if (text.isEmpty() || !preferences.isEnabled) {
return ReaderTtsReplacementApplyResult(text = text)
}
var current = text
val applied = mutableListOf<String>()
val errors = mutableListOf<ReaderTtsReplacementError>()
preferences.activeRulesForBook(bookId).forEach { rule ->
if (!rule.enabled || rule.from.isBlank()) return@forEach
val regex = runCatching { rule.toRegex() }
.onFailure {
errors += ReaderTtsReplacementError(
ruleId = rule.id,
message = it.message ?: "Invalid regex.",
)
}
.getOrNull() ?: return@forEach
val replacement = if (rule.isRegex) rule.to else Regex.escapeReplacement(rule.to)
val next = runCatching { regex.replace(current, replacement) }
.onFailure {
errors += ReaderTtsReplacementError(
ruleId = rule.id,
message = it.message ?: "Invalid replacement.",
)
}
.getOrNull() ?: return@forEach
if (next != current) {
applied += rule.id
current = next
}
}
return ReaderTtsReplacementApplyResult(
text = current,
appliedRuleIds = applied,
errors = errors,
)
}
private fun ReaderTtsReplacementRule.toRegex(): Regex {
val source = if (isRegex) from else Regex.escape(from)
val boundedSource = if (wholeWord) {
"""(?<![\p{L}\p{N}_])(?:$source)(?![\p{L}\p{N}_])"""
} else {
source
}
val options = if (matchCase) emptySet() else setOf(RegexOption.IGNORE_CASE)
return Regex(pattern = boundedSource, options = options)
}
}
fun ReaderTtsChunk.withTtsReplacements(
preferences: ReaderTtsReplacementPreferences,
bookId: String? = null,
): ReaderTtsChunk {
val result = ReaderTtsReplacementEngine.apply(
text = text,
preferences = preferences,
bookId = bookId,
)
return copy(spokenText = result.text)
}
fun List<ReaderTtsChunk>.withTtsReplacements(
preferences: ReaderTtsReplacementPreferences,
bookId: String? = null,
): List<ReaderTtsChunk> = map { it.withTtsReplacements(preferences, bookId) }
object ReaderTtsReplacementSuggestions {
val presets: List<ReaderTtsReplacementRule> = listOf(
ReaderTtsReplacementRule(
id = "suggestion_dr",
from = "Dr.",
to = "Doctor",
wholeWord = false,
),
ReaderTtsReplacementRule(
id = "suggestion_mr",
from = "Mr.",
to = "Mister",
wholeWord = false,
),
ReaderTtsReplacementRule(
id = "suggestion_mrs",
from = "Mrs.",
to = "Missus",
wholeWord = false,
),
ReaderTtsReplacementRule(
id = "suggestion_ms",
from = "Ms.",
to = "Miss",
wholeWord = false,
),
ReaderTtsReplacementRule(
id = "suggestion_vs",
from = "vs.",
to = "versus",
wholeWord = false,
),
ReaderTtsReplacementRule(
id = "suggestion_et_al",
from = "et al.",
to = "and others",
wholeWord = false,
),
ReaderTtsReplacementRule(
id = "suggestion_initials",
from = """\b([A-Z])\.\s*([A-Z])\.""",
to = "\$1 \$2",
isRegex = true,
wholeWord = false,
),
)
}
object ReaderTtsReplacementPreferencesJson {
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = false
}
fun encode(preferences: ReaderTtsReplacementPreferences): String {
return json.encodeToString(JsonElement.serializer(), toJsonElement(preferences))
}
fun decodeOrEmpty(raw: String?): ReaderTtsReplacementPreferences {
if (raw.isNullOrBlank()) return ReaderTtsReplacementPreferences()
return runCatching {
fromJsonElement(json.parseToJsonElement(raw))
}.getOrNull() ?: ReaderTtsReplacementPreferences()
}
fun toJsonElement(preferences: ReaderTtsReplacementPreferences): JsonElement {
return JsonObject(
mapOf(
"isEnabled" to JsonPrimitive(preferences.isEnabled),
"globalRules" to rulesToJson(preferences.globalRules),
"bookRules" to JsonObject(
preferences.bookRules.mapValues { (_, rules) -> rulesToJson(rules) as JsonElement },
),
"bookSettings" to JsonObject(
preferences.bookSettings.mapValues { (_, settings) -> settingsToJson(settings) as JsonElement },
),
),
)
}
fun fromJsonElement(element: JsonElement?): ReaderTtsReplacementPreferences {
val root = element as? JsonObject ?: return ReaderTtsReplacementPreferences()
val bookRules = root["bookRules"]?.jsonObjectOrNull()
?.mapValues { (_, value) -> value.jsonArrayOrNull()?.mapNotNull(::ruleFromJson).orEmpty() }
.orEmpty()
val bookSettings = root["bookSettings"]?.jsonObjectOrNull()
?.mapValues { (_, value) -> settingsFromJson(value) }
.orEmpty()
return ReaderTtsReplacementPreferences(
isEnabled = root.booleanValue("isEnabled") ?: true,
globalRules = root["globalRules"]?.jsonArrayOrNull()?.mapNotNull(::ruleFromJson).orEmpty(),
bookRules = bookRules,
bookSettings = bookSettings,
)
}
private fun rulesToJson(rules: List<ReaderTtsReplacementRule>): JsonArray {
return JsonArray(rules.map(::ruleToJson))
}
private fun ruleToJson(rule: ReaderTtsReplacementRule): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(rule.id),
"from" to JsonPrimitive(rule.from),
"to" to JsonPrimitive(rule.to),
"enabled" to JsonPrimitive(rule.enabled),
"isRegex" to JsonPrimitive(rule.isRegex),
"matchCase" to JsonPrimitive(rule.matchCase),
"wholeWord" to JsonPrimitive(rule.wholeWord),
),
)
}
private fun ruleFromJson(element: JsonElement): ReaderTtsReplacementRule? {
val root = element as? JsonObject ?: return null
val id = root.stringValue("id") ?: return null
val from = root.stringValue("from") ?: return null
return ReaderTtsReplacementRule(
id = id,
from = from,
to = root.stringValue("to").orEmpty(),
enabled = root.booleanValue("enabled") ?: true,
isRegex = root.booleanValue("isRegex") ?: false,
matchCase = root.booleanValue("matchCase") ?: false,
wholeWord = root.booleanValue("wholeWord") ?: true,
)
}
private fun settingsToJson(settings: ReaderTtsReplacementBookSettings): JsonObject {
return JsonObject(
mapOf(
"localRulesEnabled" to JsonPrimitive(settings.localRulesEnabled),
"globalRulesEnabled" to JsonPrimitive(settings.globalRulesEnabled),
"disabledGlobalRuleIds" to JsonArray(settings.disabledGlobalRuleIds.map(::JsonPrimitive)),
),
)
}
private fun settingsFromJson(element: JsonElement): ReaderTtsReplacementBookSettings {
val root = element as? JsonObject ?: return ReaderTtsReplacementBookSettings()
return ReaderTtsReplacementBookSettings(
localRulesEnabled = root.booleanValue("localRulesEnabled") ?: true,
globalRulesEnabled = root.booleanValue("globalRulesEnabled") ?: true,
disabledGlobalRuleIds = root["disabledGlobalRuleIds"]?.jsonArrayOrNull()
?.mapNotNull { it.jsonPrimitiveOrNull()?.contentOrNull }
?.toSet()
.orEmpty(),
)
}
private fun JsonObject.stringValue(name: String): String? {
return get(name)?.jsonPrimitiveOrNull()?.contentOrNull
}
private fun JsonObject.booleanValue(name: String): Boolean? {
return get(name)?.jsonPrimitiveOrNull()?.booleanOrNull
}
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray
private fun JsonElement.jsonPrimitiveOrNull() = when (this) {
is JsonPrimitive -> this
JsonNull -> null
else -> null
}
}

View file

@ -6,7 +6,8 @@ data class ImportedBookFile(
val name: String,
val uriString: String?,
val localPath: String?,
val size: Long
val size: Long,
val sourceFolder: String? = null
)
interface BookRepository {
@ -54,5 +55,7 @@ interface AiAdapter {
interface TtsAdapter {
val isAvailable: Boolean
suspend fun speak(text: String)
suspend fun pause() = Unit
suspend fun resume() = Unit
suspend fun stop()
}

View file

@ -33,6 +33,14 @@ fun BookItem.isOpdsStream(): Boolean {
return path?.startsWith("opds-pse://") == true
}
fun BookItem.matchesSourceFolders(sourceFolders: Set<String>): Boolean {
if (sourceFolders.isEmpty()) return true
val matchesInAppStorage = IN_APP_STORAGE_SOURCE in sourceFolders &&
sourceFolder == null &&
!isOpdsStream()
return matchesInAppStorage || sourceFolder in sourceFolders
}
private fun formatDecimal(value: Double, decimals: Int): String {
val factor = 10.0.pow(decimals)
val rounded = (value * factor).roundToInt() / factor

View file

@ -0,0 +1,618 @@
package com.aryan.reader.shared
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.floatOrNull
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.reader.ReaderBookmark
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.SharedReaderTextAlign
data class SharedLibrarySnapshot(
val books: List<BookItem> = emptyList(),
val shelfRecords: List<ShelfRecord> = emptyList(),
val shelfRefs: List<BookShelfRef> = emptyList(),
val tags: List<Tag> = emptyList(),
val customFonts: List<CustomFontItem> = emptyList(),
val syncedFolders: List<SyncedFolder> = emptyList(),
val recentFilesLimit: Int = 12,
val isTabsEnabled: Boolean = false,
val openTabIds: List<String> = emptyList(),
val activeTabBookId: String? = null,
val pinnedHomeBookIds: Set<String> = emptySet(),
val pinnedLibraryBookIds: Set<String> = emptySet(),
val useStrictFileFilter: Boolean = false,
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
val appTextDimFactorLight: Float = 1.0f,
val appTextDimFactorDark: Float = 1.0f,
val appSeedColor: Color? = null,
val customAppThemes: List<CustomAppTheme> = emptyList(),
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
)
object SharedLibrarySnapshotJson {
private const val SCHEMA_VERSION = 10
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
fun decodeOrEmpty(rawJson: String): SharedLibrarySnapshot {
val root = runCatching {
json.parseToJsonElement(rawJson).jsonObject
}.getOrNull() ?: return SharedLibrarySnapshot()
val schemaVersion = root.int("schemaVersion", 1)
val openTabIds = root.stringArray("openTabIds")
return SharedLibrarySnapshot(
books = root.array("books")
.mapNotNull { it.asBookItemOrNull() }
.migrateLegacyRecentState(schemaVersion, openTabIds),
shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
tags = root.array("tags").mapNotNull { it.asTagOrNull() },
customFonts = root.array("customFonts").mapNotNull { it.asCustomFontItemOrNull() },
syncedFolders = root.array("syncedFolders").mapNotNull { it.asSyncedFolderOrNull() },
recentFilesLimit = root.int("recentFilesLimit", 12),
isTabsEnabled = root.boolean("isTabsEnabled", false),
openTabIds = openTabIds,
activeTabBookId = root.string("activeTabBookId"),
pinnedHomeBookIds = root.stringArray("pinnedHomeBookIds").toSet(),
pinnedLibraryBookIds = root.stringArray("pinnedLibraryBookIds").toSet(),
useStrictFileFilter = root.boolean("useStrictFileFilter", false),
appThemeMode = root.string("appThemeMode")
?.let { runCatching { AppThemeMode.valueOf(it) }.getOrNull() }
?: AppThemeMode.SYSTEM,
appContrastOption = root.string("appContrastOption")
?.let { runCatching { AppContrastOption.valueOf(it) }.getOrNull() }
?: AppContrastOption.STANDARD,
appTextDimFactorLight = root.float("appTextDimFactorLight")
?: root.float("appTextDimFactor")
?: 1.0f,
appTextDimFactorDark = root.float("appTextDimFactorDark")
?: root.float("appTextDimFactor")
?: 1.0f,
appSeedColor = root.int("appSeedColor")?.let { Color(it) },
customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() },
readerToolbarPreferences = root["readerToolbarPreferences"]
?.takeUnless { it is JsonNull }
?.asReaderToolbarPreferencesOrNull()
?: ReaderToolbarPreferences(),
readerHighlightPalette = root["readerHighlightPalette"]
?.takeUnless { it is JsonNull }
?.asReaderHighlightPaletteOrNull()
?: ReaderHighlightPalette(),
readerTtsReplacementPreferences = root["readerTtsReplacementPreferences"]
?.takeUnless { it is JsonNull }
?.let { ReaderTtsReplacementPreferencesJson.fromJsonElement(it) }
?: ReaderTtsReplacementPreferences()
)
}
fun encode(snapshot: SharedLibrarySnapshot): String {
val root = JsonObject(
mapOf(
"schemaVersion" to JsonPrimitive(SCHEMA_VERSION),
"books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
"shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
"bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
"tags" to JsonArray(snapshot.tags.map { it.toJsonObject() }),
"customFonts" to JsonArray(snapshot.customFonts.map { it.toJsonObject() }),
"syncedFolders" to JsonArray(snapshot.syncedFolders.map { it.toJsonObject() }),
"recentFilesLimit" to JsonPrimitive(snapshot.recentFilesLimit),
"isTabsEnabled" to JsonPrimitive(snapshot.isTabsEnabled),
"openTabIds" to snapshot.openTabIds.asJsonArray(),
"activeTabBookId" to snapshot.activeTabBookId.asJson(),
"pinnedHomeBookIds" to snapshot.pinnedHomeBookIds.toList().asJsonArray(),
"pinnedLibraryBookIds" to snapshot.pinnedLibraryBookIds.toList().asJsonArray(),
"useStrictFileFilter" to JsonPrimitive(snapshot.useStrictFileFilter),
"appThemeMode" to JsonPrimitive(snapshot.appThemeMode.name),
"appContrastOption" to JsonPrimitive(snapshot.appContrastOption.name),
"appTextDimFactorLight" to JsonPrimitive(snapshot.appTextDimFactorLight),
"appTextDimFactorDark" to JsonPrimitive(snapshot.appTextDimFactorDark),
"appSeedColor" to snapshot.appSeedColor.asJson(),
"customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }),
"readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(),
"readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(),
"readerTtsReplacementPreferences" to ReaderTtsReplacementPreferencesJson.toJsonElement(
snapshot.readerTtsReplacementPreferences,
)
)
)
return json.encodeToString(JsonElement.serializer(), root)
}
}
private fun JsonObject.array(name: String): List<JsonElement> {
return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
}
private fun JsonObject.stringArray(name: String): List<String> {
return array(name).mapNotNull { element ->
runCatching { element.jsonPrimitive.content }.getOrNull()
}
}
private fun JsonObject.string(name: String): String? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
}
private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
return runCatching { this[name]?.jsonPrimitive?.longOrNull }.getOrNull() ?: fallback
}
private fun JsonObject.nullableLong(name: String): Long? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
}
private fun JsonObject.int(name: String): Int? {
return runCatching { this[name]?.jsonPrimitive?.content?.toIntOrNull() }.getOrNull()
}
private fun JsonObject.int(name: String, fallback: Int): Int {
return int(name) ?: fallback
}
private fun JsonObject.float(name: String): Float? {
return runCatching { this[name]?.jsonPrimitive?.floatOrNull }.getOrNull()
}
private fun JsonObject.double(name: String): Double? {
return runCatching { this[name]?.jsonPrimitive?.doubleOrNull }.getOrNull()
}
private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
return runCatching { this[name]?.jsonPrimitive?.booleanOrNull }.getOrNull() ?: fallback
}
private fun List<BookItem>.migrateLegacyRecentState(schemaVersion: Int, openTabIds: List<String>): List<BookItem> {
if (schemaVersion >= 3) return this
val openedBookIds = openTabIds.toSet()
return map { book ->
if (book.isRecent && !book.hasReaderFootprint(openedBookIds)) {
book.copy(isRecent = false)
} else {
book
}
}
}
private fun BookItem.hasReaderFootprint(openedBookIds: Set<String>): Boolean {
return id in openedBookIds ||
lastPageIndex != null ||
(progressPercentage ?: 0f) > 0f ||
readerSettings != null ||
readerBookmarks.isNotEmpty() ||
readerHighlights.isNotEmpty()
}
private fun JsonElement.asBookItemOrNull(): BookItem? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val id = obj.string("id") ?: return null
val displayName = obj.string("displayName") ?: return null
val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
return BookItem(
id = id,
path = obj.string("path"),
type = type,
displayName = displayName,
timestamp = obj.long("timestamp"),
coverImagePath = obj.string("coverImagePath"),
title = obj.string("title"),
author = obj.string("author"),
progressPercentage = obj.float("progressPercentage"),
isRecent = obj.boolean("isRecent", true),
fileSize = obj.long("fileSize"),
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"),
readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(),
readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() },
readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() }
)
}
private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return ShelfRecord(
id = obj.string("id") ?: return null,
name = obj.string("name") ?: return null,
isSmart = obj.boolean("isSmart", false),
smartRulesJson = obj.string("smartRulesJson")
)
}
private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return BookShelfRef(
bookId = obj.string("bookId") ?: return null,
shelfId = obj.string("shelfId") ?: return null,
addedAt = obj.long("addedAt")
)
}
private fun JsonElement.asTagOrNull(): Tag? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return Tag(
id = obj.string("id") ?: return null,
name = obj.string("name") ?: return null,
color = runCatching {
obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
}.getOrNull()
)
}
private fun JsonElement.asCustomFontItemOrNull(): CustomFontItem? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return CustomFontItem(
id = obj.string("id") ?: return null,
displayName = obj.string("displayName") ?: return null,
fileName = obj.string("fileName") ?: return null,
fileExtension = obj.string("fileExtension") ?: return null,
path = obj.string("path") ?: return null,
timestamp = obj.long("timestamp"),
isDeleted = obj.boolean("isDeleted", false)
)
}
private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return SyncedFolder(
uriString = obj.string("uriString") ?: return null,
name = obj.string("name") ?: return null,
lastScanTime = obj.long("lastScanTime"),
allowedFileTypes = obj.stringArray("allowedFileTypes")
.mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() }
.toSet()
.ifEmpty { FileType.entries.toSet() }
)
}
private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return CustomAppTheme(
id = obj.string("id") ?: return null,
name = obj.string("name") ?: return null,
seedColor = obj.int("seedColor")?.let { Color(it) } ?: return null
)
}
private fun BookItem.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"path" to path.asJson(),
"type" to JsonPrimitive(type.name),
"displayName" to JsonPrimitive(displayName),
"timestamp" to JsonPrimitive(timestamp),
"coverImagePath" to coverImagePath.asJson(),
"title" to title.asJson(),
"author" to author.asJson(),
"progressPercentage" to progressPercentage.asJson(),
"isRecent" to JsonPrimitive(isRecent),
"fileSize" to JsonPrimitive(fileSize),
"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(),
"readerSettings" to readerSettings.asJson(),
"readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }),
"readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() })
)
)
}
private fun ShelfRecord.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"name" to JsonPrimitive(name),
"isSmart" to JsonPrimitive(isSmart),
"smartRulesJson" to smartRulesJson.asJson()
)
)
}
private fun BookShelfRef.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"bookId" to JsonPrimitive(bookId),
"shelfId" to JsonPrimitive(shelfId),
"addedAt" to JsonPrimitive(addedAt)
)
)
}
private fun Tag.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"name" to JsonPrimitive(name),
"color" to color.asJson()
)
)
}
private fun CustomFontItem.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"displayName" to JsonPrimitive(displayName),
"fileName" to JsonPrimitive(fileName),
"fileExtension" to JsonPrimitive(fileExtension),
"path" to JsonPrimitive(path),
"timestamp" to JsonPrimitive(timestamp),
"isDeleted" to JsonPrimitive(isDeleted)
)
)
}
private fun SyncedFolder.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"uriString" to JsonPrimitive(uriString),
"name" to JsonPrimitive(name),
"lastScanTime" to JsonPrimitive(lastScanTime),
"allowedFileTypes" to allowedFileTypes.map { it.name }.sorted().asJsonArray()
)
)
}
private fun CustomAppTheme.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"name" to JsonPrimitive(name),
"seedColor" to JsonPrimitive(seedColor.toArgb())
)
)
}
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Long?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Color?.asJson(): JsonElement = this?.let { JsonPrimitive(it.toArgb()) } ?: JsonNull
private fun List<String>.asJsonArray(): JsonArray {
return JsonArray(map { JsonPrimitive(it) })
}
private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val defaults = ReaderSettings()
return ReaderSettings(
fontSize = obj.int("fontSize") ?: defaults.fontSize,
lineSpacing = obj.float("lineSpacing") ?: defaults.lineSpacing,
margin = obj.int("margin") ?: defaults.margin,
darkMode = obj.boolean("darkMode", defaults.darkMode),
readingMode = obj.string("readingMode")
?.let { runCatching { ReaderReadingMode.valueOf(it) }.getOrNull() }
?: defaults.readingMode,
textAlign = obj.string("textAlign")
?.let { runCatching { SharedReaderTextAlign.valueOf(it) }.getOrNull() }
?: defaults.textAlign,
pageWidth = obj.int("pageWidth") ?: defaults.pageWidth,
fontFamily = obj.string("fontFamily") ?: defaults.fontFamily,
paragraphSpacing = obj.float("paragraphSpacing") ?: defaults.paragraphSpacing,
imageScale = obj.float("imageScale") ?: defaults.imageScale,
horizontalMargin = obj.int("horizontalMargin"),
verticalMargin = obj.int("verticalMargin"),
themeId = obj.string("themeId"),
textureId = obj.string("textureId"),
textureAlpha = obj.float("textureAlpha") ?: defaults.textureAlpha,
customFontPath = obj.string("customFontPath"),
backgroundColorArgb = obj.nullableLong("backgroundColorArgb"),
textColorArgb = obj.nullableLong("textColorArgb"),
systemUiMode = obj.string("systemUiMode")
?.let { runCatching { SystemUiMode.valueOf(it) }.getOrNull() }
?: defaults.systemUiMode,
pageInfoMode = obj.string("pageInfoMode")
?.let { runCatching { PageInfoMode.valueOf(it) }.getOrNull() }
?: defaults.pageInfoMode,
pageInfoPosition = obj.string("pageInfoPosition")
?.let { runCatching { PageInfoPosition.valueOf(it) }.getOrNull() }
?: defaults.pageInfoPosition,
seamlessChapterNavigation = obj.boolean("seamlessChapterNavigation", defaults.seamlessChapterNavigation),
chapterTurnDragMultiplier = obj.float("chapterTurnDragMultiplier") ?: defaults.chapterTurnDragMultiplier
)
}
private fun JsonElement.asReaderToolbarPreferencesOrNull(): ReaderToolbarPreferences? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val order = obj.stringArray("toolOrder").mapNotNull(ReaderTool::fromId)
val bottomToolIds = if (obj["bottomToolIds"] == null) {
ReaderToolbarPreferences.defaultBottomToolIds
} else {
obj.stringArray("bottomToolIds").toSet()
}
return ReaderToolbarPreferences(
hiddenToolIds = obj.stringArray("hiddenToolIds").toSet(),
toolOrder = order.ifEmpty { ReaderTool.entries.toList() },
bottomToolIds = bottomToolIds
).sanitized()
}
private fun JsonElement.asReaderHighlightPaletteOrNull(): ReaderHighlightPalette? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val colors = obj.stringArray("colorIds")
.mapNotNull { colorId -> HighlightColor.entries.firstOrNull { it.id == colorId || it.name == colorId } }
return ReaderHighlightPalette(colors = colors).sanitized()
}
private fun JsonElement.asReaderBookmarkOrNull(): ReaderBookmark? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val pageIndex = obj.int("pageIndex") ?: return null
return ReaderBookmark(
id = obj.string("id") ?: return null,
pageIndex = pageIndex,
chapterTitle = obj.string("chapterTitle") ?: "",
preview = obj.string("preview") ?: "",
locator = obj["locator"]
?.takeUnless { it is JsonNull }
?.asReaderLocatorOrNull()
?.withFallbacks(pageIndex = pageIndex, textQuote = obj.string("preview") ?: "")
?: ReaderLocator(
pageIndex = pageIndex,
textQuote = obj.string("preview") ?: ""
)
)
}
private fun JsonElement.asReaderHighlightOrNull(): UserHighlight? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val cfi = obj.string("cfi") ?: return null
val text = obj.string("text") ?: return null
val chapterIndex = obj.int("chapterIndex") ?: return null
val color = obj.string("colorId")
?.let { colorId -> HighlightColor.entries.firstOrNull { it.id == colorId } }
?: HighlightColor.YELLOW
return UserHighlight(
id = obj.string("id") ?: return null,
cfi = cfi,
text = text,
color = color,
chapterIndex = chapterIndex,
note = obj.string("note")?.takeIf { it.isNotBlank() },
locator = obj["locator"]
?.takeUnless { it is JsonNull }
?.asReaderLocatorOrNull()
?.withFallbacks(
chapterIndex = chapterIndex,
cfi = cfi,
textQuote = text
)
?: ReaderLocator.fromLegacy(
chapterIndex = chapterIndex,
cfi = cfi,
textQuote = text
)
)
}
private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return ReaderLocator(
chapterIndex = obj.int("chapterIndex"),
chapterId = obj.string("chapterId"),
href = obj.string("href"),
pageIndex = obj.int("pageIndex"),
startOffset = obj.int("startOffset"),
endOffset = obj.int("endOffset"),
textQuote = obj.string("textQuote"),
cfi = obj.string("cfi")
)
}
private fun ReaderSettings?.asJson(): JsonElement {
val settings = this ?: return JsonNull
return JsonObject(
mapOf(
"fontSize" to JsonPrimitive(settings.fontSize),
"lineSpacing" to JsonPrimitive(settings.lineSpacing),
"margin" to JsonPrimitive(settings.margin),
"darkMode" to JsonPrimitive(settings.darkMode),
"readingMode" to JsonPrimitive(settings.readingMode.name),
"textAlign" to JsonPrimitive(settings.textAlign.name),
"pageWidth" to JsonPrimitive(settings.pageWidth),
"fontFamily" to JsonPrimitive(settings.fontFamily),
"paragraphSpacing" to JsonPrimitive(settings.paragraphSpacing),
"imageScale" to JsonPrimitive(settings.imageScale),
"horizontalMargin" to settings.horizontalMargin.asJson(),
"verticalMargin" to settings.verticalMargin.asJson(),
"themeId" to settings.themeId.asJson(),
"textureId" to settings.textureId.asJson(),
"textureAlpha" to JsonPrimitive(settings.textureAlpha),
"customFontPath" to settings.customFontPath.asJson(),
"backgroundColorArgb" to settings.backgroundColorArgb.asJson(),
"textColorArgb" to settings.textColorArgb.asJson(),
"systemUiMode" to JsonPrimitive(settings.systemUiMode.name),
"pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name),
"pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name),
"seamlessChapterNavigation" to JsonPrimitive(settings.seamlessChapterNavigation),
"chapterTurnDragMultiplier" to JsonPrimitive(settings.chapterTurnDragMultiplier)
)
)
}
private fun ReaderToolbarPreferences.toJsonObject(): JsonObject {
val sanitized = sanitized()
return JsonObject(
mapOf(
"hiddenToolIds" to sanitized.hiddenToolIds.toList().sorted().asJsonArray(),
"toolOrder" to sanitized.toolOrder.map { it.id }.asJsonArray(),
"bottomToolIds" to sanitized.bottomToolIds.toList().sorted().asJsonArray()
)
)
}
private fun ReaderHighlightPalette.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"colorIds" to sanitized().colors.map { it.id }.asJsonArray()
)
)
}
private fun ReaderBookmark.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"pageIndex" to JsonPrimitive(pageIndex),
"chapterTitle" to JsonPrimitive(chapterTitle),
"preview" to JsonPrimitive(preview),
"locator" to locator.toJsonObject()
)
)
}
private fun UserHighlight.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"cfi" to JsonPrimitive(cfi),
"text" to JsonPrimitive(text),
"colorId" to JsonPrimitive(color.id),
"chapterIndex" to JsonPrimitive(chapterIndex),
"note" to note.asJson(),
"locator" to locator.toJsonObject()
)
)
}
private fun ReaderLocator.toJsonObject(): JsonObject {
return JsonObject(
buildMap {
chapterIndex?.let { put("chapterIndex", JsonPrimitive(it)) }
chapterId?.let { put("chapterId", JsonPrimitive(it)) }
href?.let { put("href", JsonPrimitive(it)) }
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
cfi?.let { put("cfi", JsonPrimitive(it)) }
}
)
}

View file

@ -1,5 +1,8 @@
package com.aryan.reader.shared
import com.aryan.reader.shared.reader.ReaderEngine
import com.aryan.reader.shared.reader.ReaderSessionState
fun LibraryState.reduce(action: LibraryAction): LibraryState {
return when (action) {
is LibraryAction.SearchChanged -> copy(searchQuery = action.query)
@ -56,7 +59,122 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
is AppAction.NavigationRequested -> this
is AppAction.AppThemeChanged -> copy(appThemeMode = action.mode)
is AppAction.AppContrastChanged -> copy(appContrastOption = action.option)
is AppAction.AppTextDimFactorLightChanged -> copy(appTextDimFactorLight = action.factor.coerceIn(0.3f, 1.0f))
is AppAction.AppTextDimFactorDarkChanged -> copy(appTextDimFactorDark = action.factor.coerceIn(0.3f, 1.0f))
is AppAction.AppSeedColorChanged -> copy(appSeedColor = action.color)
is AppAction.CustomAppThemeAdded -> {
val updatedThemes = customAppThemes.filterNot { it.id == action.theme.id } + action.theme
copy(customAppThemes = updatedThemes, appSeedColor = action.theme.seedColor)
}
is AppAction.CustomAppThemeDeleted -> {
val updatedThemes = customAppThemes.filterNot { it.id == action.themeId }
val shouldClearSeed = appSeedColor != null && updatedThemes.none { it.seedColor == appSeedColor }
copy(
customAppThemes = updatedThemes,
appSeedColor = if (shouldClearSeed) null else appSeedColor
)
}
is AppAction.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled)
is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled)
is AppAction.TabsEnabledChanged -> copy(
isTabsEnabled = action.enabled,
openTabIds = if (action.enabled) openTabIds else emptyList(),
activeTabBookId = if (action.enabled) activeTabBookId else null
)
is AppAction.BookTabOpened -> {
val bookId = action.bookId.trim()
if (bookId.isBlank()) {
this
} else {
copy(
isTabsEnabled = true,
openTabIds = (openTabIds - bookId) + bookId,
activeTabBookId = bookId
)
}
}
is AppAction.BookTabClosed -> {
val remaining = openTabIds.filterNot { it == action.bookId }
copy(
openTabIds = remaining,
activeTabBookId = if (activeTabBookId == action.bookId) remaining.lastOrNull() else activeTabBookId
)
}
AppAction.AllTabsClosed -> copy(openTabIds = emptyList(), activeTabBookId = null)
is AppAction.HomePinToggled -> copy(
pinnedHomeBookIds = if (action.bookId in pinnedHomeBookIds) {
pinnedHomeBookIds - action.bookId
} else {
pinnedHomeBookIds + action.bookId
}
)
is AppAction.LibraryPinToggled -> copy(
pinnedLibraryBookIds = if (action.bookId in pinnedLibraryBookIds) {
pinnedLibraryBookIds - action.bookId
} else {
pinnedLibraryBookIds + action.bookId
}
)
is AppAction.ReaderToolbarPreferencesChanged -> copy(
readerToolbarPreferences = action.preferences.sanitized()
)
is AppAction.ReaderToolVisibilityChanged -> copy(
readerToolbarPreferences = readerToolbarPreferences.withVisibility(action.tool, action.hidden)
)
is AppAction.ReaderToolPlacementChanged -> copy(
readerToolbarPreferences = readerToolbarPreferences.withBottomPlacement(action.tool, action.bottom)
)
is AppAction.ReaderToolOrderChanged -> copy(
readerToolbarPreferences = readerToolbarPreferences.withToolOrder(action.toolOrder)
)
is AppAction.ReaderHighlightPaletteChanged -> copy(
readerHighlightPalette = action.palette.sanitized()
)
is AppAction.ReaderTtsReplacementPreferencesChanged -> copy(
readerTtsReplacementPreferences = action.preferences
)
}
}
fun ReaderSessionState.reduce(action: ReaderAction, readerEngine: ReaderEngine): ReaderSessionState {
return when (action) {
ReaderAction.NextPage -> readerEngine.next(this)
ReaderAction.PreviousPage -> readerEngine.previous(this)
is ReaderAction.GoToPage -> readerEngine.goToPage(this, action.pageIndex)
is ReaderAction.GoToPageNumber -> readerEngine.goToPageNumber(this, action.pageNumber)
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.VisiblePageChanged -> readerEngine.syncVisiblePage(this, action.pageIndex, action.locator)
is ReaderAction.GoToSearchResult -> readerEngine.goToSearchResult(this, action.resultIndex)
is ReaderAction.SearchChanged -> readerEngine.search(this, action.query)
ReaderAction.SearchOpened -> readerEngine.openSearch(this)
ReaderAction.SearchClosed -> readerEngine.closeSearch(this)
ReaderAction.SearchResultsPanelToggled -> readerEngine.toggleSearchResultsPanel(this)
is ReaderAction.SearchOptionsChanged -> readerEngine.updateSearchOptions(this, action.options)
ReaderAction.NextSearchResult -> readerEngine.nextSearchResult(this)
ReaderAction.PreviousSearchResult -> readerEngine.previousSearchResult(this)
ReaderAction.ToggleBookmark -> readerEngine.toggleBookmark(this)
is ReaderAction.ToggleBookmarkAtLocator -> readerEngine.toggleBookmarkAtLocator(
state = this,
locator = action.locator,
chapterTitle = action.title,
preview = action.preview
)
is ReaderAction.SettingsChanged -> readerEngine.updateSettings(this, action.settings)
is ReaderAction.RenderModeChanged -> readerEngine.updateSettings(
this,
reader.settings.copy(readingMode = action.renderMode.toReaderReadingMode())
)
is ReaderAction.ThemeChanged -> readerEngine.updateSettings(this, action.theme.toReaderSettings(reader.settings))
is ReaderAction.FormatChanged -> readerEngine.updateSettings(this, action.settings.toReaderSettings(reader.settings))
is ReaderAction.HighlightCreated -> readerEngine.upsertHighlight(this, action.highlight)
is ReaderAction.HighlightUpdated -> readerEngine.updateHighlight(
state = this,
highlightId = action.highlightId,
color = action.color,
note = action.note
)
is ReaderAction.HighlightDeleted -> readerEngine.deleteHighlight(this, action.highlightId)
}
}

View file

@ -0,0 +1,97 @@
package com.aryan.reader.shared
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
enum class SmartField {
TITLE,
AUTHOR,
PROGRESS,
FILE_TYPE,
FOLDER,
TAG
}
@Serializable
enum class SmartOperator {
EQUALS,
CONTAINS,
GREATER_THAN,
LESS_THAN
}
@Serializable
data class SmartRule(
val field: SmartField,
val operator: SmartOperator,
val value: String
)
@Serializable
data class SmartCollectionDefinition(
val matchAll: Boolean = true,
val rules: List<SmartRule> = emptyList()
)
object SmartCollectionEngine {
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition)
fun fromJson(rawJson: String?): SmartCollectionDefinition? {
if (rawJson.isNullOrBlank()) return null
return runCatching {
json.decodeFromString<SmartCollectionDefinition>(rawJson)
}.getOrNull()
}
fun evaluate(book: BookItem, definition: SmartCollectionDefinition): Boolean {
if (definition.rules.isEmpty()) return false
val results = definition.rules.map { rule ->
when (rule.field) {
SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule)
SmartField.AUTHOR -> evaluateString(book.author.orEmpty(), rule)
SmartField.FILE_TYPE -> evaluateString(book.type.name, rule)
SmartField.FOLDER -> evaluateString(book.sourceFolder.orEmpty(), rule)
SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule)
SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule)
}
}
return if (definition.matchAll) results.all { it } else results.any { it }
}
private fun evaluateString(target: String, rule: SmartRule): Boolean {
return when (rule.operator) {
SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true)
SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true)
SmartOperator.GREATER_THAN,
SmartOperator.LESS_THAN -> false
}
}
private fun evaluateNumber(target: Float, rule: SmartRule): Boolean {
val ruleValue = rule.value.toFloatOrNull() ?: return false
return when (rule.operator) {
SmartOperator.EQUALS -> target == ruleValue
SmartOperator.GREATER_THAN -> target > ruleValue
SmartOperator.LESS_THAN -> target < ruleValue
SmartOperator.CONTAINS -> false
}
}
private fun evaluateTags(tags: List<String>, rule: SmartRule): Boolean {
return when (rule.operator) {
SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) }
SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) }
SmartOperator.GREATER_THAN,
SmartOperator.LESS_THAN -> false
}
}
}

View file

@ -0,0 +1,144 @@
package com.aryan.reader.shared.opds
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
object SharedOpdsCatalogs {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
fun defaultCatalogs(idFactory: () -> String): List<OpdsCatalog> {
return listOf(
OpdsCatalog(
id = idFactory(),
title = "Project Gutenberg",
url = "https://m.gutenberg.org/ebooks.opds/",
isDefault = true
),
OpdsCatalog(
id = idFactory(),
title = "Standard Ebooks",
url = "https://standardebooks.org/feeds/opds",
isDefault = true
)
)
}
fun decode(rawJson: String?): List<OpdsCatalog> {
if (rawJson.isNullOrBlank()) return emptyList()
return runCatching {
json.parseToJsonElement(rawJson)
.jsonArray
.mapNotNull { it.asCatalogOrNull() }
}.getOrDefault(emptyList())
}
fun decodeOrSeed(rawJson: String?, idFactory: () -> String): List<OpdsCatalog> {
return decode(rawJson).ifEmpty { defaultCatalogs(idFactory) }
}
fun encode(catalogs: List<OpdsCatalog>): String {
val array = JsonArray(catalogs.map { it.toJsonObject() })
return json.encodeToString(JsonElement.serializer(), array)
}
fun addCatalog(
catalogs: List<OpdsCatalog>,
title: String,
url: String,
username: String?,
password: String?,
idFactory: () -> String
): List<OpdsCatalog> {
val normalizedTitle = title.trim()
val normalizedUrl = url.trim()
if (normalizedTitle.isBlank() || normalizedUrl.isBlank()) return catalogs
return catalogs + OpdsCatalog(
id = idFactory(),
title = normalizedTitle,
url = normalizedUrl,
username = username.normalizedCredential(),
password = password.normalizedCredential()
)
}
fun updateCatalog(
catalogs: List<OpdsCatalog>,
id: String,
title: String,
url: String,
username: String?,
password: String?
): List<OpdsCatalog> {
return catalogs.map { catalog ->
if (catalog.id != id || catalog.isDefault) {
catalog
} else {
catalog.copy(
title = title.trim(),
url = url.trim(),
username = username.normalizedCredential(),
password = password.normalizedCredential()
)
}
}
}
fun removeCatalog(catalogs: List<OpdsCatalog>, id: String): List<OpdsCatalog> {
val catalog = catalogs.firstOrNull { it.id == id }
if (catalog?.isDefault == true) return catalogs
return catalogs.filterNot { it.id == id }
}
private fun JsonElement.asCatalogOrNull(): OpdsCatalog? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val id = obj.string("id") ?: return null
val title = obj.string("title") ?: return null
val url = obj.string("url") ?: return null
return OpdsCatalog(
id = id,
title = title,
url = url,
isDefault = obj.boolean("isDefault") ?: false,
username = obj.string("username").normalizedCredential(),
password = obj.string("password").normalizedCredential()
)
}
private fun OpdsCatalog.toJsonObject(): JsonObject {
return JsonObject(
buildMap {
put("id", JsonPrimitive(id))
put("title", JsonPrimitive(title))
put("url", JsonPrimitive(url))
put("isDefault", JsonPrimitive(isDefault))
put("username", username?.let(::JsonPrimitive) ?: JsonNull)
put("password", password?.let(::JsonPrimitive) ?: JsonNull)
}
)
}
private fun JsonObject.string(name: String): String? {
val value = this[name]?.takeUnless { it is JsonNull } ?: return null
return runCatching { value.jsonPrimitive.contentOrNull }.getOrNull()
}
private fun JsonObject.boolean(name: String): Boolean? {
return runCatching { this[name]?.jsonPrimitive?.booleanOrNull }.getOrNull()
}
private fun String?.normalizedCredential(): String? {
return this?.trim()?.takeIf { it.isNotBlank() }
}
}

View file

@ -0,0 +1,162 @@
package com.aryan.reader.shared.opds
class SharedOpdsController(
private val repository: SharedOpdsRepository,
private val idFactory: () -> String
) {
private val urlStack = mutableListOf<String>()
var state: SharedOpdsScreenState = SharedOpdsScreenState(catalogs = repository.loadCatalogs())
private set
fun reloadCatalogs(): SharedOpdsScreenState {
state = state.copy(catalogs = repository.loadCatalogs())
return state
}
fun addCatalog(title: String, url: String, username: String?, password: String?): SharedOpdsScreenState {
val nextCatalogs = SharedOpdsCatalogs.addCatalog(
catalogs = repository.loadCatalogs(),
title = title,
url = url,
username = username,
password = password,
idFactory = idFactory
)
repository.saveCatalogs(nextCatalogs)
state = state.copy(catalogs = nextCatalogs)
return state
}
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?): SharedOpdsScreenState {
val nextCatalogs = SharedOpdsCatalogs.updateCatalog(
catalogs = repository.loadCatalogs(),
id = id,
title = title,
url = url,
username = username,
password = password
)
repository.saveCatalogs(nextCatalogs)
state = state.copy(
catalogs = nextCatalogs,
currentCatalog = state.currentCatalog?.let { current ->
nextCatalogs.firstOrNull { it.id == current.id } ?: current
}
)
return state
}
fun removeCatalog(id: String): SharedOpdsScreenState {
val nextCatalogs = SharedOpdsCatalogs.removeCatalog(repository.loadCatalogs(), id)
repository.saveCatalogs(nextCatalogs)
state = state.copy(catalogs = nextCatalogs)
return state
}
suspend fun openCatalog(catalog: OpdsCatalog, emit: (SharedOpdsScreenState) -> Unit) {
urlStack.clear()
state = state.copy(searchUrlTemplate = null, currentCatalog = catalog)
fetchUrl(catalog.url, isPagination = false, emit = emit)
}
suspend fun openFeedUrl(url: String, emit: (SharedOpdsScreenState) -> Unit) {
fetchUrl(url, isPagination = false, emit = emit)
}
suspend fun loadNextPage(emit: (SharedOpdsScreenState) -> Unit) {
val nextUrl = state.currentFeed?.nextUrl ?: return
if (state.isLoading) return
fetchUrl(nextUrl, isPagination = true, emit = emit)
}
suspend fun navigateBack(emit: (SharedOpdsScreenState) -> Unit): Boolean {
return if (urlStack.size > 1) {
urlStack.removeAt(urlStack.lastIndex)
val previousUrl = urlStack.removeAt(urlStack.lastIndex)
fetchUrl(previousUrl, isPagination = false, emit = emit)
true
} else {
urlStack.clear()
state = state.copy(
isViewingCatalog = false,
currentFeed = null,
searchUrlTemplate = null,
currentCatalog = null
)
emit(state)
false
}
}
suspend fun search(query: String, emit: (SharedOpdsScreenState) -> Unit) {
val searchLink = state.searchUrlTemplate ?: return
if (query.isBlank()) return
val catalog = state.currentCatalog
state = state.copy(isLoading = true, errorMessage = null)
emit(state)
val finalUrl = runCatching {
SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl ->
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
}
}.getOrElse { error ->
state = state.copy(isLoading = false, errorMessage = "Failed to search catalog: ${error.message}")
emit(state)
return
}
fetchUrl(finalUrl, isPagination = false, emit = emit)
}
fun clearError(): SharedOpdsScreenState {
state = state.copy(errorMessage = null)
return state
}
fun updateDownloadState(entryId: String, downloadState: SharedOpdsDownloadState?): SharedOpdsScreenState {
val nextMap = if (downloadState == null) {
state.downloadingState - entryId
} else {
state.downloadingState + (entryId to downloadState)
}
state = state.copy(downloadingState = nextMap)
return state
}
private suspend fun fetchUrl(
url: String,
isPagination: Boolean,
emit: (SharedOpdsScreenState) -> Unit
) {
val catalog = state.currentCatalog
state = state.copy(isLoading = true, errorMessage = null, isViewingCatalog = true)
emit(state)
val result = repository.fetchFeed(url, catalog?.username, catalog?.password)
result.onSuccess { newFeed ->
val template = newFeed.searchUrl ?: state.searchUrlTemplate
state = if (isPagination) {
val currentEntries = state.currentFeed?.entries.orEmpty()
state.copy(
isLoading = false,
currentFeed = newFeed.copy(entries = currentEntries + newFeed.entries),
searchUrlTemplate = template
)
} else {
if (urlStack.isEmpty() || urlStack.last() != url) {
urlStack.add(url)
}
state.copy(
isLoading = false,
currentFeed = newFeed,
searchUrlTemplate = template
)
}
}.onFailure { error ->
state = state.copy(
isLoading = false,
errorMessage = "Failed to load feed: ${error.message ?: "unknown error"}"
)
}
emit(state)
}
}

View file

@ -0,0 +1,130 @@
package com.aryan.reader.shared.opds
data class OpdsCatalog(
val id: String,
val title: String,
val url: String,
val isDefault: Boolean = false,
val username: String? = null,
val password: String? = null
)
data class OpdsFacet(
val title: String,
val group: String,
val url: String,
val isActive: Boolean
)
data class OpdsFeed(
val title: String,
val entries: List<OpdsEntry>,
val nextUrl: String?,
val searchUrl: String? = null,
val facets: List<OpdsFacet> = emptyList()
)
data class OpdsAuthor(
val name: String,
val url: String?
)
data class OpdsAcquisition(
val url: String,
val mimeType: String
) {
val formatName: String
get() = when {
mimeType.contains("epub", ignoreCase = true) -> "EPUB"
mimeType.contains("pdf", ignoreCase = true) -> "PDF"
mimeType.contains("markdown", ignoreCase = true) ||
mimeType.contains("text/x-markdown", ignoreCase = true) -> "MD"
mimeType.contains("html", ignoreCase = true) ||
mimeType.contains("xhtml", ignoreCase = true) -> "HTML"
mimeType.contains("mobi", ignoreCase = true) ||
mimeType.contains("x-mobipocket-ebook", ignoreCase = true) -> "MOBI"
mimeType.contains("fictionbook", ignoreCase = true) ||
mimeType.contains("fb2", ignoreCase = true) -> "FB2"
mimeType.contains("cbz", ignoreCase = true) ||
mimeType.contains("comicbook", ignoreCase = true) -> "CBZ"
mimeType.contains("cbr", ignoreCase = true) ||
mimeType.contains("rar", ignoreCase = true) -> "CBR"
mimeType.contains("txt", ignoreCase = true) ||
mimeType.contains("text/plain", ignoreCase = true) -> "TXT"
else -> mimeType.substringAfterLast("/").uppercase()
}
val priority: Int
get() = when (formatName) {
"EPUB" -> 5
"PDF" -> 4
"MOBI" -> 3
"FB2", "MD", "HTML" -> 2
"CBZ", "CBR", "CB7" -> 1
"TXT" -> 0
else -> -1
}
}
data class OpdsEntry(
val id: String,
val title: String,
val summary: String?,
val authors: List<OpdsAuthor> = emptyList(),
val coverUrl: String?,
val acquisitions: List<OpdsAcquisition> = emptyList(),
val navigationUrl: String?,
val publisher: String? = null,
val published: String? = null,
val language: String? = null,
val series: String? = null,
val seriesIndex: String? = null,
val categories: List<String> = emptyList(),
val pseCount: Int? = null,
val pseUrlTemplate: String? = null
) {
val author: String?
get() = authors.firstOrNull()?.name
val bestAcquisition: OpdsAcquisition?
get() = acquisitions.maxByOrNull { it.priority }
val isAcquisition: Boolean
get() = acquisitions.isNotEmpty()
val isNavigation: Boolean
get() = navigationUrl != null && acquisitions.isEmpty()
val isStreamable: Boolean
get() = pseUrlTemplate != null && pseCount != null && pseCount > 0
}
data class SharedOpdsDownloadState(
val isDownloading: Boolean,
val progress: Float? = null
)
data class SharedOpdsScreenState(
val catalogs: List<OpdsCatalog> = emptyList(),
val currentCatalog: OpdsCatalog? = null,
val currentFeed: OpdsFeed? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isViewingCatalog: Boolean = false,
val searchUrlTemplate: String? = null,
val downloadingState: Map<String, SharedOpdsDownloadState> = emptyMap()
)
data class OpdsStreamReference(
val id: String,
val count: Int,
val urlTemplate: String,
val catalogId: String? = null
)
interface SharedOpdsRepository {
fun loadCatalogs(): List<OpdsCatalog>
fun saveCatalogs(catalogs: List<OpdsCatalog>)
suspend fun fetchFeed(url: String, username: String? = null, password: String? = null): Result<OpdsFeed>
suspend fun getSearchTemplate(openSearchUrl: String, username: String? = null, password: String? = null): String?
}

View file

@ -0,0 +1,206 @@
package com.aryan.reader.shared.opds
import com.aryan.reader.shared.SharedFileCapabilities
object SharedOpdsSearch {
suspend fun buildSearchUrl(
searchLink: String,
query: String,
openSearchTemplateResolver: suspend (String) -> String?
): String {
val template = if (searchLink.hasSearchTemplateToken()) {
searchLink
} else {
openSearchTemplateResolver(searchLink) ?: searchLink
}
return expandSearchTemplate(template, query)
}
fun expandSearchTemplate(template: String, query: String): String {
val encoded = query.percentEncode()
val expandedSearchTerms = template.replace("{searchTerms}", encoded)
if (expandedSearchTerms != template) return expandedSearchTerms
val queryTemplate = Regex("""\{([?&])([^}]+)\}""").find(template)
if (queryTemplate != null) {
val operator = queryTemplate.groupValues[1]
val variables = queryTemplate.groupValues[2]
.split(',')
.map { it.substringBefore(':').substringBefore('*').trim() }
.filter { it.isNotBlank() }
val parameterName = variables.firstOrNull { it.equals("searchTerms", ignoreCase = true) }
?: variables.firstOrNull()
?: "query"
val prefix = template.substringBefore(queryTemplate.value)
val suffix = template.substringAfter(queryTemplate.value)
val separator = when {
operator == "&" -> "&"
prefix.contains("?") -> "&"
else -> "?"
}
return "$prefix$separator$parameterName=$encoded$suffix"
}
val expandedQuery = template
.replace("{query}", encoded)
.replace("{keyword}", encoded)
if (expandedQuery != template) return expandedQuery
val separator = if (template.contains("?")) "&" else "?"
return "$template${separator}query=$encoded"
}
private fun String.hasSearchTemplateToken(): Boolean {
return contains("{searchTerms}") ||
Regex("""\{[?&][^}]+\}""").containsMatchIn(this) ||
contains("{query}") ||
contains("{keyword}")
}
}
object SharedOpdsDownloadNamer {
fun resolveExtension(
acquisition: OpdsAcquisition,
contentDisposition: String?,
urlPathSegment: String?
): String {
val candidates = listOfNotNull(
extractContentDispositionFilename(contentDisposition),
urlPathSegment
)
candidates.forEach { candidate ->
extensionSuffixFromName(candidate.percentDecode())?.let { return it }
}
return when (acquisition.formatName) {
"EPUB" -> ".epub"
"PDF" -> ".pdf"
"MOBI" -> ".mobi"
"FB2" -> ".fb2"
"CBZ" -> ".cbz"
"CBR" -> ".cbr"
"CB7" -> ".cb7"
"MD" -> ".md"
"HTML" -> ".html"
"TXT" -> ".txt"
else -> ".epub"
}
}
fun safeFileStem(title: String, fallback: String = "opds_book"): String {
val safe = title
.replace(Regex("""[^a-zA-Z0-9._-]+"""), "_")
.trim('_')
.take(80)
return safe.ifBlank { fallback }
}
fun extractContentDispositionFilename(contentDisposition: String?): String? {
if (contentDisposition.isNullOrBlank()) return null
val encodedFilename = Regex("""filename\*=UTF-8''([^;]+)""", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
return Regex("""filename="?([^";]+)"?""", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
?.trim()
?.trim('"')
}
private fun extensionSuffixFromName(fileName: String?): String? {
if (fileName.isNullOrBlank()) return null
val cleanName = fileName.substringBefore('?').substringBefore('#')
val extension = cleanName.substringAfterLast('.', missingDelimiterValue = "")
.lowercase()
.takeIf { it.isNotBlank() }
?: return null
if (SharedFileCapabilities.fileTypeForName(cleanName) == com.aryan.reader.shared.FileType.UNKNOWN) return null
return ".$extension"
}
}
object SharedOpdsStreamUri {
private const val SCHEME_PREFIX = "opds-pse://stream"
fun build(reference: OpdsStreamReference): String {
return "$SCHEME_PREFIX?id=${reference.id.percentEncode()}" +
"&count=${reference.count}" +
"&url=${reference.urlTemplate.percentEncode()}" +
reference.catalogId?.let { "&catalogId=${it.percentEncode()}" }.orEmpty()
}
fun parse(uriString: String?): OpdsStreamReference? {
if (uriString.isNullOrBlank() || !uriString.startsWith(SCHEME_PREFIX)) return null
val query = uriString.substringAfter('?', missingDelimiterValue = "")
val params = query.split('&')
.mapNotNull { pair ->
if (pair.isBlank()) return@mapNotNull null
val key = pair.substringBefore('=').percentDecode()
val value = pair.substringAfter('=', missingDelimiterValue = "").percentDecode()
key to value
}
.toMap()
val id = params["id"]?.takeIf { it.isNotBlank() } ?: return null
val count = params["count"]?.toIntOrNull()?.takeIf { it > 0 } ?: return null
val url = params["url"]?.takeIf { it.isNotBlank() } ?: return null
return OpdsStreamReference(
id = id,
count = count,
urlTemplate = url,
catalogId = params["catalogId"]?.takeIf { it.isNotBlank() }
)
}
}
fun String.percentEncode(): String {
val bytes = encodeToByteArray()
return buildString(bytes.size) {
bytes.forEach { byte ->
val value = byte.toInt() and 0xFF
val char = value.toChar()
if (char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9' || char in "-_.~") {
append(char)
} else {
append('%')
append(value.toString(16).uppercase().padStart(2, '0'))
}
}
}
}
fun String.percentDecode(): String {
val bytes = mutableListOf<Byte>()
var index = 0
while (index < length) {
val char = this[index]
if (char == '%' && index + 2 < length) {
val value = substring(index + 1, index + 3).toIntOrNull(16)
if (value != null) {
bytes += value.toByte()
index += 3
continue
}
}
val encoded = char.toString().encodeToByteArray()
encoded.forEach { bytes += it }
index += 1
}
return bytes.toByteArray().decodeToString()
}
object SharedOpdsText {
fun cleanSummary(summary: String?): String {
if (summary.isNullOrBlank()) return ""
return summary
.replace(Regex("""<br\s*/?>""", RegexOption.IGNORE_CASE), "\n")
.replace(Regex("""</p\s*>""", RegexOption.IGNORE_CASE), "\n\n")
.replace(Regex("""<[^>]+>"""), " ")
.replace(Regex("""\s+"""), " ")
.trim()
}
}

View file

@ -8,7 +8,8 @@ import kotlin.math.roundToInt
enum class PdfAnnotationKind {
INK,
TEXT
TEXT,
HIGHLIGHT
}
enum class PdfInkTool {
@ -44,16 +45,96 @@ data class SharedPdfAnnotation(
val tool: PdfInkTool = PdfInkTool.PEN,
val points: List<PdfPagePoint> = emptyList(),
val bounds: PdfPageBounds? = null,
val boundsList: List<PdfPageBounds> = emptyList(),
val text: String = "",
val note: String? = null,
val colorArgb: Int,
val backgroundArgb: Int = 0x00FFFFFF,
val strokeWidth: Float = 2f,
val fontSize: Float = 16f,
val isBold: Boolean = false,
val isItalic: Boolean = false,
val isUnderline: Boolean = false,
val isStrikeThrough: Boolean = false,
val fontPath: String? = null,
val fontName: String? = null,
val rangeStartIndex: Int? = null,
val rangeEndIndex: Int? = null,
val createdAt: Long = 0L
)
@Serializable
data class SharedPdfEmbeddedAnnotation(
val id: String,
val pageIndex: Int,
val index: Int,
val subtype: Int,
val bounds: PdfPageBounds,
val contents: String = "",
val author: String = "",
val name: String = "",
val inReplyTo: String = "",
val replies: List<SharedPdfEmbeddedAnnotation> = emptyList()
) {
val hasVisibleText: Boolean
get() = contents.isNotBlank() || replies.any { it.hasVisibleText }
}
object SharedPdfEmbeddedAnnotationThreads {
fun group(
annotations: List<SharedPdfEmbeddedAnnotation>,
geometryTolerance: Float = 0.02f
): List<SharedPdfEmbeddedAnnotation> {
if (annotations.isEmpty()) return emptyList()
val byName = annotations
.filter { it.name.isNotBlank() }
.associateBy { it.name }
val childrenByParentId = mutableMapOf<String, MutableList<SharedPdfEmbeddedAnnotation>>()
val roots = mutableListOf<SharedPdfEmbeddedAnnotation>()
annotations.forEach { annotation ->
val parent = byName[annotation.inReplyTo]
if (parent != null && parent.id != annotation.id) {
childrenByParentId.getOrPut(parent.id) { mutableListOf() } += annotation
} else {
roots += annotation
}
}
fun attachReplies(
annotation: SharedPdfEmbeddedAnnotation,
visitedIds: Set<String> = emptySet()
): SharedPdfEmbeddedAnnotation {
if (annotation.id in visitedIds) return annotation.copy(replies = emptyList())
val nextVisited = visitedIds + annotation.id
val replies = childrenByParentId[annotation.id]
.orEmpty()
.map { attachReplies(it, nextVisited) }
return annotation.copy(replies = annotation.replies + replies)
}
val groupedRoots = mutableListOf<MutableList<SharedPdfEmbeddedAnnotation>>()
roots.map { attachReplies(it) }.forEach { annotation ->
val group = groupedRoots.firstOrNull { existingGroup ->
existingGroup.firstOrNull()?.bounds?.inflatedBy(geometryTolerance)?.intersects(annotation.bounds) == true
}
if (group == null) {
groupedRoots += mutableListOf(annotation)
} else {
group += annotation
}
}
return groupedRoots
.mapNotNull { group ->
val root = group.firstOrNull() ?: return@mapNotNull null
root.copy(replies = root.replies + group.drop(1))
}
.filter { it.hasVisibleText }
}
}
data class PdfToolConfig(
val colorArgb: Int,
val strokeWidth: Float
@ -61,10 +142,10 @@ data class PdfToolConfig(
object SharedPdfAnnotationDefaults {
val penPalette: List<Int> = listOf(
0xFF111111.toInt(),
0xFFD32F2F.toInt(),
0xFF1976D2.toInt(),
0xFF388E3C.toInt(),
0xFF000000.toInt(),
0xFFFF0000.toInt(),
0xFF0000FF.toInt(),
0xFF4CAF50.toInt(),
0xFFFFFFFF.toInt()
)
@ -78,13 +159,13 @@ object SharedPdfAnnotationDefaults {
fun configFor(tool: PdfInkTool): PdfToolConfig {
return when (tool) {
PdfInkTool.PEN -> PdfToolConfig(0xFF111111.toInt(), 2.5f)
PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF111111.toInt(), 3.5f)
PdfInkTool.PENCIL -> PdfToolConfig(0xFF616161.toInt(), 1.8f)
PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFFEB3B.toInt(), 12f)
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFF9800.toInt(), 16f)
PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 18f)
PdfInkTool.TEXT -> PdfToolConfig(0xFF111111.toInt(), 1f)
PdfInkTool.PEN -> PdfToolConfig(0xFFFF0000.toInt(), 0.008f)
PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF0000FF.toInt(), 0.008f)
PdfInkTool.PENCIL -> PdfToolConfig(0xFF444444.toInt(), 0.008f)
PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFF9800.toInt(), 0.035f)
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFFEB3B.toInt(), 0.035f)
PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 0.03f)
PdfInkTool.TEXT -> PdfToolConfig(0xFF000000.toInt(), 0.02f)
}
}
}
@ -116,6 +197,22 @@ object SharedPdfAnnotationSerializer {
}
}
private fun PdfPageBounds.inflatedBy(amount: Float): PdfPageBounds {
return PdfPageBounds(
left = (left - amount).coerceAtLeast(0f),
top = (top - amount).coerceAtLeast(0f),
right = (right + amount).coerceAtMost(1f),
bottom = (bottom + amount).coerceAtMost(1f)
)
}
private fun PdfPageBounds.intersects(other: PdfPageBounds): Boolean {
return left <= other.right &&
right >= other.left &&
top <= other.bottom &&
bottom >= other.top
}
data class PdfZoomSpec(
val min: Float = 0.65f,
val max: Float = 3.0f,

View file

@ -0,0 +1,573 @@
package com.aryan.reader.shared.pdf
import com.aryan.reader.shared.PdfDisplayMode
import com.aryan.reader.shared.SearchHighlightMode
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class SharedPdfSearchResult(
val pageIndex: Int,
val preview: String,
val matchIndex: Int,
val matchLength: Int = 0
)
@Serializable
data class SharedPdfBookmark(
val pageIndex: Int,
val label: String = "",
val createdAt: Long = 0L
)
@Serializable
data class SharedPdfBookmarkStore(
val version: Int = 1,
val bookmarks: List<SharedPdfBookmark> = emptyList()
)
object SharedPdfBookmarkSerializer {
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
encodeDefaults = true
}
fun encode(bookmarks: List<SharedPdfBookmark>): String {
return json.encodeToString(SharedPdfBookmarkStore(bookmarks = bookmarks))
}
fun decode(raw: String): List<SharedPdfBookmark> {
if (raw.isBlank()) return emptyList()
return runCatching {
json.decodeFromString<SharedPdfBookmarkStore>(raw).bookmarks
}.getOrElse {
runCatching { json.decodeFromString<List<SharedPdfBookmark>>(raw) }.getOrDefault(emptyList())
}
}
}
data class SharedPdfJumpHistory(
val pages: List<Int> = emptyList(),
val cursor: Int = -1,
val maxEntries: Int = 21
) {
val backPage: Int? get() = pages.getOrNull(cursor - 1)
val forwardPage: Int? get() = pages.getOrNull(cursor + 1)
val hasJumpTargets: Boolean get() = backPage != null || forwardPage != null
fun record(
currentPageIndex: Int,
targetPageIndex: Int,
pageCount: Int
): SharedPdfJumpHistory {
if (
pageCount <= 0 ||
currentPageIndex !in 0 until pageCount ||
targetPageIndex !in 0 until pageCount ||
currentPageIndex == targetPageIndex
) {
return this
}
val pruned = pruned(pageCount)
val nextPages = pruned.pages.toMutableList()
var nextCursor = pruned.cursor
while (nextPages.lastIndex > nextCursor) {
nextPages.removeAt(nextPages.lastIndex)
}
if (nextCursor > 0 && nextPages.getOrNull(nextCursor - 1) == currentPageIndex) {
nextPages[nextCursor] = targetPageIndex
return copy(
pages = nextPages,
cursor = nextCursor
).bounded()
}
if (nextCursor == -1 || nextPages.getOrNull(nextCursor) != currentPageIndex) {
nextPages += currentPageIndex
nextCursor = nextPages.lastIndex
}
if (nextPages.lastOrNull() != targetPageIndex) {
nextPages += targetPageIndex
nextCursor = nextPages.lastIndex
}
return copy(
pages = nextPages,
cursor = nextCursor
).bounded()
}
fun pruned(pageCount: Int): SharedPdfJumpHistory {
if (pageCount <= 0) return clear()
val nextPages = pages.toMutableList()
var nextCursor = cursor
var index = nextPages.lastIndex
while (index >= 0) {
if (nextPages[index] !in 0 until pageCount) {
nextPages.removeAt(index)
if (nextCursor >= index) nextCursor--
}
index--
}
return copy(
pages = nextPages,
cursor = nextCursor.coerceIn(-1, nextPages.lastIndex)
).bounded()
}
fun stepBack(): SharedPdfJumpHistory {
return if (backPage == null) this else copy(cursor = (cursor - 1).coerceAtLeast(0))
}
fun stepForward(): SharedPdfJumpHistory {
return if (forwardPage == null) this else copy(cursor = (cursor + 1).coerceAtMost(pages.lastIndex))
}
fun clear(): SharedPdfJumpHistory = copy(pages = emptyList(), cursor = -1)
private fun bounded(): SharedPdfJumpHistory {
val safeMaxEntries = maxEntries.coerceAtLeast(2)
if (pages.size <= safeMaxEntries) {
return copy(cursor = cursor.coerceIn(-1, pages.lastIndex))
}
val overflow = pages.size - safeMaxEntries
return copy(
pages = pages.drop(overflow),
cursor = (cursor - overflow).coerceIn(-1, pages.size - overflow - 1)
)
}
}
data class SharedPdfReaderState(
val pageIndex: Int = 0,
val pageCount: Int = 0,
val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION,
val zoom: Float = PdfZoomSpec().default,
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 isTextSelectionMode: Boolean = false,
val bookmarks: List<SharedPdfBookmark> = emptyList(),
val selectedAnnotationId: String? = null,
val annotations: List<SharedPdfAnnotation> = emptyList()
) {
val safePageCount: Int get() = pageCount.coerceAtLeast(0)
val lastPageIndex: Int get() = (safePageCount - 1).coerceAtLeast(0)
val canGoPrevious: Boolean get() = pageIndex > 0
val canGoNext: Boolean get() = pageIndex < lastPageIndex
val progressPercent: Float get() = ((pageIndex + 1).toFloat() / safePageCount.coerceAtLeast(1)) * 100f
fun coerced(zoomSpec: PdfZoomSpec = PdfZoomSpec()): SharedPdfReaderState {
val safePage = pageIndex.coerceIn(0, lastPageIndex)
return copy(
pageIndex = safePage,
pageCount = safePageCount,
activeSearchResultIndex = activeSearchResultIndex.coerceAtLeast(-1),
zoom = zoomSpec.clamp(zoom),
bookmarks = bookmarks.normalizedBookmarks(lastPageIndex),
selectedAnnotationId = selectedAnnotationId?.takeIf { selectedId ->
annotations.any { it.id == selectedId }
}
)
}
companion object {
fun initial(
pageCount: Int,
initialPageIndex: Int = 0,
zoomSpec: PdfZoomSpec = PdfZoomSpec()
): SharedPdfReaderState {
val safePageCount = pageCount.coerceAtLeast(0)
val lastPageIndex = (safePageCount - 1).coerceAtLeast(0)
return SharedPdfReaderState(
pageIndex = initialPageIndex.coerceIn(0, lastPageIndex),
pageCount = safePageCount,
zoom = zoomSpec.clamp(zoomSpec.default)
)
}
}
}
sealed interface SharedPdfReaderAction {
data class GoToPage(val pageIndex: Int) : SharedPdfReaderAction
data object PreviousPage : SharedPdfReaderAction
data object NextPage : SharedPdfReaderAction
data object FirstPage : SharedPdfReaderAction
data object LastPage : SharedPdfReaderAction
data class DisplayModeChanged(val mode: PdfDisplayMode) : SharedPdfReaderAction
data object DisplayModeToggled : SharedPdfReaderAction
data class ZoomChanged(val zoom: Float) : SharedPdfReaderAction
data class ZoomBy(val delta: Float) : SharedPdfReaderAction
data class SearchChanged(val query: String) : SharedPdfReaderAction
data class SearchHighlightModeChanged(val mode: SearchHighlightMode) : SharedPdfReaderAction
data object SearchHighlightModeToggled : SharedPdfReaderAction
data class GoToSearchResult(
val resultIndex: Int,
val results: List<SharedPdfSearchResult>
) : SharedPdfReaderAction
data class ToolSelected(val tool: PdfInkTool) : SharedPdfReaderAction
data class ColorSelected(val colorArgb: Int) : SharedPdfReaderAction
data class StrokeWidthChanged(val strokeWidth: Float) : SharedPdfReaderAction
data class TextSelectionModeChanged(val enabled: Boolean) : SharedPdfReaderAction
data class BookmarksLoaded(val bookmarks: List<SharedPdfBookmark>) : SharedPdfReaderAction
data class BookmarkToggled(
val pageIndex: Int,
val label: String = "",
val createdAt: Long = 0L
) : SharedPdfReaderAction
data class AnnotationsLoaded(val annotations: List<SharedPdfAnnotation>) : SharedPdfReaderAction
data class AnnotationAdded(val annotation: SharedPdfAnnotation) : SharedPdfReaderAction
data class AnnotationSelected(val annotationId: String?) : SharedPdfReaderAction
data class AnnotationUpdated(val annotation: SharedPdfAnnotation) : SharedPdfReaderAction
data class AnnotationDeleted(val annotationId: String) : SharedPdfReaderAction
data class AnnotationsChanged(val annotations: List<SharedPdfAnnotation>) : SharedPdfReaderAction
data class UndoLastAnnotationOnPage(val pageIndex: Int) : SharedPdfReaderAction
data class ClearPageAnnotations(val pageIndex: Int) : SharedPdfReaderAction
}
fun SharedPdfReaderState.reduce(
action: SharedPdfReaderAction,
zoomSpec: PdfZoomSpec = PdfZoomSpec()
): SharedPdfReaderState {
fun goToPage(target: Int): SharedPdfReaderState {
return copy(pageIndex = target.coerceIn(0, lastPageIndex)).coerced(zoomSpec)
}
return when (action) {
is SharedPdfReaderAction.GoToPage -> goToPage(action.pageIndex)
SharedPdfReaderAction.PreviousPage -> goToPage(pageIndex - 1)
SharedPdfReaderAction.NextPage -> goToPage(pageIndex + 1)
SharedPdfReaderAction.FirstPage -> goToPage(0)
SharedPdfReaderAction.LastPage -> goToPage(lastPageIndex)
is SharedPdfReaderAction.DisplayModeChanged -> copy(displayMode = action.mode)
SharedPdfReaderAction.DisplayModeToggled -> copy(
displayMode = when (displayMode) {
PdfDisplayMode.PAGINATION -> PdfDisplayMode.VERTICAL_SCROLL
PdfDisplayMode.VERTICAL_SCROLL -> PdfDisplayMode.PAGINATION
}
)
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,
activeSearchResultIndex = -1
)
is SharedPdfReaderAction.SearchHighlightModeChanged -> copy(searchHighlightMode = action.mode)
SharedPdfReaderAction.SearchHighlightModeToggled -> copy(
searchHighlightMode = when (searchHighlightMode) {
SearchHighlightMode.ALL -> SearchHighlightMode.FOCUSED
SearchHighlightMode.FOCUSED -> SearchHighlightMode.ALL
}
)
is SharedPdfReaderAction.GoToSearchResult -> {
if (action.results.isEmpty()) {
this
} else {
val normalizedIndex = action.resultIndex.wrapIndex(action.results.size)
copy(
activeSearchResultIndex = normalizedIndex,
pageIndex = action.results[normalizedIndex].pageIndex.coerceIn(0, lastPageIndex)
)
}
}
is SharedPdfReaderAction.ToolSelected -> {
val config = SharedPdfAnnotationDefaults.configFor(action.tool)
copy(
selectedTool = action.tool,
selectedColorArgb = config.colorArgb,
strokeWidth = config.strokeWidth
)
}
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.BookmarksLoaded -> copy(bookmarks = action.bookmarks.normalizedBookmarks(lastPageIndex))
is SharedPdfReaderAction.BookmarkToggled -> {
val page = action.pageIndex.coerceIn(0, lastPageIndex)
val withoutPage = bookmarks.filterNot { it.pageIndex == page }
val nextBookmarks = if (withoutPage.size == bookmarks.size) {
withoutPage + SharedPdfBookmark(
pageIndex = page,
label = action.label.ifBlank { "Page ${page + 1}" },
createdAt = action.createdAt
)
} else {
withoutPage
}
copy(bookmarks = nextBookmarks.normalizedBookmarks(lastPageIndex))
}
is SharedPdfReaderAction.AnnotationsLoaded -> copy(annotations = action.annotations.toList())
is SharedPdfReaderAction.AnnotationAdded -> copy(
annotations = annotations + action.annotation,
selectedAnnotationId = action.annotation.id
)
is SharedPdfReaderAction.AnnotationSelected -> copy(
selectedAnnotationId = action.annotationId?.takeIf { id -> annotations.any { it.id == id } }
)
is SharedPdfReaderAction.AnnotationUpdated -> {
val index = annotations.indexOfFirst { it.id == action.annotation.id }
if (index < 0) {
this
} else {
copy(annotations = annotations.toMutableList().also { it[index] = action.annotation })
}
}
is SharedPdfReaderAction.AnnotationDeleted -> copy(
annotations = annotations.filterNot { it.id == action.annotationId },
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId }
)
is SharedPdfReaderAction.AnnotationsChanged -> copy(annotations = action.annotations.toList())
is SharedPdfReaderAction.UndoLastAnnotationOnPage -> {
val index = annotations.indexOfLast { it.pageIndex == action.pageIndex }
if (index < 0) {
this
} else {
val removedId = annotations[index].id
copy(
annotations = annotations.toMutableList().also { it.removeAt(index) },
selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId }
)
}
}
is SharedPdfReaderAction.ClearPageAnnotations -> {
val removedIds = annotations.filter { it.pageIndex == action.pageIndex }.map { it.id }.toSet()
copy(
annotations = annotations.filterNot { it.pageIndex == action.pageIndex },
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds }
)
}
}.coerced(zoomSpec)
}
object SharedPdfSearchEngine {
fun search(
pageTexts: List<String>,
query: String,
previewRadiusBefore: Int = 70,
previewRadiusAfter: Int = 100
): List<SharedPdfSearchResult> {
val normalized = query.trim()
if (normalized.isBlank()) return emptyList()
return pageTexts.flatMapIndexed { pageIndex, text ->
val matches = mutableListOf<SharedPdfSearchResult>()
var startIndex = 0
while (startIndex < text.length) {
val matchIndex = text.indexOf(normalized, startIndex, ignoreCase = true)
if (matchIndex < 0) break
matches += SharedPdfSearchResult(
pageIndex = pageIndex,
preview = text.previewAround(
index = matchIndex,
queryLength = normalized.length,
before = previewRadiusBefore,
after = previewRadiusAfter
),
matchIndex = matchIndex,
matchLength = normalized.length
)
startIndex = matchIndex + normalized.length.coerceAtLeast(1)
}
matches
}
}
fun highlightsForPage(
results: List<SharedPdfSearchResult>,
pageIndex: Int,
activeResultIndex: Int,
mode: SearchHighlightMode
): List<SharedPdfSearchResult> {
return when (mode) {
SearchHighlightMode.ALL -> results.filter { it.pageIndex == pageIndex }
SearchHighlightMode.FOCUSED -> {
val active = results.getOrNull(activeResultIndex)
if (active?.pageIndex == pageIndex) listOf(active) else emptyList()
}
}
}
}
class SharedPdfSearchIndex(
val pageCount: Int = 0
) {
private val pageTexts = LinkedHashMap<Int, String>()
private val tokenPages = LinkedHashMap<String, MutableSet<Int>>()
val indexedPageCount: Int
get() = pageTexts.size
fun hasPage(pageIndex: Int): Boolean = pageTexts.containsKey(pageIndex)
fun pageText(pageIndex: Int): String? = pageTexts[pageIndex]
fun indexedPages(): List<SharedPdfIndexedPage> {
return pageTexts.entries
.sortedBy { it.key }
.map { SharedPdfIndexedPage(pageIndex = it.key, text = it.value) }
}
fun putPage(pageIndex: Int, text: String) {
if (pageCount > 0 && pageIndex !in 0 until pageCount) return
removePageTokens(pageIndex)
pageTexts[pageIndex] = text
text.searchTokens().forEach { token ->
tokenPages.getOrPut(token) { linkedSetOf() } += pageIndex
}
}
fun clear() {
pageTexts.clear()
tokenPages.clear()
}
fun search(
query: String,
previewRadiusBefore: Int = 70,
previewRadiusAfter: Int = 100
): List<SharedPdfSearchResult> {
val normalized = query.trim()
if (normalized.isBlank()) return emptyList()
val matcher = SharedPdfPhraseMatcher(normalized)
val candidates = candidatePages(matcher.tokens)
return candidates.flatMap { pageIndex ->
val text = pageTexts[pageIndex].orEmpty()
matcher.findAll(text).map { match ->
SharedPdfSearchResult(
pageIndex = pageIndex,
preview = text.previewAround(
index = match.startIndex,
queryLength = match.length,
before = previewRadiusBefore,
after = previewRadiusAfter
),
matchIndex = match.startIndex,
matchLength = match.length
)
}
}
}
private fun candidatePages(tokens: List<String>): List<Int> {
if (tokens.isEmpty()) return pageTexts.keys.sorted()
val candidateSets = tokens.map { token ->
tokenPages.asSequence()
.filter { (indexedToken, _) -> indexedToken.startsWith(token) }
.flatMap { (_, pages) -> pages.asSequence() }
.toSet()
}
if (candidateSets.any { it.isEmpty() }) return emptyList()
return candidateSets
.drop(1)
.fold(candidateSets.first()) { acc, pages -> acc.intersect(pages) }
.sorted()
}
private fun removePageTokens(pageIndex: Int) {
if (!pageTexts.containsKey(pageIndex)) return
val emptyTokens = mutableListOf<String>()
tokenPages.forEach { (token, pages) ->
pages.remove(pageIndex)
if (pages.isEmpty()) emptyTokens += token
}
emptyTokens.forEach(tokenPages::remove)
}
}
data class SharedPdfIndexedPage(
val pageIndex: Int,
val text: String
)
private data class SharedPdfPhraseMatch(
val startIndex: Int,
val length: Int
)
private class SharedPdfPhraseMatcher(query: String) {
val tokens: List<String> = query.searchTokens()
private val regex = query.toSearchPhraseRegex()
private val literal = query.takeIf { regex == null }
fun findAll(text: String): List<SharedPdfPhraseMatch> {
return if (regex != null) {
regex.findAll(text).map { match ->
SharedPdfPhraseMatch(
startIndex = match.range.first,
length = match.range.last - match.range.first + 1
)
}.toList()
} else {
val needle = literal.orEmpty()
val matches = mutableListOf<SharedPdfPhraseMatch>()
var startIndex = 0
while (startIndex < text.length) {
val matchIndex = text.indexOf(needle, startIndex, ignoreCase = true)
if (matchIndex < 0) break
matches += SharedPdfPhraseMatch(matchIndex, needle.length)
startIndex = matchIndex + needle.length.coerceAtLeast(1)
}
matches
}
}
}
private fun String.toSearchPhraseRegex(): Regex? {
val tokens = trim().split(Regex("\\s+")).filter { it.isNotBlank() }
if (tokens.size <= 1) return null
val prefix = if (all { it.code < 128 }) "\\b" else ""
return Regex(prefix + tokens.joinToString("\\s+") { Regex.escape(it) }, RegexOption.IGNORE_CASE)
}
private fun Int.wrapIndex(size: Int): Int {
if (size <= 0) return -1
return when {
this < 0 -> size - 1
this >= size -> 0
else -> this
}
}
private fun List<SharedPdfBookmark>.normalizedBookmarks(lastPageIndex: Int): List<SharedPdfBookmark> {
return asSequence()
.filter { it.pageIndex in 0..lastPageIndex }
.distinctBy { it.pageIndex }
.sortedBy { it.pageIndex }
.toList()
}
private fun String.previewAround(
index: Int,
queryLength: Int,
before: Int,
after: Int
): String {
val start = (index - before).coerceAtLeast(0)
val end = (index + queryLength + after).coerceAtMost(length)
val prefix = if (start > 0) "..." else ""
val suffix = if (end < length) "..." else ""
return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix
}
private fun String.searchTokens(): List<String> {
val tokens = mutableListOf<String>()
val current = StringBuilder()
forEach { char ->
if (char.isLetterOrDigit() || char == '_') {
current.append(char.lowercaseChar())
} else if (current.isNotEmpty()) {
tokens += current.toString()
current.setLength(0)
}
}
if (current.isNotEmpty()) tokens += current.toString()
return tokens.distinct()
}

View file

@ -0,0 +1,172 @@
package com.aryan.reader.shared.pdf
import kotlin.math.abs
data class PdfNormalizedPoint(
val x: Float,
val y: Float
)
data class PdfTextCharBounds(
val index: Int,
val left: Float,
val top: Float,
val right: Float,
val bottom: Float
) {
val hasBounds: Boolean
get() = right > left && bottom > top
}
object PdfSelectionGeometry {
private const val DefaultMergedLineTolerance = 0.006f
private const val DefaultCharLineTolerance = 0.012f
private const val MinLineTolerance = 0.002f
fun normalizedPoint(
pointX: Float,
pointY: Float,
viewportWidth: Int,
viewportHeight: Int
): PdfNormalizedPoint? {
if (viewportWidth <= 0 || viewportHeight <= 0) return null
return PdfNormalizedPoint(
x = (pointX / viewportWidth).coerceIn(0f, 1f),
y = (pointY / viewportHeight).coerceIn(0f, 1f)
)
}
fun mergeBoundsByLine(
bounds: List<PdfPageBounds>,
lineTolerance: Float = DefaultMergedLineTolerance
): List<PdfPageBounds> {
if (bounds.isEmpty()) return emptyList()
val lines = mutableListOf<MutableList<PdfPageBounds>>()
bounds.sortedWith(compareBy<PdfPageBounds> { it.top }.thenBy { it.left }).forEach { boundsForChar ->
val line = lines.firstOrNull { existing ->
existing.any { it.isSameVisualLineAs(boundsForChar, lineTolerance) }
}
if (line == null) {
lines += mutableListOf(boundsForChar)
} else {
line += boundsForChar
}
}
return lines.map { it.toMergedBounds() }
}
fun lineBoundsForChars(
chars: List<PdfTextCharBounds>,
lineTolerance: Float = DefaultCharLineTolerance
): List<PdfPageBounds> {
return chars.groupByLine(lineTolerance).map { it.toCharLineBounds() }
}
fun nearestCharOnLine(
chars: List<PdfTextCharBounds>,
point: PdfNormalizedPoint,
lineTolerance: Float = DefaultCharLineTolerance
): PdfTextCharBounds? {
val lines = chars.groupByLine(lineTolerance)
val matchingLines = lines.filter { line ->
val top = line.minOf { it.top }
val bottom = line.maxOf { it.bottom }
val averageHeight = line.map { it.bottom - it.top }.average().toFloat()
val verticalPadding = maxOf(averageHeight * 0.45f, MinLineTolerance)
point.y in (top - verticalPadding)..(bottom + verticalPadding)
}
val line = matchingLines.minWithOrNull(
compareBy<List<PdfTextCharBounds>>(
{ lineVerticalDistance(point.y, it) },
{ lineHorizontalDistance(point.x, it) }
)
) ?: return null
return line.minByOrNull { char ->
horizontalDistance(point.x, char)
}
}
private fun List<PdfTextCharBounds>.groupByLine(lineTolerance: Float): List<List<PdfTextCharBounds>> {
val lines = mutableListOf<MutableList<PdfTextCharBounds>>()
filter { it.hasBounds }
.sortedWith(compareBy<PdfTextCharBounds> { it.top }.thenBy { it.left })
.forEach { char ->
val line = lines.firstOrNull { existing ->
val averageHeight = existing.map { it.bottom - it.top }.average().toFloat()
val charHeight = char.bottom - char.top
val dynamicTolerance = maxOf(minOf(averageHeight, charHeight) * 0.55f, MinLineTolerance)
abs(existing.averageVerticalMidpoint() - char.verticalMidpoint()) <= minOf(lineTolerance, dynamicTolerance)
}
if (line == null) {
lines += mutableListOf(char)
} else {
line += char
}
}
return lines
}
private fun List<PdfTextCharBounds>.toCharLineBounds(): PdfPageBounds {
return PdfPageBounds(
left = minOf { it.left }.coerceIn(0f, 1f),
top = minOf { it.top }.coerceIn(0f, 1f),
right = maxOf { it.right }.coerceIn(0f, 1f),
bottom = maxOf { it.bottom }.coerceIn(0f, 1f)
)
}
private fun List<PdfPageBounds>.toMergedBounds(): PdfPageBounds {
return PdfPageBounds(
left = minOf { it.left }.coerceIn(0f, 1f),
top = minOf { it.top }.coerceIn(0f, 1f),
right = maxOf { it.right }.coerceIn(0f, 1f),
bottom = maxOf { it.bottom }.coerceIn(0f, 1f)
)
}
private fun PdfTextCharBounds.verticalMidpoint(): Float = (top + bottom) / 2f
private fun PdfPageBounds.isSameVisualLineAs(other: PdfPageBounds, lineTolerance: Float): Boolean {
val overlap = minOf(bottom, other.bottom) - maxOf(top, other.top)
val minHeight = minOf(bottom - top, other.bottom - other.top)
if (overlap > 0f && overlap >= minHeight * 0.45f) return true
val dynamicTolerance = maxOf(minHeight * 0.35f, MinLineTolerance)
return abs(verticalMidpoint() - other.verticalMidpoint()) <= minOf(lineTolerance, dynamicTolerance)
}
private fun PdfPageBounds.verticalMidpoint(): Float = (top + bottom) / 2f
private fun List<PdfTextCharBounds>.averageVerticalMidpoint(): Float {
return map { it.verticalMidpoint() }.average().toFloat()
}
private fun lineVerticalDistance(pointY: Float, line: List<PdfTextCharBounds>): Float {
val top = line.minOf { it.top }
val bottom = line.maxOf { it.bottom }
return when {
pointY < top -> top - pointY
pointY > bottom -> pointY - bottom
else -> 0f
}
}
private fun lineHorizontalDistance(pointX: Float, line: List<PdfTextCharBounds>): Float {
val left = line.minOf { it.left }
val right = line.maxOf { it.right }
return when {
pointX < left -> left - pointX
pointX > right -> pointX - right
else -> 0f
}
}
private fun horizontalDistance(pointX: Float, char: PdfTextCharBounds): Float {
return when {
pointX < char.left -> char.left - pointX
pointX > char.right -> pointX - char.right
else -> 0f
}
}
}

View file

@ -0,0 +1,30 @@
package com.aryan.reader.shared.pdf
data class PdfVisiblePageLayout(
val pageIndex: Int,
val top: Float,
val bottom: Float
) {
val visibleHeight: Float
get() = (bottom - top).coerceAtLeast(0f)
}
fun mostVisiblePdfPageIndex(
visiblePages: List<PdfVisiblePageLayout>,
viewportTop: Float,
viewportBottom: Float,
fallbackPageIndex: Int
): Int {
return visiblePages
.filter { it.visibleHeight > 0f }
.map { page ->
val top = maxOf(page.top, viewportTop)
val bottom = minOf(page.bottom, viewportBottom)
page to (bottom - top).coerceAtLeast(0f)
}
.maxByOrNull { it.second }
?.takeIf { it.second > 0f }
?.first
?.pageIndex
?: fallbackPageIndex
}

View file

@ -0,0 +1,415 @@
package com.aryan.reader.shared.pdf
import com.aryan.reader.shared.localFolderSyncSha256ShortHex
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
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 kotlin.math.pow
object SharedPdfAnnotationSidecarCodec {
const val KEY_PDF_ANNOTATIONS = "pdfAnnotations"
const val KEY_LEGACY_INK = "ink"
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
prettyPrint = true
}
fun encodeAnnotationsElement(annotations: List<SharedPdfAnnotation>): JsonElement {
return json.parseToJsonElement(SharedPdfAnnotationSerializer.encode(annotations))
}
fun decodeAnnotationsElement(element: JsonElement): List<SharedPdfAnnotation> {
return SharedPdfAnnotationSerializer.decode(json.encodeToString(JsonElement.serializer(), element))
}
fun annotationsFromData(data: JsonObject): List<SharedPdfAnnotation> {
data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it) }
data[KEY_LEGACY_INK]?.let { ink ->
val decoded = decodeAnnotationsElement(ink)
if (decoded.isNotEmpty() || ink.looksLikeSharedAnnotationStore()) {
return decoded
}
}
return legacyAndroidAnnotationsFromData(data)
}
fun withCanonicalAnnotations(data: JsonObject): JsonObject {
if (data[KEY_PDF_ANNOTATIONS] != null) return data
val annotations = annotationsFromData(data)
if (annotations.isEmpty()) return data
return JsonObject(data + (KEY_PDF_ANNOTATIONS to encodeAnnotationsElement(annotations)))
}
fun canonicalizeDataJson(rawDataJson: String): String {
val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson
return json.encodeToString(JsonElement.serializer(), withCanonicalAnnotations(data))
}
fun legacyAndroidDataFromAnnotations(
annotations: List<SharedPdfAnnotation>,
existingData: JsonObject = JsonObject(emptyMap())
): JsonObject {
if (annotations.isEmpty()) return existingData
val next = existingData.toMutableMap()
if (!existingData[KEY_LEGACY_INK].isLegacyAndroidInkArray()) {
next[KEY_LEGACY_INK] = annotations.toLegacyAndroidInkArray()
}
if (!existingData[KEY_LEGACY_TEXT_BOXES].isJsonArray()) {
next[KEY_LEGACY_TEXT_BOXES] = annotations.toLegacyAndroidTextBoxArray()
}
if (!existingData[KEY_LEGACY_HIGHLIGHTS].isJsonArray()) {
next[KEY_LEGACY_HIGHLIGHTS] = annotations.toLegacyAndroidHighlightArray()
}
return JsonObject(next)
}
fun legacyAndroidDataJsonFromCanonical(rawDataJson: String): String {
val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson
val annotations = annotationsFromData(data)
if (annotations.isEmpty()) return rawDataJson
return json.encodeToString(
JsonElement.serializer(),
legacyAndroidDataFromAnnotations(annotations, data)
)
}
private fun legacyAndroidAnnotationsFromData(data: JsonObject): List<SharedPdfAnnotation> {
return buildList {
addAll(data[KEY_LEGACY_INK].parseLegacyAndroidInk())
addAll(data[KEY_LEGACY_TEXT_BOXES].parseLegacyAndroidTextBoxes())
addAll(data[KEY_LEGACY_HIGHLIGHTS].parseLegacyAndroidHighlights())
}
}
private fun JsonElement?.parseLegacyAndroidInk(): List<SharedPdfAnnotation> {
val array = this?.jsonArrayOrNull() ?: return emptyList()
if (!this.isLegacyAndroidInkArray()) return emptyList()
return array.mapNotNull { element ->
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
val points = obj.array("points")
?.mapNotNull { pointElement ->
val point = pointElement.jsonObjectOrNull() ?: return@mapNotNull null
PdfPagePoint(
x = point.float("x") ?: return@mapNotNull null,
y = point.float("y") ?: return@mapNotNull null,
timestamp = point.long("t") ?: point.long("timestamp") ?: 0L
)
}
.orEmpty()
if (points.isEmpty()) return@mapNotNull null
val tool = obj.string("inkType")
?: obj.string("type")
?: PdfInkTool.PEN.name
SharedPdfAnnotation(
id = obj.string("id") ?: stableAnnotationId("ink", element),
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
kind = PdfAnnotationKind.INK,
tool = tool.toPdfInkTool(),
points = points,
colorArgb = obj.int("color") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb,
strokeWidth = obj.float("strokeWidth") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth,
createdAt = points.firstOrNull()?.timestamp ?: 0L
)
}
}
private fun JsonElement?.parseLegacyAndroidTextBoxes(): List<SharedPdfAnnotation> {
val array = this?.jsonArrayOrNull() ?: return emptyList()
return array.mapNotNull { element ->
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
val bounds = obj.objectValue("bounds")?.toPdfPageBoundsOrNull() ?: return@mapNotNull null
val rawFontSize = obj.float("fontSize") ?: 16f
SharedPdfAnnotation(
id = obj.string("id") ?: stableAnnotationId("text", element),
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
kind = PdfAnnotationKind.TEXT,
tool = PdfInkTool.TEXT,
bounds = bounds,
text = obj.string("text").orEmpty(),
colorArgb = obj.int("color") ?: 0xFF000000.toInt(),
backgroundArgb = obj.int("backgroundColor") ?: 0x00000000,
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
fontSize = rawFontSize.legacyTextBoxFontSizeToShared(),
isBold = obj.boolean("isBold") ?: false,
isItalic = obj.boolean("isItalic") ?: false,
isUnderline = obj.boolean("isUnderline") ?: false,
isStrikeThrough = obj.boolean("isStrikeThrough") ?: false,
fontPath = obj.string("fontPath"),
fontName = obj.string("fontName")
)
}
}
private fun JsonElement?.parseLegacyAndroidHighlights(): List<SharedPdfAnnotation> {
val array = this?.jsonArrayOrNull() ?: return emptyList()
return array.mapNotNull { element ->
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
val boundsList = obj.array("bounds")
?.mapNotNull { it.jsonObjectOrNull()?.toPdfPageBoundsOrNull() }
?.filter { it.isNormalizedPageBounds() }
.orEmpty()
val rangeStart = obj.int("rangeStart")
val rangeEnd = obj.int("rangeEnd")
if (boundsList.isEmpty() && (rangeStart == null || rangeEnd == null)) return@mapNotNull null
val inclusiveRangeEnd = if (rangeStart != null && rangeEnd != null) {
(rangeEnd - 1).coerceAtLeast(rangeStart)
} else {
rangeEnd
}
val colorName = obj.string("color") ?: "YELLOW"
SharedPdfAnnotation(
id = obj.string("id") ?: stableAnnotationId("highlight", element),
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
kind = PdfAnnotationKind.HIGHLIGHT,
tool = PdfInkTool.HIGHLIGHTER,
bounds = boundsList.firstOrNull(),
boundsList = boundsList,
text = obj.string("text").orEmpty(),
note = obj.string("note"),
colorArgb = colorName.toSharedHighlightArgb(),
rangeStartIndex = rangeStart,
rangeEndIndex = inclusiveRangeEnd
)
}
}
private fun List<SharedPdfAnnotation>.toLegacyAndroidInkArray(): JsonArray {
return JsonArray(
filter { it.kind == PdfAnnotationKind.INK && it.points.isNotEmpty() }
.map { annotation ->
JsonObject(
buildMap {
put("id", JsonPrimitive(annotation.id))
put("pageIndex", JsonPrimitive(annotation.pageIndex))
put("annotationType", JsonPrimitive("INK"))
put("inkType", JsonPrimitive(annotation.tool.name))
put("color", JsonPrimitive(annotation.colorArgb))
put("strokeWidth", JsonPrimitive(annotation.strokeWidth.toDouble()))
put(
"points",
JsonArray(
annotation.points.map { point ->
JsonObject(
mapOf(
"x" to JsonPrimitive(point.x.toDouble()),
"y" to JsonPrimitive(point.y.toDouble()),
"t" to JsonPrimitive(point.timestamp)
)
)
}
)
)
}
)
}
)
}
private fun List<SharedPdfAnnotation>.toLegacyAndroidTextBoxArray(): JsonArray {
return JsonArray(
filter { it.kind == PdfAnnotationKind.TEXT && it.bounds != null }
.map { annotation ->
val bounds = requireNotNull(annotation.bounds)
JsonObject(
buildMap {
put("id", JsonPrimitive(annotation.id))
put("pageIndex", JsonPrimitive(annotation.pageIndex))
put("text", JsonPrimitive(annotation.text))
put("color", JsonPrimitive(annotation.colorArgb))
put("backgroundColor", JsonPrimitive(annotation.backgroundArgb))
put("fontSize", JsonPrimitive(annotation.fontSize.sharedFontSizeToLegacyTextBox().toDouble()))
put("isBold", JsonPrimitive(annotation.isBold))
put("isItalic", JsonPrimitive(annotation.isItalic))
put("isUnderline", JsonPrimitive(annotation.isUnderline))
put("isStrikeThrough", JsonPrimitive(annotation.isStrikeThrough))
annotation.fontPath?.let { put("fontPath", JsonPrimitive(it)) }
annotation.fontName?.let { put("fontName", JsonPrimitive(it)) }
put("bounds", bounds.toJsonObject())
}
)
}
)
}
private fun List<SharedPdfAnnotation>.toLegacyAndroidHighlightArray(): JsonArray {
return JsonArray(
filter { it.kind == PdfAnnotationKind.HIGHLIGHT }
.map { annotation ->
JsonObject(
buildMap {
put("id", JsonPrimitive(annotation.id))
put("pageIndex", JsonPrimitive(annotation.pageIndex))
put("color", JsonPrimitive(annotation.colorArgb.toLegacyHighlightColorName()))
put("text", JsonPrimitive(annotation.text))
val rangeStart = annotation.rangeStartIndex ?: 0
val rangeEnd = annotation.rangeEndIndex?.plus(1)?.coerceAtLeast(rangeStart) ?: rangeStart
put("rangeStart", JsonPrimitive(rangeStart))
put("rangeEnd", JsonPrimitive(rangeEnd))
annotation.note?.takeIf { it.isNotBlank() }?.let { put("note", JsonPrimitive(it)) }
put("bounds", JsonArray(emptyList()))
}
)
}
)
}
private fun parseObjectOrNull(raw: String): JsonObject? {
return runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull()
}
private fun stableAnnotationId(prefix: String, element: JsonElement): String {
return "${prefix}_${localFolderSyncSha256ShortHex(json.encodeToString(JsonElement.serializer(), element))}"
}
private fun JsonElement.looksLikeSharedAnnotationStore(): Boolean {
val obj = jsonObjectOrNull()
if (obj?.array("annotations") != null) return true
val array = jsonArrayOrNull() ?: return false
val first = array.firstOrNull()?.jsonObjectOrNull() ?: return false
return first["kind"] != null && first["colorArgb"] != null
}
private fun JsonElement?.isLegacyAndroidInkArray(): Boolean {
val array = this?.jsonArrayOrNull() ?: return false
if (array.isEmpty()) return true
return array.all { element ->
val obj = element.jsonObjectOrNull() ?: return@all false
obj["kind"] == null &&
obj["points"] != null &&
(obj["annotationType"] != null || obj["inkType"] != null || obj["type"] != null)
}
}
private fun JsonElement?.isJsonArray(): Boolean = this?.jsonArrayOrNull() != null
private fun JsonElement.jsonArrayOrNull(): JsonArray? {
if (this is JsonNull) return null
return runCatching { jsonArray }.getOrNull()
}
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
if (this is JsonNull) return null
return runCatching { jsonObject }.getOrNull()
}
private fun JsonObject.array(name: String): JsonArray? = this[name]?.jsonArrayOrNull()
private fun JsonObject.objectValue(name: String): JsonObject? = this[name]?.jsonObjectOrNull()
private fun JsonObject.string(name: String): String? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull }
.getOrNull()
?.takeIf { it.isNotBlank() }
}
private fun JsonObject.int(name: String): Int? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
}
private fun JsonObject.long(name: String): Long? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
}
private fun JsonObject.float(name: String): Float? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull?.toFloat() }.getOrNull()
}
private fun JsonObject.boolean(name: String): Boolean? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.booleanOrNull }.getOrNull()
}
private fun JsonObject.toPdfPageBoundsOrNull(): PdfPageBounds? {
val left = float("left") ?: return null
val top = float("top") ?: return null
val right = float("right") ?: return null
val bottom = float("bottom") ?: return null
return PdfPageBounds(
left = minOf(left, right),
top = minOf(top, bottom),
right = maxOf(left, right),
bottom = maxOf(top, bottom)
)
}
private fun PdfPageBounds.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"left" to JsonPrimitive(left.toDouble()),
"top" to JsonPrimitive(top.toDouble()),
"right" to JsonPrimitive(right.toDouble()),
"bottom" to JsonPrimitive(bottom.toDouble())
)
)
}
private fun PdfPageBounds.isNormalizedPageBounds(): Boolean {
return left in 0f..1f &&
top in 0f..1f &&
right in 0f..1f &&
bottom in 0f..1f &&
right >= left &&
bottom >= top
}
private fun String.toPdfInkTool(): PdfInkTool {
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()
)
}

View file

@ -0,0 +1,412 @@
package com.aryan.reader.shared.pdf
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.IntSize
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
sealed interface SharedPdfInkRenderData {
data class Standard(
val path: Path,
val color: Color,
val strokeWidthPx: Float,
val cap: StrokeCap,
val blendMode: BlendMode
) : SharedPdfInkRenderData
data class Fountain(
val path: Path,
val color: Color
) : SharedPdfInkRenderData
data class Pencil(
val path: Path,
val color: Color,
val strokeWidthPx: Float,
val velocityAlpha: Float
) : SharedPdfInkRenderData
}
object SharedPdfInkRenderer {
fun createRenderData(
annotation: SharedPdfAnnotation,
canvasSize: IntSize
): SharedPdfInkRenderData? {
if (annotation.kind != PdfAnnotationKind.INK || annotation.points.isEmpty()) return null
val widthPx = canvasSize.width.coerceAtLeast(1).toFloat()
val heightPx = canvasSize.height.coerceAtLeast(1).toFloat()
val strokeWidthPx = effectiveStrokeWidthPx(annotation.strokeWidth, widthPx)
val color = Color(annotation.colorArgb)
if (annotation.points.size == 1) {
val point = annotation.points.first()
val x = point.x * widthPx
val y = point.y * heightPx
return when (annotation.tool) {
PdfInkTool.FOUNTAIN_PEN -> {
val path = Path().apply {
addOval(Rect(center = Offset(x, y), radius = strokeWidthPx / 2f))
}
SharedPdfInkRenderData.Fountain(path = path, color = color)
}
PdfInkTool.PENCIL -> {
val path = Path().apply {
moveTo(x, y)
lineTo(x, y)
}
SharedPdfInkRenderData.Pencil(
path = path,
color = color,
strokeWidthPx = strokeWidthPx,
velocityAlpha = 1f
)
}
else -> {
val path = Path().apply {
moveTo(x, y)
lineTo(x, y)
}
SharedPdfInkRenderData.Standard(
path = path,
color = color,
strokeWidthPx = strokeWidthPx,
cap = annotation.tool.strokeCap,
blendMode = annotation.tool.blendMode
)
}
}
}
return when (annotation.tool) {
PdfInkTool.PENCIL -> {
val path = annotation.points.toSmoothPath(widthPx, heightPx)
val velocityAlpha = annotation.points.velocityAlpha(widthPx, heightPx)
SharedPdfInkRenderData.Pencil(
path = path,
color = color,
strokeWidthPx = strokeWidthPx,
velocityAlpha = velocityAlpha
)
}
PdfInkTool.FOUNTAIN_PEN -> {
val (leftSide, rightSide) = calculateFountainPenEdges(
points = annotation.points,
baseWidthPx = strokeWidthPx,
pageWidthPx = widthPx,
pageHeightPx = heightPx
)
val path = Path()
if (leftSide.isNotEmpty()) {
path.moveTo(leftSide.first().x, leftSide.first().y)
leftSide.drop(1).forEach { path.lineTo(it.x, it.y) }
rightSide.asReversed().forEach { path.lineTo(it.x, it.y) }
path.close()
}
SharedPdfInkRenderData.Fountain(path = path, color = color)
}
PdfInkTool.PEN,
PdfInkTool.HIGHLIGHTER,
PdfInkTool.HIGHLIGHTER_ROUND,
PdfInkTool.ERASER,
PdfInkTool.TEXT -> {
SharedPdfInkRenderData.Standard(
path = annotation.points.toSmoothPath(widthPx, heightPx),
color = color,
strokeWidthPx = strokeWidthPx,
cap = annotation.tool.strokeCap,
blendMode = annotation.tool.blendMode
)
}
}
}
fun effectiveStrokeWidthPx(strokeWidth: Float, canvasSize: IntSize): Float {
return effectiveStrokeWidthPx(strokeWidth, canvasSize.width.coerceAtLeast(1).toFloat())
}
fun effectiveStrokeWidthPx(strokeWidth: Float, pageWidthPx: Float): Float {
val safeWidth = pageWidthPx.coerceAtLeast(1f)
return if (strokeWidth <= 1f) {
(strokeWidth * safeWidth).coerceAtLeast(0.1f)
} else {
strokeWidth.coerceAtLeast(0.1f)
}
}
fun effectiveStrokeWidthNorm(strokeWidth: Float, pageWidthPx: Float): Float {
val safeWidth = pageWidthPx.coerceAtLeast(1f)
return if (strokeWidth <= 1f) strokeWidth.coerceAtLeast(0.0001f) else strokeWidth / safeWidth
}
fun calculateSnappedPoint(
currentPoint: PdfPagePoint,
startPoint: PdfPagePoint?,
pageAspectRatio: Float,
thresholdDegrees: Double = 10.0
): PdfPagePoint {
if (startPoint == null) return currentPoint
val safeAspectRatio = pageAspectRatio.takeIf { it > 0f } ?: 1f
val dx = (currentPoint.x - startPoint.x) * safeAspectRatio
val dy = currentPoint.y - startPoint.y
val angleDeg = atan2(dy, dx) * 180 / PI
val absAngle = abs(angleDeg)
val isHorizontal = absAngle < thresholdDegrees || abs(absAngle - 180.0) < thresholdDegrees
val isVertical = abs(absAngle - 90.0) < thresholdDegrees
return when {
isHorizontal -> currentPoint.copy(y = startPoint.y)
isVertical -> currentPoint.copy(x = startPoint.x)
else -> currentPoint
}
}
fun isAnnotationHit(
annotation: SharedPdfAnnotation,
hitPoint: PdfPagePoint,
pageWidthPx: Float,
pageAspectRatio: Float,
eraserStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER).strokeWidth,
lastHitPoint: PdfPagePoint? = null
): Boolean {
return when (annotation.kind) {
PdfAnnotationKind.HIGHLIGHT,
PdfAnnotationKind.TEXT -> annotation.allBounds().any { it.contains(hitPoint.x, hitPoint.y) }
PdfAnnotationKind.INK -> isInkAnnotationHit(
annotation = annotation,
hitPoint = hitPoint,
pageWidthPx = pageWidthPx,
pageAspectRatio = pageAspectRatio,
eraserStrokeWidth = eraserStrokeWidth,
lastHitPoint = lastHitPoint
)
}
}
private fun isInkAnnotationHit(
annotation: SharedPdfAnnotation,
hitPoint: PdfPagePoint,
pageWidthPx: Float,
pageAspectRatio: Float,
eraserStrokeWidth: Float,
lastHitPoint: PdfPagePoint?
): Boolean {
if (annotation.points.isEmpty()) return false
val safeAspectRatio = pageAspectRatio.takeIf { it > 0f } ?: 1f
val eraserWidthNorm = effectiveStrokeWidthNorm(eraserStrokeWidth, pageWidthPx)
val annotationWidthNorm = effectiveStrokeWidthNorm(annotation.strokeWidth, pageWidthPx)
val threshold = eraserWidthNorm + annotationWidthNorm / 2f
val thresholdSq = threshold * threshold
fun distSqToEraser(px: Float, pyScaled: Float): Float {
val e1x = hitPoint.x
val e1yScaled = hitPoint.y / safeAspectRatio
if (lastHitPoint == null) {
val dx = px - e1x
val dy = pyScaled - e1yScaled
return dx * dx + dy * dy
}
val e0x = lastHitPoint.x
val e0yScaled = lastHitPoint.y / safeAspectRatio
val ex = e1x - e0x
val ey = e1yScaled - e0yScaled
val segmentLenSq = ex * ex + ey * ey
if (segmentLenSq < 1e-8f) {
val dx = px - e1x
val dy = pyScaled - e1yScaled
return dx * dx + dy * dy
}
val t = ((px - e0x) * ex + (pyScaled - e0yScaled) * ey) / segmentLenSq
val closestX = e0x + ex * t.coerceIn(0f, 1f)
val closestY = e0yScaled + ey * t.coerceIn(0f, 1f)
val dx = px - closestX
val dy = pyScaled - closestY
return dx * dx + dy * dy
}
if (annotation.points.size == 1) {
val p = annotation.points.first()
return distSqToEraser(p.x, p.y / safeAspectRatio) < thresholdSq
}
for (i in 0 until annotation.points.lastIndex) {
val a = annotation.points[i]
val b = annotation.points[i + 1]
val pax = hitPoint.x - a.x
val pay = (hitPoint.y - a.y) / safeAspectRatio
val bax = b.x - a.x
val bay = (b.y - a.y) / safeAspectRatio
val segmentLenSq = (bax * bax + bay * bay).coerceAtLeast(1e-6f)
val t = ((pax * bax + pay * bay) / segmentLenSq).coerceIn(0f, 1f)
val closestX = bax * t
val closestY = bay * t
val dx = pax - closestX
val dy = pay - closestY
if (dx * dx + dy * dy < thresholdSq) return true
if (lastHitPoint != null) {
if (distSqToEraser(a.x, a.y / safeAspectRatio) < thresholdSq) return true
if (distSqToEraser(b.x, b.y / safeAspectRatio) < thresholdSq) return true
}
}
return false
}
fun calculateFountainPenEdges(
points: List<PdfPagePoint>,
baseWidthPx: Float,
pageWidthPx: Float,
pageHeightPx: Float
): Pair<List<Offset>, List<Offset>> {
if (points.size < 2) return emptyList<Offset>() to emptyList<Offset>()
val leftSide = mutableListOf<Offset>()
val rightSide = mutableListOf<Offset>()
val computedWidths = FloatArray(points.size)
computedWidths[0] = baseWidthPx
val velocityFactor = 300f
for (i in 1 until points.size) {
val p0 = points[i - 1]
val p1 = points[i]
val dx = p1.x - p0.x
val dy = p1.y - p0.y
val aspect = if (pageWidthPx > 0f && pageHeightPx > 0f) pageHeightPx / pageWidthPx else 1f
val scaledDy = dy * aspect
val distNorm = sqrt(dx * dx + scaledDy * scaledDy)
val timeDelta = (p1.timestamp - p0.timestamp).coerceAtLeast(1)
val velocityNorm = distNorm / timeDelta
val targetWidth = (baseWidthPx * (1f / (1f + velocityNorm * velocityFactor))).coerceIn(
baseWidthPx * 0.2f,
baseWidthPx * 1.4f
)
computedWidths[i] = computedWidths[i - 1] * 0.6f + targetWidth * 0.4f
}
for (i in 0 until points.lastIndex) {
val current = points[i]
val next = points[i + 1]
val currentX = current.x * pageWidthPx
val currentY = current.y * pageHeightPx
val nextX = next.x * pageWidthPx
val nextY = next.y * pageHeightPx
val angle = atan2(nextY - currentY, nextX - currentX)
val normalAngle = angle - (PI / 2f).toFloat()
val halfWidth = computedWidths[i] / 2f
leftSide += Offset(
x = currentX + cos(normalAngle) * halfWidth,
y = currentY + sin(normalAngle) * halfWidth
)
rightSide += Offset(
x = currentX - cos(normalAngle) * halfWidth,
y = currentY - sin(normalAngle) * halfWidth
)
}
val last = points.last()
val previous = points[points.lastIndex - 1]
val lastX = last.x * pageWidthPx
val lastY = last.y * pageHeightPx
val previousX = previous.x * pageWidthPx
val previousY = previous.y * pageHeightPx
val lastAngle = atan2(lastY - previousY, lastX - previousX)
val lastNormal = lastAngle - (PI / 2f).toFloat()
val lastHalfWidth = computedWidths.last() / 2f
leftSide += Offset(
x = lastX + cos(lastNormal) * lastHalfWidth,
y = lastY + sin(lastNormal) * lastHalfWidth
)
rightSide += Offset(
x = lastX - cos(lastNormal) * lastHalfWidth,
y = lastY - sin(lastNormal) * lastHalfWidth
)
return leftSide to rightSide
}
}
fun PdfInkTool.sharedPdfStrokeWidthRange(): ClosedFloatingPointRange<Float> {
return when (this) {
PdfInkTool.HIGHLIGHTER,
PdfInkTool.HIGHLIGHTER_ROUND -> 0.01f..0.06f
PdfInkTool.ERASER -> 0.002f..0.10f
PdfInkTool.TEXT -> 0.01f..0.08f
PdfInkTool.PEN,
PdfInkTool.FOUNTAIN_PEN,
PdfInkTool.PENCIL -> 0.001f..0.015f
}
}
fun Float.sharedPdfStrokePercent(range: ClosedFloatingPointRange<Float>): Int {
val span = (range.endInclusive - range.start).coerceAtLeast(0.0001f)
return (((this - range.start) / span) * 100f).toInt().coerceIn(1, 100)
}
private val PdfInkTool.strokeCap: StrokeCap
get() = when (this) {
PdfInkTool.HIGHLIGHTER -> StrokeCap.Butt
PdfInkTool.HIGHLIGHTER_ROUND -> StrokeCap.Round
else -> StrokeCap.Round
}
private val PdfInkTool.blendMode: BlendMode
get() = when (this) {
PdfInkTool.HIGHLIGHTER,
PdfInkTool.HIGHLIGHTER_ROUND -> BlendMode.Multiply
else -> BlendMode.SrcOver
}
private fun PdfPageBounds.contains(x: Float, y: Float): Boolean {
return x in left..right && y in top..bottom
}
private fun SharedPdfAnnotation.allBounds(): List<PdfPageBounds> {
return boundsList.ifEmpty { listOfNotNull(bounds) }
}
private fun List<PdfPagePoint>.toSmoothPath(widthPx: Float, heightPx: Float): Path {
val path = Path()
val first = first()
path.moveTo(first.x * widthPx, first.y * heightPx)
for (i in 1 until size) {
val p0 = this[i - 1]
val p1 = this[i]
val p0x = p0.x * widthPx
val p0y = p0.y * heightPx
val p1x = p1.x * widthPx
val p1y = p1.y * heightPx
val midX = (p0x + p1x) / 2f
val midY = (p0y + p1y) / 2f
if (i == 1) {
path.lineTo(midX, midY)
} else {
path.quadraticTo(p0x, p0y, midX, midY)
}
}
val last = last()
path.lineTo(last.x * widthPx, last.y * heightPx)
return path
}
private fun List<PdfPagePoint>.velocityAlpha(widthPx: Float, heightPx: Float): Float {
if (size < 2) return 1f
var totalDistance = 0f
for (i in 1 until size) {
val p0 = this[i - 1]
val p1 = this[i]
val dx = (p1.x - p0.x) * widthPx
val dy = (p1.y - p0.y) * heightPx
totalDistance += sqrt(dx * dx + dy * dy)
}
val duration = (last().timestamp - first().timestamp).coerceAtLeast(1)
val velocity = totalDistance / duration
return (1f - (velocity - 0.2f) / 1.8f).coerceIn(0.4f, 1f)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,353 @@
package com.aryan.reader.shared.pdf
import androidx.compose.ui.unit.IntSize
import kotlinx.serialization.Serializable
import kotlin.math.ceil
@Serializable
data class SharedPdfTextStyleConfig(
val colorArgb: Int = 0xFF000000.toInt(),
val backgroundColorArgb: Int = 0x00000000,
val fontSize: Float = 16f,
val isBold: Boolean = false,
val isItalic: Boolean = false,
val isUnderline: Boolean = false,
val isStrikeThrough: Boolean = false,
val fontPath: String? = null,
val fontName: String? = null
)
@Serializable
data class SharedPdfTextFontPreset(
val name: String,
val fontPath: String? = null
)
enum class SharedPdfTextResizeHandle {
TOP_LEFT,
TOP_CENTER,
TOP_RIGHT,
RIGHT_CENTER,
BOTTOM_RIGHT,
BOTTOM_CENTER,
BOTTOM_LEFT,
LEFT_CENTER
}
@Serializable
data class SharedPdfTextDraft(
val id: String,
val pageIndex: Int,
val bounds: PdfPageBounds,
val text: String = "",
val style: SharedPdfTextStyleConfig = SharedPdfTextStyleConfig(),
val createdAt: Long = 0L,
val isManuallySized: Boolean = false
)
object SharedPdfTextAnnotationDefaults {
val fontSizes: List<Float> = listOf(12f, 14f, 16f, 18f, 20f, 24f, 30f)
val fontPresets: List<SharedPdfTextFontPreset> = listOf(
SharedPdfTextFontPreset("Default"),
SharedPdfTextFontPreset("Merriweather", "asset:fonts/merriweather.ttf"),
SharedPdfTextFontPreset("Lato", "asset:fonts/lato.ttf"),
SharedPdfTextFontPreset("Lora", "asset:fonts/lora.ttf"),
SharedPdfTextFontPreset("Roboto Mono", "asset:fonts/roboto_mono.ttf"),
SharedPdfTextFontPreset("Lexend", "asset:fonts/lexend.ttf")
)
val textColorPalette: List<Int>
get() = SharedPdfAnnotationDefaults.penPalette
val backgroundColorPalette: List<Int> = listOf(
0x00000000,
0x8CFF9800.toInt(),
0x8CFFEB3B.toInt(),
0x8C81C784.toInt(),
0x8C64B5F6.toInt(),
0x8CE1BEE7.toInt()
)
fun normalizeTextDraft(text: String): String {
return text
.replace("\r\n", "\n")
.replace('\r', '\n')
.trim()
}
fun createAnnotation(
id: String,
pageIndex: Int,
anchor: PdfPagePoint,
canvasSize: IntSize,
text: String,
style: SharedPdfTextStyleConfig,
createdAt: Long
): SharedPdfAnnotation {
val cleanText = normalizeTextDraft(text)
return SharedPdfAnnotation(
id = id,
pageIndex = pageIndex,
kind = PdfAnnotationKind.TEXT,
tool = PdfInkTool.TEXT,
bounds = boundsForPlacedText(anchor, canvasSize, cleanText, style),
text = cleanText,
colorArgb = style.colorArgb,
backgroundArgb = style.backgroundColorArgb,
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
fontSize = style.fontSize,
isBold = style.isBold,
isItalic = style.isItalic,
isUnderline = style.isUnderline,
isStrikeThrough = style.isStrikeThrough,
fontPath = style.fontPath,
fontName = style.fontName,
createdAt = createdAt
)
}
fun createDraft(
id: String,
pageIndex: Int,
anchor: PdfPagePoint,
canvasSize: IntSize,
style: SharedPdfTextStyleConfig,
createdAt: Long
): SharedPdfTextDraft {
return SharedPdfTextDraft(
id = id,
pageIndex = pageIndex,
bounds = boundsForPlacedText(anchor, canvasSize, " ", style),
text = "",
style = style,
createdAt = createdAt
)
}
fun boundsForPlacedText(
anchor: PdfPagePoint,
canvasSize: IntSize,
text: String,
style: SharedPdfTextStyleConfig
): 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 left = anchor.x.coerceIn(0f, 1f - widthNorm)
val top = anchor.y.coerceIn(0f, 1f - heightNorm)
return PdfPageBounds(
left = left,
top = top,
right = left + widthNorm,
bottom = top + heightNorm
)
}
fun estimateLineCount(text: String, fontSize: Float, widthPx: Float): Int {
if (text.isBlank()) return 1
val averageCharWidth = (fontSize * 0.55f).coerceAtLeast(1f)
val charsPerLine = (widthPx / averageCharWidth).toInt().coerceAtLeast(8)
return text.lineSequence().sumOf { rawLine ->
val length = rawLine.length.coerceAtLeast(1)
ceil(length / charsPerLine.toFloat()).toInt().coerceAtLeast(1)
}.coerceAtLeast(1)
}
private fun estimateWidthNorm(
text: String,
style: SharedPdfTextStyleConfig,
pageWidthPx: Float
): Float {
val longestLine = text.lineSequence().maxOfOrNull { it.length } ?: 0
val estimatedTextWidth = (longestLine.coerceAtLeast(12) * style.fontSize * 0.55f) + 18f
return (estimatedTextWidth / pageWidthPx).coerceAtLeast(0.28f)
}
}
fun SharedPdfTextDraft.withText(
text: String,
canvasSize: IntSize
): SharedPdfTextDraft {
val normalizedText = text
.replace("\r\n", "\n")
.replace('\r', '\n')
if (isManuallySized) {
return copy(text = normalizedText)
}
val anchor = PdfPagePoint(bounds.left, bounds.top, createdAt)
return copy(
text = normalizedText,
bounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
anchor = anchor,
canvasSize = canvasSize,
text = normalizedText.ifBlank { " " },
style = style
)
)
}
fun SharedPdfTextDraft.withStyle(
style: SharedPdfTextStyleConfig,
canvasSize: IntSize
): SharedPdfTextDraft {
if (isManuallySized) {
return copy(style = style)
}
val anchor = PdfPagePoint(bounds.left, bounds.top, createdAt)
return copy(
style = style,
bounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
anchor = anchor,
canvasSize = canvasSize,
text = text.ifBlank { " " },
style = style
)
)
}
fun SharedPdfTextDraft.withBounds(bounds: PdfPageBounds): SharedPdfTextDraft {
return copy(bounds = bounds.coercedToPage(), isManuallySized = true)
}
fun SharedPdfTextDraft.toAnnotation(): SharedPdfAnnotation {
val cleanText = SharedPdfTextAnnotationDefaults.normalizeTextDraft(text)
return SharedPdfAnnotation(
id = id,
pageIndex = pageIndex,
kind = PdfAnnotationKind.TEXT,
tool = PdfInkTool.TEXT,
bounds = bounds,
text = cleanText,
colorArgb = style.colorArgb,
backgroundArgb = style.backgroundColorArgb,
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
fontSize = style.fontSize,
isBold = style.isBold,
isItalic = style.isItalic,
isUnderline = style.isUnderline,
isStrikeThrough = style.isStrikeThrough,
fontPath = style.fontPath,
fontName = style.fontName,
createdAt = createdAt
)
}
fun PdfPageBounds.resizedBy(
handle: SharedPdfTextResizeHandle,
deltaXPx: Float,
deltaYPx: Float,
canvasSize: IntSize,
minWidthPx: Float = 50f,
minHeightPx: Float = 50f
): PdfPageBounds {
val pageWidthPx = canvasSize.width.coerceAtLeast(1).toFloat()
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
val minWidth = minWidthPx.coerceIn(1f, pageWidthPx)
val minHeight = minHeightPx.coerceIn(1f, pageHeightPx)
var leftPx = left * pageWidthPx
var topPx = top * pageHeightPx
var rightPx = right * pageWidthPx
var bottomPx = bottom * pageHeightPx
when (handle) {
SharedPdfTextResizeHandle.TOP_LEFT -> {
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
}
SharedPdfTextResizeHandle.TOP_CENTER -> {
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
}
SharedPdfTextResizeHandle.TOP_RIGHT -> {
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
}
SharedPdfTextResizeHandle.RIGHT_CENTER -> {
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
}
SharedPdfTextResizeHandle.BOTTOM_RIGHT -> {
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
}
SharedPdfTextResizeHandle.BOTTOM_CENTER -> {
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
}
SharedPdfTextResizeHandle.BOTTOM_LEFT -> {
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
}
SharedPdfTextResizeHandle.LEFT_CENTER -> {
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
}
}
return PdfPageBounds(
left = leftPx / pageWidthPx,
top = topPx / pageHeightPx,
right = rightPx / pageWidthPx,
bottom = bottomPx / pageHeightPx
).coercedToPage()
}
fun PdfPageBounds.movedBy(
deltaXPx: Float,
deltaYPx: Float,
canvasSize: IntSize
): PdfPageBounds {
val pageWidthPx = canvasSize.width.coerceAtLeast(1).toFloat()
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
val widthPx = ((right - left) * pageWidthPx).coerceIn(1f, pageWidthPx)
val heightPx = ((bottom - top) * pageHeightPx).coerceIn(1f, pageHeightPx)
val nextLeftPx = ((left * pageWidthPx) + deltaXPx).coerceIn(0f, (pageWidthPx - widthPx).coerceAtLeast(0f))
val nextTopPx = ((top * pageHeightPx) + deltaYPx).coerceIn(0f, (pageHeightPx - heightPx).coerceAtLeast(0f))
return PdfPageBounds(
left = nextLeftPx / pageWidthPx,
top = nextTopPx / pageHeightPx,
right = (nextLeftPx + widthPx) / pageWidthPx,
bottom = (nextTopPx + heightPx) / pageHeightPx
).coercedToPage()
}
fun SharedPdfAnnotation.sharedPdfTextStyle(): SharedPdfTextStyleConfig {
return SharedPdfTextStyleConfig(
colorArgb = colorArgb,
backgroundColorArgb = backgroundArgb,
fontSize = fontSize,
isBold = isBold,
isItalic = isItalic,
isUnderline = isUnderline,
isStrikeThrough = isStrikeThrough,
fontPath = fontPath,
fontName = fontName
)
}
fun SharedPdfAnnotation.withSharedPdfTextStyle(style: SharedPdfTextStyleConfig): SharedPdfAnnotation {
return copy(
colorArgb = style.colorArgb,
backgroundArgb = style.backgroundColorArgb,
fontSize = style.fontSize,
isBold = style.isBold,
isItalic = style.isItalic,
isUnderline = style.isUnderline,
isStrikeThrough = style.isStrikeThrough,
fontPath = style.fontPath,
fontName = style.fontName
)
}
private fun PdfPageBounds.coercedToPage(): PdfPageBounds {
val coercedLeft = left.coerceIn(0f, 1f)
val coercedTop = top.coerceIn(0f, 1f)
val coercedRight = right.coerceIn(coercedLeft, 1f)
val coercedBottom = bottom.coerceIn(coercedTop, 1f)
return PdfPageBounds(
left = coercedLeft,
top = coercedTop,
right = coercedRight,
bottom = coercedBottom
)
}

View file

@ -1,66 +1,152 @@
package com.aryan.reader.shared.reader
import com.aryan.reader.paginatedreader.SemanticBlock
import com.aryan.reader.paginatedreader.SemanticFlexContainer
import com.aryan.reader.paginatedreader.SemanticList
import com.aryan.reader.paginatedreader.SemanticTable
import com.aryan.reader.paginatedreader.SemanticTextBlock
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
import com.aryan.reader.shared.HighlightColor
import com.aryan.reader.shared.UserHighlight
sealed interface ReaderLinkTarget {
data class External(val url: String) : ReaderLinkTarget
data class Internal(val locator: ReaderLocator) : ReaderLinkTarget
data object Ignored : ReaderLinkTarget
}
data class ReaderBookmark(
val id: String,
val pageIndex: Int,
val chapterTitle: String,
val preview: String
val preview: String,
val locator: ReaderLocator = ReaderLocator(pageIndex = pageIndex, textQuote = preview)
)
data class ReaderSearchResult(
val pageIndex: Int,
val chapterTitle: String,
val preview: String
val preview: String,
val matchIndex: Int = 0,
val chapterIndex: Int = 0,
val locator: ReaderLocator = ReaderLocator(
chapterIndex = chapterIndex,
pageIndex = pageIndex,
startOffset = matchIndex,
textQuote = preview
)
)
data class ReaderSearchOptions(
val matchCase: Boolean = false,
val wholeWords: Boolean = false
)
data class ReaderSessionState(
val reader: PaginatedReaderState,
val bookmarks: List<ReaderBookmark> = emptyList(),
val highlights: List<UserHighlight> = emptyList(),
val isSearchActive: Boolean = false,
val showSearchResultsPanel: Boolean = true,
val searchQuery: String = "",
val searchOptions: ReaderSearchOptions = ReaderSearchOptions(),
val searchResults: List<ReaderSearchResult> = emptyList(),
val activeSearchResultIndex: Int = -1
val activeSearchResultIndex: Int = -1,
val navigationLocator: ReaderLocator? = null,
val navigationRequestId: Long = 0L
) {
val currentBookmark: ReaderBookmark?
get() = bookmarks.firstOrNull { it.pageIndex == reader.currentPageIndex }
get() = navigationLocator
?.let { locator -> bookmarks.firstOrNull { it.locator.sameLocation(locator) } }
?: bookmarks.firstOrNull { it.pageIndex == reader.currentPageIndex && !it.locator.hasTextRange }
val activeSearchResult: ReaderSearchResult?
get() = searchResults.getOrNull(activeSearchResultIndex)
val canGoToPreviousSearchResult: Boolean
get() = when {
activeSearchResultIndex > 0 -> true
activeSearchResultIndex >= 0 -> false
else -> searchResults.any { it.pageIndex <= reader.currentPageIndex }
}
val canGoToNextSearchResult: Boolean
get() = when {
activeSearchResultIndex in 0 until searchResults.lastIndex -> true
activeSearchResultIndex >= 0 -> false
else -> searchResults.any { it.pageIndex >= reader.currentPageIndex }
}
}
class ReaderEngine(
private val paginator: SimplePaginator = SimplePaginator()
) {
private data class PaginationCacheKey(
val bookId: String,
val chapterSignature: Int,
val settings: ReaderSettings
)
private val paginationCache = object : LinkedHashMap<PaginationCacheKey, List<ReaderPage>>(8, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<PaginationCacheKey, List<ReaderPage>>?): Boolean {
return size > 8
}
}
fun createSession(
book: SharedEpubBook,
settings: ReaderSettings = ReaderSettings()
settings: ReaderSettings = ReaderSettings(),
initialPageIndex: Int = 0,
bookmarks: List<ReaderBookmark> = emptyList(),
highlights: List<UserHighlight> = emptyList()
): ReaderSessionState {
val pages = pagesFor(book, settings)
val initialIndex = initialPageIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0))
val reader = PaginatedReaderState(
book = book,
pages = pages,
currentPageIndex = initialIndex,
settings = settings
)
return ReaderSessionState(
reader = PaginatedReaderState(
book = book,
pages = paginator.paginate(book, settings),
settings = settings
)
reader = reader,
bookmarks = bookmarks
.mapNotNull { it.normalizedForBook(book, pages) }
.distinctBy { it.locationKey() }
.sortedWith(compareBy<ReaderBookmark> { it.pageIndex }.thenBy { it.locator.startOffset ?: -1 }),
highlights = highlights
.map { it.withNormalizedLocator() }
.filter { (it.locator.chapterIndex ?: it.chapterIndex) in book.chapters.indices }
.distinctBy { it.id },
navigationLocator = reader.currentPage?.toLocator(book)
)
}
fun next(state: ReaderSessionState): ReaderSessionState {
if (!state.reader.canGoNext) return state
return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex + 1))
return goToPage(state, state.reader.currentPageIndex + 1)
}
fun previous(state: ReaderSessionState): ReaderSessionState {
if (!state.reader.canGoPrevious) return state
return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex - 1))
return goToPage(state, state.reader.currentPageIndex - 1)
}
fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
val page = state.reader.pages.getOrNull(target)
return state.copy(
reader = state.reader.copy(currentPageIndex = target),
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target }
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target },
navigationLocator = page?.toLocator(state.reader.book),
navigationRequestId = state.navigationRequestId + 1
)
}
fun goToPageNumber(state: ReaderSessionState, pageNumber: Int): ReaderSessionState {
return goToPage(state, pageNumber - 1)
}
fun goToProgress(state: ReaderSessionState, progress: Float): ReaderSessionState {
if (state.reader.pages.isEmpty()) return state
val target = ((state.reader.pages.lastIndex) * progress.coerceIn(0f, 1f)).toInt()
@ -72,24 +158,307 @@ class ReaderEngine(
return if (pageIndex >= 0) goToPage(state, pageIndex) else state
}
fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState {
val pageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
.takeIf { it >= 0 }
?: locator.pageIndex
?.takeIf { it in state.reader.pages.indices }
?: return state
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()
)
return state.copy(
reader = state.reader.copy(currentPageIndex = pageIndex),
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex },
navigationLocator = normalizedLocator,
navigationRequestId = state.navigationRequestId + 1
)
}
fun resolveLink(
state: ReaderSessionState,
href: String,
sourceChapterIndex: Int? = state.reader.currentPage?.chapterIndex
): ReaderLinkTarget {
val trimmed = href.trim()
if (trimmed.isBlank()) {
logReaderLink("resolve_ignored reason=blank")
return ReaderLinkTarget.Ignored
}
val normalizedHref = when {
trimmed.startsWith("about:blank#", ignoreCase = true) -> "#${trimmed.substringAfter('#')}"
trimmed.startsWith("www.", ignoreCase = true) -> "https://$trimmed"
else -> trimmed
}
logReaderLink("resolve_start href=\"$trimmed\" normalized=\"$normalizedHref\" sourceChapter=$sourceChapterIndex")
val scheme = normalizedHref.schemeOrNull()
if (scheme != null) {
return when (scheme.lowercase()) {
"http", "https", "mailto", "tel" -> {
logReaderLink("resolve_external scheme=$scheme")
ReaderLinkTarget.External(normalizedHref)
}
else -> {
logReaderLink("resolve_ignored reason=unsupported_scheme scheme=$scheme")
ReaderLinkTarget.Ignored
}
}
}
val sourceIndex = sourceChapterIndex
?.takeIf { it in state.reader.book.chapters.indices }
?: state.reader.currentPage?.chapterIndex
?: 0
val sourceChapter = state.reader.book.chapters.getOrNull(sourceIndex)
?: run {
logReaderLink("resolve_ignored reason=missing_source sourceChapter=$sourceIndex")
return ReaderLinkTarget.Ignored
}
val pathPart = normalizedHref.substringBefore('#').substringBefore('?')
val fragment = normalizedHref.substringAfter('#', missingDelimiterValue = "").substringBefore('?')
.takeIf { it.isNotBlank() }
?.percentDecodedOrSelf()
val targetChapterIndex = if (pathPart.isBlank()) {
sourceIndex
} else {
val targetPath = resolveEpubPath(sourceChapter.baseHref, pathPart.percentDecodedOrSelf())
state.reader.book.chapters.indexOfFirst { chapter ->
val chapterPath = normalizeEpubPath(chapter.baseHref.orEmpty())
chapterPath == targetPath ||
chapter.id == pathPart ||
chapterPath.substringAfterLast('/') == targetPath.substringAfterLast('/')
}
}
if (targetChapterIndex !in state.reader.book.chapters.indices) {
logReaderLink(
"resolve_ignored reason=missing_target path=\"$pathPart\" sourceChapter=$sourceIndex " +
"base=\"${sourceChapter.baseHref.orEmpty()}\""
)
return ReaderLinkTarget.Ignored
}
val targetChapter = state.reader.book.chapters[targetChapterIndex]
val targetOffset = fragment
?.let { targetChapter.semanticBlocks.findElementOffset(it) }
?: 0
val targetPageIndex = state.reader.pages.indexOfFirst { page ->
page.chapterIndex == targetChapterIndex && targetOffset in page.startOffset..page.endOffset
}.takeIf { it >= 0 }
val locator = ReaderLocator(
chapterIndex = targetChapterIndex,
chapterId = targetChapter.id,
href = targetChapter.baseHref,
pageIndex = targetPageIndex,
startOffset = targetOffset,
endOffset = targetOffset,
cfi = "desktop:$targetChapterIndex:$targetOffset:$targetOffset"
)
logReaderLink(
"resolve_internal targetChapter=$targetChapterIndex targetPage=$targetPageIndex " +
"fragment=\"${fragment.orEmpty()}\" offset=$targetOffset"
)
return ReaderLinkTarget.Internal(locator)
}
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)
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 },
navigationLocator = normalizedLocator ?: state.navigationLocator
)
}
fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState {
return state.copy(reader = paginator.repaginate(state.reader, settings))
val current = state.reader.currentPage
val pages = pagesFor(state.reader.book, settings)
val newIndex = if (current == null) {
0
} else {
pages.indexOfFirst {
it.chapterIndex == current.chapterIndex && it.startOffset <= current.startOffset && it.endOffset >= current.startOffset
}.takeIf { it >= 0 } ?: 0
}
val updated = state.copy(
reader = state.reader.copy(
pages = pages,
currentPageIndex = newIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)),
settings = settings
)
)
return if (updated.searchQuery.isNotBlank()) search(updated, updated.searchQuery) 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
)
return synchronized(paginationCache) {
paginationCache.getOrPut(key) {
paginator.paginate(book, settings)
}
}
}
fun openSearch(state: ReaderSessionState): ReaderSessionState {
return state.copy(isSearchActive = true, showSearchResultsPanel = true)
}
fun closeSearch(state: ReaderSessionState): ReaderSessionState {
return state.copy(
isSearchActive = false,
showSearchResultsPanel = true,
searchQuery = "",
searchResults = emptyList(),
activeSearchResultIndex = -1
)
}
fun toggleSearchResultsPanel(state: ReaderSessionState): ReaderSessionState {
return state.copy(showSearchResultsPanel = !state.showSearchResultsPanel)
}
fun updateSearchOptions(state: ReaderSessionState, options: ReaderSearchOptions): ReaderSessionState {
val updated = state.copy(searchOptions = options)
return if (updated.searchQuery.isBlank()) updated else search(updated, updated.searchQuery)
}
fun toggleBookmark(state: ReaderSessionState): ReaderSessionState {
val page = state.reader.currentPage ?: return state
val existing = state.bookmarks.firstOrNull { it.pageIndex == state.reader.currentPageIndex }
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
val locator = state.navigationLocator
?.takeIf { it.belongsTo(page) }
?.normalizedForPage(state, page.pageIndex)
?: ReaderLocator(
chapterIndex = page.chapterIndex,
chapterId = chapter?.id,
pageIndex = page.pageIndex,
startOffset = page.startOffset,
endOffset = page.endOffset,
textQuote = page.text.preview()
)
val preview = locator.textQuote?.takeIf { it.isNotBlank() } ?: page.text.preview()
return toggleBookmarkAtLocator(
state = state,
locator = locator,
chapterTitle = page.chapterTitle,
preview = preview
)
}
fun toggleBookmarkAtLocator(
state: ReaderSessionState,
locator: ReaderLocator,
chapterTitle: String? = null,
preview: String? = null
): ReaderSessionState {
val targetPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
.takeIf { it >= 0 }
?: locator.pageIndex
?.takeIf { it in state.reader.pages.indices }
?: state.reader.currentPageIndex
val page = state.reader.pages.getOrNull(targetPageIndex) ?: return state
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
chapterIndex = page.chapterIndex,
chapterId = chapter?.id,
href = chapter?.baseHref,
pageIndex = targetPageIndex,
startOffset = page.startOffset,
endOffset = page.endOffset,
textQuote = preview ?: page.text.preview(),
cfi = locator.cfi ?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}"
)
val existing = state.bookmarks.firstOrNull {
it.locator.sameLocation(normalizedLocator) ||
(!normalizedLocator.hasTextRange && it.pageIndex == targetPageIndex)
}
val updated = if (existing != null) {
state.bookmarks - existing
} else {
state.bookmarks + ReaderBookmark(
id = "${state.reader.book.id}_${state.reader.currentPageIndex}",
pageIndex = state.reader.currentPageIndex,
chapterTitle = page.chapterTitle,
preview = page.text.preview()
id = bookmarkId(state.reader.book.id, targetPageIndex, normalizedLocator),
pageIndex = targetPageIndex,
chapterTitle = chapterTitle ?: page.chapterTitle,
preview = preview ?: page.text.preview(),
locator = normalizedLocator
)
}
return state.copy(bookmarks = updated.sortedBy { it.pageIndex })
return state.copy(
bookmarks = updated.sortedWith(
compareBy<ReaderBookmark> { it.pageIndex }.thenBy { it.locator.startOffset ?: -1 }
)
)
}
fun upsertHighlight(state: ReaderSessionState, highlight: UserHighlight): ReaderSessionState {
if (highlight.text.isBlank()) return state
val normalized = highlight.withNormalizedLocator()
val existingIndex = state.highlights.indexOfFirst {
it.id == normalized.id ||
(it.chapterIndex == normalized.chapterIndex && it.locator.sameLocation(normalized.locator))
}
val updated = state.highlights.toMutableList()
if (existingIndex >= 0) {
updated[existingIndex] = updated[existingIndex].copy(
cfi = normalized.cfi,
text = normalized.text,
color = normalized.color,
chapterIndex = normalized.chapterIndex,
locator = normalized.locator
)
} else {
updated += normalized
}
return state.copy(
highlights = updated
.filter { (it.locator.chapterIndex ?: it.chapterIndex) in state.reader.book.chapters.indices }
.distinctBy { it.id }
)
}
fun updateHighlight(
state: ReaderSessionState,
highlightId: String,
color: HighlightColor? = null,
note: String? = null
): ReaderSessionState {
return state.copy(
highlights = state.highlights.map { highlight ->
if (highlight.id == highlightId) {
highlight.copy(
color = color ?: highlight.color,
note = if (note != null) note.takeIf { it.isNotBlank() } else highlight.note
)
} else {
highlight
}
}
)
}
fun deleteHighlight(state: ReaderSessionState, highlightId: String): ReaderSessionState {
return state.copy(highlights = state.highlights.filterNot { it.id == highlightId })
}
fun search(state: ReaderSessionState, query: String): ReaderSessionState {
@ -97,57 +466,305 @@ class ReaderEngine(
val results = if (normalized.isBlank()) {
emptyList()
} else {
state.reader.pages.mapNotNull { page ->
val index = page.text.indexOf(normalized, ignoreCase = true)
if (index < 0) {
null
} else {
ReaderSearchResult(
pageIndex = page.pageIndex,
chapterTitle = page.chapterTitle,
preview = page.text.previewAround(index, normalized.length)
)
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(
isSearchActive = state.isSearchActive || normalized.isNotBlank(),
showSearchResultsPanel = state.showSearchResultsPanel || normalized.isNotBlank(),
searchQuery = query,
searchResults = results,
activeSearchResultIndex = activeIndex
)
return updated.activeSearchResult?.let { goToPage(updated, it.pageIndex) } ?: updated
return updated.activeSearchResult?.let { goToSearchResult(updated, activeIndex) } ?: updated
}
fun nextSearchResult(state: ReaderSessionState): ReaderSessionState {
if (state.searchResults.isEmpty()) return state
val nextIndex = if (state.activeSearchResultIndex < state.searchResults.lastIndex) {
val targetIndex = if (state.activeSearchResultIndex >= 0) {
state.activeSearchResultIndex + 1
} else {
0
state.searchResults.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex }
}
return state.copy(
reader = state.reader.copy(currentPageIndex = state.searchResults[nextIndex].pageIndex),
activeSearchResultIndex = nextIndex
)
if (targetIndex !in state.searchResults.indices) return state
return goToSearchResult(state, targetIndex)
}
fun previousSearchResult(state: ReaderSessionState): ReaderSessionState {
if (state.searchResults.isEmpty()) return state
val nextIndex = if (state.activeSearchResultIndex > 0) {
val targetIndex = if (state.activeSearchResultIndex >= 0) {
state.activeSearchResultIndex - 1
} else {
state.searchResults.lastIndex
state.searchResults.indexOfLast { it.pageIndex <= state.reader.currentPageIndex }
}
if (targetIndex !in state.searchResults.indices) return state
return goToSearchResult(state, targetIndex)
}
fun goToSearchResult(state: ReaderSessionState, resultIndex: Int): ReaderSessionState {
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) }
.takeIf { it >= 0 }
?: result.pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
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 = state.searchResults[nextIndex].pageIndex),
activeSearchResultIndex = nextIndex
reader = state.reader.copy(currentPageIndex = targetPage),
activeSearchResultIndex = targetIndex,
navigationLocator = result.locator.copy(pageIndex = targetPage).withFallbacks(
chapterIndex = page?.chapterIndex,
chapterId = chapter?.id,
href = chapter?.baseHref,
pageIndex = targetPage
),
navigationRequestId = state.navigationRequestId + 1
)
}
}
private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
val targetChapter = locator.chapterIndex
if (targetChapter != null && targetChapter != chapterIndex) return false
if (locator.hasTextRange) {
val start = locator.startOffset ?: return false
val end = locator.endOffset ?: start
return if (start == end) {
start in startOffset..endOffset
} else {
start < endOffset && end > startOffset
}
}
val targetPage = locator.pageIndex
return targetPage != null && targetPage == pageIndex
}
private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List<ReaderPage>): ReaderBookmark? {
val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) }
.takeIf { it >= 0 }
?: pageIndex.takeIf { it in pages.indices }
?: return null
val page = pages.getOrNull(targetPageIndex) ?: return null
val chapter = book.chapters.getOrNull(page.chapterIndex)
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
chapterIndex = page.chapterIndex,
chapterId = chapter?.id,
href = chapter?.baseHref,
pageIndex = targetPageIndex,
startOffset = page.startOffset,
endOffset = page.endOffset,
textQuote = preview.ifBlank { page.text.preview() },
cfi = locator.cfi ?: page.toDesktopCfi()
)
return copy(
pageIndex = targetPageIndex,
chapterTitle = chapterTitle.ifBlank { page.chapterTitle },
preview = preview.ifBlank { normalizedLocator.textQuote ?: page.text.preview() },
locator = normalizedLocator
)
}
private fun ReaderBookmark.locationKey(): String {
val locator = locator
return listOf(
locator.chapterIndex,
locator.pageIndex,
locator.startOffset,
locator.endOffset,
locator.cfi
).joinToString(":")
}
private fun bookmarkId(bookId: String, pageIndex: Int, locator: ReaderLocator): String {
val chapter = locator.chapterIndex ?: -1
val start = locator.startOffset ?: -1
val end = locator.endOffset ?: start
return "${bookId}_${pageIndex}_${chapter}_${start}_${end}"
}
private fun ReaderLocator.belongsTo(page: ReaderPage): Boolean {
val targetChapter = chapterIndex
if (targetChapter != null && targetChapter != page.chapterIndex) return false
if (pageIndex == page.pageIndex) return true
val start = startOffset
val end = endOffset ?: start
if (start != null && end != null) {
return if (start == end) {
start in page.startOffset..page.endOffset
} else {
start < page.endOffset && end > page.startOffset
}
}
return pageIndex == page.pageIndex
}
private fun ReaderLocator.normalizedForPage(state: ReaderSessionState, pageIndex: Int): ReaderLocator? {
val page = state.reader.pages.getOrNull(pageIndex) ?: return null
val chapter = state.reader.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 ReaderPage.toLocator(book: SharedEpubBook): ReaderLocator {
val chapter = book.chapters.getOrNull(chapterIndex)
return ReaderLocator(
chapterIndex = chapterIndex,
chapterId = chapter?.id,
href = chapter?.baseHref,
pageIndex = pageIndex,
startOffset = startOffset,
endOffset = endOffset,
textQuote = text.preview(),
cfi = toDesktopCfi()
)
}
private fun ReaderPage.toDesktopCfi(): String {
return "desktop:$chapterIndex:$startOffset:$endOffset"
}
private fun String.schemeOrNull(): String? {
val colonIndex = indexOf(':')
if (colonIndex <= 0) return null
val firstPathIndex = listOf(indexOf('/'), indexOf('?'), indexOf('#'))
.filter { it >= 0 }
.minOrNull()
if (firstPathIndex != null && firstPathIndex < colonIndex) return null
val candidate = substring(0, colonIndex)
return candidate.takeIf { it.all { char -> char.isLetterOrDigit() || char == '+' || char == '-' || char == '.' } }
}
private fun resolveEpubPath(baseHref: String?, hrefPath: String): String {
val path = hrefPath.trimStart('/')
if (path.isBlank()) return normalizeEpubPath(baseHref.orEmpty())
val base = baseHref.orEmpty()
val baseDirectory = if (base.substringAfterLast('/', base).contains('.')) {
base.substringBeforeLast('/', missingDelimiterValue = "")
} else {
base
}
return normalizeEpubPath(if (baseDirectory.isBlank()) path else "$baseDirectory/$path")
}
private fun normalizeEpubPath(path: String): String {
val parts = mutableListOf<String>()
path.replace('\\', '/')
.split('/')
.forEach { part ->
when (part) {
"", "." -> Unit
".." -> if (parts.isNotEmpty()) parts.removeAt(parts.lastIndex)
else -> parts += part
}
}
return parts.joinToString("/")
}
private fun String.percentDecodedOrSelf(): String {
return runCatching {
val output = StringBuilder()
val bytes = mutableListOf<Byte>()
fun flushBytes() {
if (bytes.isNotEmpty()) {
output.append(bytes.toByteArray().decodeToString())
bytes.clear()
}
}
var index = 0
while (index < length) {
val char = this[index]
if (char == '%' && index + 2 < length) {
val value = substring(index + 1, index + 3).toIntOrNull(16)
if (value != null) {
bytes += value.toByte()
index += 3
continue
}
}
flushBytes()
output.append(char)
index++
}
flushBytes()
output.toString()
}.getOrDefault(this)
}
private fun Iterable<SemanticBlock>.findElementOffset(elementId: String): Int? {
for (block in this) {
block.findElementOffset(elementId)?.let { return it }
}
return null
}
private fun SemanticBlock.findElementOffset(elementId: String): Int? {
if (this is SemanticTextBlock) {
if (this.elementId == elementId) return startCharOffsetInSource
spans.firstOrNull { it.elementId == elementId }?.let { span ->
return startCharOffsetInSource + span.start.coerceAtLeast(0)
}
}
return when (this) {
is SemanticList -> items.findElementOffset(elementId)
is SemanticTable -> rows.asSequence()
.flatMap { it.asSequence() }
.mapNotNull { it.content.findElementOffset(elementId) }
.firstOrNull()
is SemanticFlexContainer -> children.findElementOffset(elementId)
is SemanticWrappingBlock -> paragraphsToWrap.findElementOffset(elementId)
else -> null
}
}
private fun UserHighlight.withNormalizedLocator(): UserHighlight {
val normalizedLocator = locator.copy(textQuote = text).withFallbacks(
chapterIndex = chapterIndex,
cfi = cfi,
textQuote = text
)
return copy(
chapterIndex = normalizedLocator.chapterIndex ?: chapterIndex,
cfi = normalizedLocator.cfi ?: cfi,
locator = normalizedLocator
)
}
private fun String.preview(): String {
return trim()
.replace(Regex("\\s+"), " ")
@ -161,3 +778,23 @@ private fun String.previewAround(index: Int, queryLength: Int): String {
val suffix = if (end < length) "..." else ""
return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix
}
private fun String.indexOfSearch(query: String, startIndex: Int, options: ReaderSearchOptions): Int {
var index = indexOf(query, startIndex, ignoreCase = !options.matchCase)
if (!options.wholeWords) return index
while (index >= 0) {
val before = getOrNull(index - 1)
val after = getOrNull(index + query.length)
if (!before.isWordChar() && !after.isWordChar()) return index
index = indexOf(query, index + query.length.coerceAtLeast(1), ignoreCase = !options.matchCase)
}
return -1
}
private fun Char?.isWordChar(): Boolean {
return this != null && (isLetterOrDigit() || this == '_')
}
private fun logReaderLink(message: String) {
println("ReaderLinkResolve $message")
}

View file

@ -1,6 +1,9 @@
package com.aryan.reader.shared.reader
import com.aryan.reader.paginatedreader.SemanticBlock
import com.aryan.reader.shared.PageInfoMode
import com.aryan.reader.shared.PageInfoPosition
import com.aryan.reader.shared.SystemUiMode
data class SharedEpubBook(
val id: String,
@ -20,10 +23,7 @@ data class SharedEpubChapter(
val baseHref: String? = null
)
data class ReaderLocator(
val chapterIndex: Int = 0,
val charOffset: Int = 0
)
typealias ReaderLocator = com.aryan.reader.shared.ReaderLocator
enum class ReaderReadingMode {
PAGINATED,
@ -44,8 +44,26 @@ data class ReaderSettings(
val readingMode: ReaderReadingMode = ReaderReadingMode.PAGINATED,
val textAlign: SharedReaderTextAlign = SharedReaderTextAlign.START,
val pageWidth: Int = 760,
val fontFamily: String = "Default"
)
val fontFamily: String = "Default",
val paragraphSpacing: Float = 1.0f,
val imageScale: Float = 1.0f,
val horizontalMargin: Int? = null,
val verticalMargin: Int? = null,
val themeId: String? = null,
val textureId: String? = null,
val textureAlpha: Float = 0.55f,
val customFontPath: String? = null,
val backgroundColorArgb: Long? = null,
val textColorArgb: Long? = null,
val systemUiMode: SystemUiMode = SystemUiMode.DEFAULT,
val pageInfoMode: PageInfoMode = PageInfoMode.DEFAULT,
val pageInfoPosition: PageInfoPosition = PageInfoPosition.BOTTOM,
val seamlessChapterNavigation: Boolean = true,
val chapterTurnDragMultiplier: Float = 1.0f
) {
val resolvedHorizontalMargin: Int get() = horizontalMargin ?: margin
val resolvedVerticalMargin: Int get() = verticalMargin ?: margin
}
data class ReaderPage(
val pageIndex: Int,

View file

@ -0,0 +1,106 @@
package com.aryan.reader.shared.reader
object SharedTextBookFactory {
fun fromPlainText(
id: String,
fileName: String,
title: String,
plainText: String,
author: String? = null
): SharedEpubBook {
return SharedEpubBook(
id = id,
fileName = fileName,
title = title,
author = author,
chapters = listOf(
SharedEpubChapter(
id = "chapter_0",
title = title,
plainText = plainText.ifBlank { "This document did not contain readable text." }
)
)
)
}
fun fromHtml(
id: String,
fileName: String,
title: String,
html: String,
author: String? = null
): SharedEpubBook {
val sanitizedHtml = html.sanitizeReaderHtml()
val body = sanitizedHtml.extractBodyOrSelf()
return SharedEpubBook(
id = id,
fileName = fileName,
title = title,
author = author,
chapters = listOf(
SharedEpubChapter(
id = "chapter_0",
title = sanitizedHtml.tagText("h1")
.ifBlank { sanitizedHtml.tagText("title") }
.ifBlank { title },
plainText = sanitizedHtml.htmlToText().ifBlank { title },
htmlContent = body
)
)
)
}
private fun String.extractBodyOrSelf(): String {
return Regex("(?is)<body\\b[^>]*>(.*?)</body>")
.find(this)
?.groupValues
?.get(1)
?.trim()
?: this
}
private fun String.tagText(tag: String): String {
return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.get(1)
?.htmlToText()
.orEmpty()
}
private fun String.htmlToText(): String {
return replace(Regex("(?is)<script.*?</script>"), "")
.replace(Regex("(?is)<style.*?</style>"), "")
.replace(Regex("(?i)<br\\s*/?>"), "\n")
.replace(Regex("(?i)</p\\s*>"), "\n\n")
.replace(Regex("(?i)</h[1-6]\\s*>"), "\n\n")
.replace(Regex("<[^>]+>"), " ")
.decodeEntities()
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
.replace(Regex(" *\\n *"), "\n")
.replace(Regex("\\n{3,}"), "\n\n")
.trim()
}
private fun String.decodeEntities(): String {
return replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.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()
}
}
private fun String.sanitizeReaderHtml(): String {
return replace(Regex("(?is)<script\\b.*?</script>"), "")
.replace(Regex("(?is)<object\\b.*?</object>"), "")
.replace(Regex("(?is)<embed\\b[^>]*>"), "")
.replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
}
}

View file

@ -98,8 +98,8 @@ class SimplePaginator {
viewportWidth: Int,
viewportHeight: Int
): Int {
val usableWidth = (viewportWidth - settings.margin * 2).coerceAtLeast(360)
val usableHeight = (viewportHeight - settings.margin * 2).coerceAtLeast(360)
val usableWidth = (viewportWidth - settings.resolvedHorizontalMargin * 2).coerceAtLeast(360)
val usableHeight = (viewportHeight - settings.resolvedVerticalMargin * 2).coerceAtLeast(360)
val averageCharWidth = settings.fontSize * 0.55f
val lineHeight = settings.fontSize * settings.lineSpacing
val charsPerLine = (usableWidth / averageCharWidth).toInt().coerceAtLeast(35)

View file

@ -0,0 +1,11 @@
package com.aryan.reader.shared.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
internal expect fun LocalBookCoverImage(
path: String,
contentDescription: String?,
modifier: Modifier = Modifier
)

View file

@ -0,0 +1,147 @@
package com.aryan.reader.shared.ui
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.SharedReaderScreenState
import com.aryan.reader.shared.ShelfType
import com.aryan.reader.shared.isOpdsStream
import com.aryan.reader.shared.progressPercentValue
import com.aryan.reader.shared.toHomeScreenModel
enum class SharedAppToolAction {
IMPORT_FILES,
IMPORT_FOLDER,
SYNC,
APP_THEME,
AI_SETTINGS,
CUSTOM_FONTS,
HELP_FEEDBACK,
SUPPORT,
ABOUT,
TABS_TOGGLE
}
data class SharedAppShellModel(
val primaryTabs: List<SharedAppTab>,
val selectedPrimaryTab: SharedAppTab,
val toolActions: List<SharedAppToolAction>
)
fun sharedAppShellModel(
selectedTab: SharedAppTab,
aiSettingsAvailable: Boolean
): SharedAppShellModel {
val primaryTabs = listOf(
SharedAppTab.HOME,
SharedAppTab.LIBRARY,
SharedAppTab.CATALOGS,
SharedAppTab.READER
)
val selectedPrimaryTab = when (selectedTab) {
SharedAppTab.SHELVES -> SharedAppTab.LIBRARY
SharedAppTab.CUSTOM_FONTS,
SharedAppTab.SUPPORT,
SharedAppTab.FEEDBACK,
SharedAppTab.ABOUT -> SharedAppTab.HOME
else -> selectedTab
}
val toolActions = buildList {
add(SharedAppToolAction.IMPORT_FILES)
add(SharedAppToolAction.IMPORT_FOLDER)
add(SharedAppToolAction.SYNC)
add(SharedAppToolAction.APP_THEME)
if (aiSettingsAvailable) add(SharedAppToolAction.AI_SETTINGS)
add(SharedAppToolAction.CUSTOM_FONTS)
add(SharedAppToolAction.HELP_FEEDBACK)
add(SharedAppToolAction.SUPPORT)
add(SharedAppToolAction.ABOUT)
add(SharedAppToolAction.TABS_TOGGLE)
}
return SharedAppShellModel(
primaryTabs = primaryTabs,
selectedPrimaryTab = selectedPrimaryTab,
toolActions = toolActions
)
}
data class NonReaderHomeLayoutModel(
val continueBook: BookItem?,
val activeTabs: List<BookItem>,
val pinnedBooks: List<BookItem>,
val recentBooks: List<BookItem>,
val selectedBooks: List<BookItem>,
val isContextualModeActive: Boolean,
val isEmpty: Boolean,
val isLibraryEmpty: Boolean
)
fun SharedReaderScreenState.toNonReaderHomeLayoutModel(): NonReaderHomeLayoutModel {
val model = toHomeScreenModel()
val activeTabs = if (isTabsEnabled) model.openTabs else emptyList()
val continueBook = activeTabs.firstOrNull { it.id == activeTabBookId }
?: model.recentBooks.firstOrNull { progressPercentValue(it.progressPercentage) in 1..99 }
?: model.recentBooks.firstOrNull()
val continueId = continueBook?.id
val pinnedBooks = model.recentBooks
.filter { it.id in pinnedHomeBookIds && it.id != continueId }
val recentBooks = model.recentBooks
.filter { it.id !in pinnedHomeBookIds && it.id != continueId }
return NonReaderHomeLayoutModel(
continueBook = continueBook,
activeTabs = activeTabs,
pinnedBooks = pinnedBooks,
recentBooks = recentBooks,
selectedBooks = model.selectedBooks,
isContextualModeActive = model.isContextualModeActive,
isEmpty = continueBook == null && pinnedBooks.isEmpty() && recentBooks.isEmpty() && activeTabs.isEmpty(),
isLibraryEmpty = model.isLibraryEmpty
)
}
data class NonReaderLibraryOrganizationModel(
val allBooksCount: Int,
val shelfCount: Int,
val smartShelfCount: Int,
val tagCount: Int,
val folderCount: Int,
val unreadCount: Int,
val inProgressCount: Int,
val completedCount: Int,
val activeFilterCount: Int,
val availableFileTypes: List<FileType>,
val hasInAppBooks: Boolean,
val hasOpdsStreams: Boolean
)
fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibraryOrganizationModel {
val books = rawLibraryBooks
val rootFolderCount = shelves.count { it.type == ShelfType.FOLDER && it.parentShelfId == null }
val tagIds = (allTags.map { it.id } + books.flatMap { book -> book.tags.map { it.id } }).toSet()
return NonReaderLibraryOrganizationModel(
allBooksCount = books.size,
shelfCount = shelves.count { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART },
smartShelfCount = shelves.count { it.type == ShelfType.SMART },
tagCount = tagIds.size,
folderCount = maxOf(rootFolderCount, syncedFolders.size),
unreadCount = books.count { progressPercentValue(it.progressPercentage) == 0 },
inProgressCount = books.count { progressPercentValue(it.progressPercentage) in 1..99 },
completedCount = books.count { progressPercentValue(it.progressPercentage) >= 100 },
activeFilterCount = libraryFilters.activeFilterCount(),
availableFileTypes = books
.map { it.type }
.filterNot { it == FileType.UNKNOWN }
.distinct()
.sortedBy { it.ordinal },
hasInAppBooks = books.any { it.sourceFolder == null && !it.isOpdsStream() },
hasOpdsStreams = books.any { it.isOpdsStream() }
)
}
private fun LibraryFilters.activeFilterCount(): Int {
return fileTypes.size +
sourceFolders.size +
tagIds.size +
if (readStatus == ReadStatusFilter.ALL) 0 else 1
}

View file

@ -0,0 +1,235 @@
package com.aryan.reader.shared.ui
import com.aryan.reader.shared.PdfDisplayMode
import com.aryan.reader.shared.ReaderAutoScrollState
import com.aryan.reader.shared.ReaderExtrasState
import com.aryan.reader.shared.ReaderTool
import com.aryan.reader.shared.ReaderToolbarPreferences
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.SharedPdfReaderState
import com.aryan.reader.shared.reader.ReaderSessionState
enum class ReaderWorkspaceKind {
EPUB,
PDF
}
enum class ReaderWorkspaceLeftSection(val title: String) {
CONTENTS("Contents"),
SEARCH("Search"),
BOOKMARKS("Bookmarks"),
NOTES("Notes")
}
enum class ReaderWorkspaceInspectorSection(val title: String) {
APPEARANCE("Appearance"),
TOOLS("Tools"),
AI_TTS("AI/TTS"),
TOOLBAR("Toolbar")
}
enum class ReaderWorkspaceTopAction {
CONTENTS,
SEARCH,
BOOKMARK,
APPEARANCE,
READ_ALOUD,
AI,
AUTO_SCROLL,
TOOLS
}
enum class ReaderWorkspaceBottomAction {
PAGE_SLIDER,
PREVIOUS,
NEXT
}
data class ReaderWorkspaceChromeModel(
val preferAutoHide: Boolean,
val forceVisible: Boolean,
val forceVisibleReasons: Set<String> = emptySet()
)
data class ReaderWorkspaceModel(
val kind: ReaderWorkspaceKind,
val leftSections: List<ReaderWorkspaceLeftSection>,
val inspectorSections: List<ReaderWorkspaceInspectorSection>,
val topActions: List<ReaderWorkspaceTopAction>,
val bottomActions: List<ReaderWorkspaceBottomAction>,
val defaultPdfInteractionMode: PdfInkTool? = null,
val chrome: ReaderWorkspaceChromeModel
)
fun epubReaderWorkspaceModel(
session: ReaderSessionState,
toolbarPreferences: ReaderToolbarPreferences,
extrasState: ReaderExtrasState,
aiAvailable: Boolean
): 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 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)) {
add(ReaderWorkspaceInspectorSection.TOOLS)
}
if (
preferences.isVisible(ReaderTool.DICTIONARY) ||
preferences.isVisible(ReaderTool.AI_FEATURES) ||
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)
if (ReaderWorkspaceInspectorSection.APPEARANCE in inspectorSections) add(ReaderWorkspaceTopAction.APPEARANCE)
if (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)
}.distinct()
val bottomActions = buildList {
if (preferences.isVisible(ReaderTool.SLIDER)) add(ReaderWorkspaceBottomAction.PAGE_SLIDER)
add(ReaderWorkspaceBottomAction.PREVIOUS)
add(ReaderWorkspaceBottomAction.NEXT)
}
return ReaderWorkspaceModel(
kind = ReaderWorkspaceKind.EPUB,
leftSections = leftSections,
inspectorSections = inspectorSections,
topActions = topActions,
bottomActions = bottomActions,
chrome = readerWorkspaceChromeModel(
preferAutoHide = true,
searchActive = session.isSearchActive,
leftPanelOpen = false,
inspectorOpen = false,
annotationEditing = false,
richTextEditing = false,
loading = false,
errorMessage = null,
autoScroll = extrasState.autoScroll,
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
)
)
}
fun readerWorkspaceQuickActionTools(
toolbarPreferences: ReaderToolbarPreferences,
bottom: Boolean,
aiAvailable: Boolean
): List<ReaderTool> {
val preferences = toolbarPreferences.sanitized()
return preferences.orderedVisibleTools()
.filter { tool ->
tool.supportsDesktopQuickAction &&
preferences.isBottom(tool) == bottom &&
(tool != ReaderTool.AI_FEATURES || aiAvailable)
}
}
fun pdfReaderWorkspaceModel(
state: SharedPdfReaderState,
displayMode: PdfDisplayMode,
hasContents: Boolean,
hasBookmarks: Boolean,
hasAnnotations: Boolean,
hasEmbeddedComments: Boolean,
searchActive: Boolean,
annotationEditing: Boolean,
richTextEditing: Boolean,
loading: Boolean,
errorMessage: String?,
extrasState: ReaderExtrasState,
aiAvailable: Boolean
): ReaderWorkspaceModel {
val leftSections = buildList {
add(ReaderWorkspaceLeftSection.CONTENTS)
add(ReaderWorkspaceLeftSection.SEARCH)
if (hasBookmarks) add(ReaderWorkspaceLeftSection.BOOKMARKS)
if (hasContents || hasAnnotations || hasEmbeddedComments) add(ReaderWorkspaceLeftSection.NOTES)
}.distinct()
val inspectorSections = listOf(
ReaderWorkspaceInspectorSection.APPEARANCE,
ReaderWorkspaceInspectorSection.TOOLS,
ReaderWorkspaceInspectorSection.AI_TTS,
ReaderWorkspaceInspectorSection.TOOLBAR
)
val topActions = buildList {
add(ReaderWorkspaceTopAction.CONTENTS)
add(ReaderWorkspaceTopAction.SEARCH)
add(ReaderWorkspaceTopAction.BOOKMARK)
add(ReaderWorkspaceTopAction.APPEARANCE)
add(ReaderWorkspaceTopAction.READ_ALOUD)
if (aiAvailable) add(ReaderWorkspaceTopAction.AI)
add(ReaderWorkspaceTopAction.AUTO_SCROLL)
add(ReaderWorkspaceTopAction.TOOLS)
}
return ReaderWorkspaceModel(
kind = ReaderWorkspaceKind.PDF,
leftSections = leftSections,
inspectorSections = inspectorSections,
topActions = topActions,
bottomActions = listOf(
ReaderWorkspaceBottomAction.PAGE_SLIDER,
ReaderWorkspaceBottomAction.PREVIOUS,
ReaderWorkspaceBottomAction.NEXT
),
defaultPdfInteractionMode = null,
chrome = readerWorkspaceChromeModel(
preferAutoHide = true,
searchActive = searchActive || state.searchQuery.isNotBlank(),
leftPanelOpen = false,
inspectorOpen = false,
annotationEditing = annotationEditing || state.selectedAnnotationId != null || state.selectedTool != PdfInkTool.PEN,
richTextEditing = richTextEditing,
loading = loading,
errorMessage = errorMessage,
autoScroll = extrasState.autoScroll,
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
)
)
}
fun readerWorkspaceChromeModel(
preferAutoHide: Boolean,
searchActive: Boolean,
leftPanelOpen: Boolean,
inspectorOpen: Boolean,
annotationEditing: Boolean,
richTextEditing: Boolean,
loading: Boolean,
errorMessage: String?,
autoScroll: ReaderAutoScrollState,
ttsBusy: Boolean
): ReaderWorkspaceChromeModel {
val reasons = buildSet {
if (searchActive) add("search")
if (leftPanelOpen) add("left-panel")
if (inspectorOpen) add("inspector")
if (annotationEditing) add("annotation")
if (richTextEditing) add("rich-text")
if (loading) add("loading")
if (!errorMessage.isNullOrBlank()) add("error")
if (autoScroll.sanitized().enabled) add("auto-scroll")
if (ttsBusy) add("tts")
}
return ReaderWorkspaceChromeModel(
preferAutoHide = preferAutoHide,
forceVisible = reasons.isNotEmpty(),
forceVisibleReasons = reasons
)
}

View file

@ -0,0 +1,224 @@
package com.aryan.reader.shared.ui
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@Composable
fun ReaderWorkspaceShell(
model: ReaderWorkspaceModel,
title: String,
subtitle: String,
progressLabel: String,
modifier: Modifier = Modifier,
topActions: @Composable RowScope.() -> Unit = {},
leftSidebar: @Composable () -> Unit,
rightInspector: @Composable () -> Unit,
bottomBar: @Composable () -> Unit,
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
LaunchedEffect(forceChrome, model.chrome.preferAutoHide, model.chrome.forceVisibleReasons) {
chromeVisible = true
if (model.chrome.preferAutoHide && !forceChrome) {
delay(3_200)
chromeVisible = false
}
}
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
val wide = maxWidth >= 1120.dp
val showChrome = chromeVisible || forceChrome || !model.chrome.preferAutoHide
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()
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
content()
}
if (wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) {
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()
}
}
}
if (showChrome) {
bottomBar()
} else {
Box(
Modifier
.fillMaxWidth()
.height(20.dp)
.clickable { chromeVisible = true }
)
}
}
}
}
@Composable
private fun ReaderWorkspaceTopChrome(
title: String,
subtitle: String,
progressLabel: String,
wide: Boolean,
leftPanelOpen: Boolean,
rightPanelOpen: Boolean,
onToggleLeftPanel: () -> Unit,
onToggleRightPanel: () -> Unit,
topActions: @Composable RowScope.() -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(onClick = onToggleLeftPanel) {
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")
}
}
}
}
}
@Composable
private fun ReaderWorkspaceOverlayPanel(
title: String,
onClose: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Surface(
modifier = modifier.fillMaxHeight().padding(vertical = 8.dp),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 8.dp
) {
Column(Modifier.fillMaxSize().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
content()
}
}
}

View file

@ -0,0 +1,499 @@
package com.aryan.reader.shared.ui
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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.widthIn
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.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.Favorite
import androidx.compose.material.icons.filled.Feedback
import androidx.compose.material.icons.filled.Folder
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.Settings
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.AppContrastOption
import com.aryan.reader.shared.AppThemeMode
import com.aryan.reader.shared.CustomAppTheme
enum class SharedAppTab {
HOME,
LIBRARY,
SHELVES,
CATALOGS,
READER,
CUSTOM_FONTS,
SUPPORT,
FEEDBACK,
ABOUT
}
@Composable
fun SharedAppShell(
selectedTab: SharedAppTab,
snackbarHostState: SnackbarHostState,
appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
appTextDimFactorLight: Float = 1.0f,
appTextDimFactorDark: Float = 1.0f,
appSeedColor: Color? = null,
customAppThemes: List<CustomAppTheme> = emptyList(),
isTabsEnabled: Boolean = false,
onTabSelected: (SharedAppTab) -> Unit,
onImportFiles: () -> Unit,
onImportFolder: () -> Unit = {},
onSyncRequested: () -> Unit,
onAppThemeModeChange: (AppThemeMode) -> Unit = {},
onAppContrastOptionChange: (AppContrastOption) -> Unit = {},
onAppTextDimFactorLightChange: (Float) -> Unit = {},
onAppTextDimFactorDarkChange: (Float) -> Unit = {},
onAppSeedColorChange: (Color?) -> Unit = {},
onCustomAppThemeAdded: (CustomAppTheme) -> Unit = {},
onCustomAppThemeDeleted: (String) -> Unit = {},
onTabsEnabledChange: (Boolean) -> Unit = {},
onAiSettingsRequested: (() -> Unit)? = null,
content: @Composable (SharedAppTab) -> Unit
) {
val shellModel = remember(selectedTab, onAiSettingsRequested != null) {
sharedAppShellModel(
selectedTab = selectedTab,
aiSettingsAvailable = onAiSettingsRequested != null
)
}
var showToolsPanel by remember { mutableStateOf(false) }
var showAppThemeSettings by remember { mutableStateOf(false) }
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(padding)
) {
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 }
)
}
Box(
Modifier
.weight(1f)
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
content(selectedTab)
}
}
if (showToolsPanel) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f))
.clickable { showToolsPanel = false }
)
SharedToolsPanel(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.widthIn(max = 390.dp),
isTabsEnabled = isTabsEnabled,
aiSettingsAvailable = onAiSettingsRequested != null,
onClose = { showToolsPanel = false },
onImportFiles = {
showToolsPanel = false
onImportFiles()
},
onImportFolder = {
showToolsPanel = false
onImportFolder()
},
onSyncRequested = {
showToolsPanel = false
onSyncRequested()
},
onAppThemeRequested = {
showToolsPanel = false
showAppThemeSettings = true
},
onAiSettingsRequested = {
showToolsPanel = false
onAiSettingsRequested?.invoke()
},
onOpenTab = { tab ->
showToolsPanel = false
onTabSelected(tab)
},
onTabsEnabledChange = onTabsEnabledChange
)
}
}
}
if (showAppThemeSettings) {
SharedAppThemeSettingsDialog(
appThemeMode = appThemeMode,
appContrastOption = appContrastOption,
appTextDimFactorLight = appTextDimFactorLight,
appTextDimFactorDark = appTextDimFactorDark,
appSeedColor = appSeedColor,
customAppThemes = customAppThemes,
onThemeModeChanged = onAppThemeModeChange,
onContrastOptionChanged = onAppContrastOptionChange,
onTextDimFactorLightChanged = onAppTextDimFactorLightChange,
onTextDimFactorDarkChanged = onAppTextDimFactorDarkChange,
onSeedColorChanged = onAppSeedColorChange,
onCustomThemeAdded = onCustomAppThemeAdded,
onCustomThemeDeleted = onCustomAppThemeDeleted,
onDismiss = { showAppThemeSettings = false }
)
}
}
@Composable
private fun SharedAppSidebar(
selectedTab: SharedAppTab,
primaryTabs: List<SharedAppTab>,
onTabSelected: (SharedAppTab) -> Unit,
onToolsClick: () -> Unit
) {
Surface(
modifier = Modifier
.width(244.dp)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
Text("Episteme", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Desktop library", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
primaryTabs.forEach { tab ->
SharedSidebarNavItem(
tab = tab,
selected = selectedTab == tab,
onClick = { onTabSelected(tab) }
)
}
Spacer(Modifier.weight(1f))
HorizontalDivider()
SharedSidebarButton(
label = "Tools",
icon = Icons.Default.Settings,
onClick = onToolsClick
)
}
}
}
@Composable
private fun SharedAppCompactRail(
selectedTab: SharedAppTab,
primaryTabs: List<SharedAppTab>,
onTabSelected: (SharedAppTab) -> Unit,
onToolsClick: () -> Unit
) {
NavigationRail(containerColor = MaterialTheme.colorScheme.surface) {
primaryTabs.forEach { tab ->
NavigationRailItem(
selected = selectedTab == tab,
onClick = { onTabSelected(tab) },
icon = { Icon(tab.icon, contentDescription = null) },
label = { Text(tab.label) }
)
}
Spacer(Modifier.weight(1f))
IconButton(onClick = onToolsClick) {
Icon(Icons.Default.Settings, contentDescription = "Tools")
}
}
}
@Composable
private fun SharedSidebarNavItem(
tab: SharedAppTab,
selected: Boolean,
onClick: () -> Unit
) {
val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
Color.Transparent
}
val contentColor = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = containerColor,
contentColor = contentColor,
onClick = onClick
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(tab.icon, contentDescription = null, modifier = Modifier.size(21.dp))
Text(tab.label, style = MaterialTheme.typography.bodyMedium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal)
}
}
}
@Composable
private fun SharedSidebarButton(
label: String,
icon: ImageVector,
onClick: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
onClick = onClick
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(21.dp))
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun SharedToolsPanel(
modifier: Modifier,
isTabsEnabled: Boolean,
aiSettingsAvailable: Boolean,
onClose: () -> Unit,
onImportFiles: () -> Unit,
onImportFolder: () -> Unit,
onSyncRequested: () -> Unit,
onAppThemeRequested: () -> Unit,
onAiSettingsRequested: () -> Unit,
onOpenTab: (SharedAppTab) -> Unit,
onTabsEnabledChange: (Boolean) -> Unit
) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 8.dp
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Tools", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text("Import, sync, and app settings", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Close tools")
}
}
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")
}
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")
}
}
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
)
}
}
SharedToolsSection("Settings") {
if (aiSettingsAvailable) {
SharedToolRow(Icons.Default.Settings, "AI keys and models", onAiSettingsRequested)
}
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) }
}
Spacer(Modifier.height(12.dp))
}
}
}
@Composable
private fun SharedToolsSection(
title: String,
content: @Composable ColumnScope.() -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
content()
}
}
@Composable
private fun SharedToolRow(
icon: ImageVector,
title: String,
onClick: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
onClick = onClick
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
Text(title, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
private val SharedAppTab.label: String
get() = when (this) {
SharedAppTab.HOME -> "Home"
SharedAppTab.LIBRARY -> "Library"
SharedAppTab.SHELVES -> "Shelves"
SharedAppTab.CATALOGS -> "OPDS"
SharedAppTab.READER -> "Reader"
SharedAppTab.CUSTOM_FONTS -> "Custom fonts"
SharedAppTab.SUPPORT -> "Support"
SharedAppTab.FEEDBACK -> "Feedback"
SharedAppTab.ABOUT -> "About"
}
private val SharedAppTab.icon: ImageVector
get() = when (this) {
SharedAppTab.HOME -> Icons.Default.Home
SharedAppTab.LIBRARY -> Icons.AutoMirrored.Filled.LibraryBooks
SharedAppTab.SHELVES -> Icons.Default.Folder
SharedAppTab.CATALOGS -> Icons.Default.Cloud
SharedAppTab.READER -> Icons.AutoMirrored.Filled.MenuBook
SharedAppTab.CUSTOM_FONTS -> Icons.Default.TextFields
SharedAppTab.SUPPORT -> Icons.Default.Favorite
SharedAppTab.FEEDBACK -> Icons.Default.Feedback
SharedAppTab.ABOUT -> Icons.Default.Info
}

View file

@ -0,0 +1,980 @@
package com.aryan.reader.shared.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ColorScheme
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.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aryan.reader.shared.AppContrastOption
import com.aryan.reader.shared.AppThemeMode
import com.aryan.reader.shared.CustomAppTheme
import com.materialkolor.PaletteStyle
import com.materialkolor.dynamicColorScheme
import kotlin.math.roundToInt
import kotlin.random.Random
private val SharedLightColorScheme = lightColorScheme(
primary = Color(0xFF4C662B),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFCDEDA3),
onPrimaryContainer = Color(0xFF354E16),
secondary = Color(0xFF586249),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFDCE7C8),
onSecondaryContainer = Color(0xFF404A33),
tertiary = Color(0xFF386663),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFBCECE7),
onTertiaryContainer = Color(0xFF1F4E4B),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF93000A),
background = Color(0xFFF9FAEF),
onBackground = Color(0xFF1A1C16),
surface = Color(0xFFF9FAEF),
onSurface = Color(0xFF1A1C16),
surfaceVariant = Color(0xFFE1E4D5),
onSurfaceVariant = Color(0xFF44483D),
outline = Color(0xFF75796C),
outlineVariant = Color(0xFFC5C8BA),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2F312A),
inverseOnSurface = Color(0xFFF1F2E6),
inversePrimary = Color(0xFFB1D18A),
surfaceDim = Color(0xFFDADBD0),
surfaceBright = Color(0xFFF9FAEF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF3F4E9),
surfaceContainer = Color(0xFFEEEFE3),
surfaceContainerHigh = Color(0xFFE8E9DE),
surfaceContainerHighest = Color(0xFFE2E3D8)
)
private val SharedDarkColorScheme = darkColorScheme(
primary = Color(0xFFB1D18A),
onPrimary = Color(0xFF1F3701),
primaryContainer = Color(0xFF354E16),
onPrimaryContainer = Color(0xFFCDEDA3),
secondary = Color(0xFFBFCBAD),
onSecondary = Color(0xFF2A331E),
secondaryContainer = Color(0xFF404A33),
onSecondaryContainer = Color(0xFFDCE7C8),
tertiary = Color(0xFFA0D0CB),
onTertiary = Color(0xFF003735),
tertiaryContainer = Color(0xFF1F4E4B),
onTertiaryContainer = Color(0xFFBCECE7),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF12140E),
onBackground = Color(0xFFE2E3D8),
surface = Color(0xFF12140E),
onSurface = Color(0xFFE2E3D8),
surfaceVariant = Color(0xFF44483D),
onSurfaceVariant = Color(0xFFC5C8BA),
outline = Color(0xFF8F9285),
outlineVariant = Color(0xFF44483D),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE2E3D8),
inverseOnSurface = Color(0xFF2F312A),
inversePrimary = Color(0xFF4C662B),
surfaceDim = Color(0xFF12140E),
surfaceBright = Color(0xFF383A32),
surfaceContainerLowest = Color(0xFF0C0F09),
surfaceContainerLow = Color(0xFF1A1C16),
surfaceContainer = Color(0xFF1E201A),
surfaceContainerHigh = Color(0xFF282B24),
surfaceContainerHighest = Color(0xFF33362E)
)
@Composable
fun SharedAppTheme(
appThemeMode: AppThemeMode,
appContrastOption: AppContrastOption,
appTextDimFactorLight: Float,
appTextDimFactorDark: Float,
appSeedColor: Color?,
content: @Composable () -> Unit
) {
val darkTheme = resolveSharedAppDarkTheme(appThemeMode, isSystemInDarkTheme())
val textDimFactor = sharedAppTextDimFactor(darkTheme, appTextDimFactorLight, appTextDimFactorDark)
val colorScheme = remember(darkTheme, appContrastOption, textDimFactor, appSeedColor) {
sharedAppColorScheme(
darkTheme = darkTheme,
seedColor = appSeedColor,
contrastLevel = appContrastOption.value,
textDimFactor = textDimFactor
)
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography(),
content = content
)
}
fun resolveSharedAppDarkTheme(mode: AppThemeMode, isSystemDark: Boolean): Boolean {
return when (mode) {
AppThemeMode.LIGHT -> false
AppThemeMode.DARK -> true
AppThemeMode.SYSTEM -> isSystemDark
}
}
fun sharedAppTextDimFactor(
darkTheme: Boolean,
lightFactor: Float,
darkFactor: Float
): Float {
return if (darkTheme) darkFactor else lightFactor
}
fun sharedAppColorScheme(
darkTheme: Boolean,
seedColor: Color?,
contrastLevel: Double,
textDimFactor: Float
): ColorScheme {
val baseColorScheme = seedColor?.let {
dynamicColorScheme(
seedColor = it,
isDark = darkTheme,
contrastLevel = contrastLevel,
style = PaletteStyle.Fidelity
)
} ?: if (darkTheme) {
SharedDarkColorScheme
} else {
SharedLightColorScheme
}
return baseColorScheme.withTextDimFactor(textDimFactor)
}
@Composable
fun SharedAppThemeSettingsDialog(
appThemeMode: AppThemeMode,
appContrastOption: AppContrastOption,
appTextDimFactorLight: Float,
appTextDimFactorDark: Float,
appSeedColor: Color?,
customAppThemes: List<CustomAppTheme>,
onThemeModeChanged: (AppThemeMode) -> Unit,
onContrastOptionChanged: (AppContrastOption) -> Unit,
onTextDimFactorLightChanged: (Float) -> Unit,
onTextDimFactorDarkChanged: (Float) -> Unit,
onSeedColorChanged: (Color?) -> Unit,
onCustomThemeAdded: (CustomAppTheme) -> Unit,
onCustomThemeDeleted: (String) -> Unit,
onDismiss: () -> Unit
) {
var showCreateDialog by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("App theme", fontWeight = FontWeight.Bold) },
text = {
Column(
modifier = Modifier
.widthIn(max = 620.dp)
.heightIn(max = 620.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
SettingsLabel("Appearance")
SegmentedControl(
values = AppThemeMode.entries,
selectedValue = appThemeMode,
label = { it.label },
onValueSelected = onThemeModeChanged
)
SettingsLabel("Contrast")
SegmentedControl(
values = AppContrastOption.entries,
selectedValue = appContrastOption,
label = { it.label },
onValueSelected = onContrastOptionChanged
)
if (appThemeMode == AppThemeMode.SYSTEM) {
TextBrightnessSlider(
label = "Text brightness (Light)",
value = appTextDimFactorLight,
onValueChange = onTextDimFactorLightChanged
)
TextBrightnessSlider(
label = "Text brightness (Dark)",
value = appTextDimFactorDark,
onValueChange = onTextDimFactorDarkChanged
)
} else {
TextBrightnessSlider(
label = "Text brightness",
value = if (appThemeMode == AppThemeMode.DARK) appTextDimFactorDark else appTextDimFactorLight,
onValueChange = if (appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged
)
}
SettingsLabel("Color scheme")
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ThemeSwatch(
color = MaterialTheme.colorScheme.primary,
selected = appSeedColor == null,
label = "Dynamic",
onClick = { onSeedColorChanged(null) }
)
AppThemePresets.forEach { preset ->
ThemeSwatch(
color = preset.color,
selected = appSeedColor == preset.color,
label = preset.name,
onClick = { onSeedColorChanged(preset.color) }
)
}
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
SettingsLabel("My themes")
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Add, contentDescription = "Add custom theme")
}
}
if (customAppThemes.isEmpty()) {
Text(
"No custom themes yet",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
customAppThemes.forEach { theme ->
ThemeSwatch(
color = theme.seedColor,
selected = appSeedColor == theme.seedColor,
label = theme.name,
onClick = { onSeedColorChanged(theme.seedColor) },
onDelete = { onCustomThemeDeleted(theme.id) }
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Done")
}
}
)
if (showCreateDialog) {
SharedCreateAppThemeDialog(
onDismiss = { showCreateDialog = false },
onSave = { name, color ->
onCustomThemeAdded(
CustomAppTheme(
id = Random.nextLong().toString(),
name = name.ifBlank { "Custom" },
seedColor = color
)
)
showCreateDialog = false
}
)
}
}
@Composable
private fun SettingsLabel(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
}
@Composable
private fun <T> SegmentedControl(
values: List<T>,
selectedValue: T,
label: (T) -> String,
onValueSelected: (T) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(24.dp))
.padding(4.dp)
) {
values.forEach { value ->
val selected = selectedValue == value
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(20.dp))
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
.clickable { onValueSelected(value) },
contentAlignment = Alignment.Center
) {
Text(
text = label(value),
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun TextBrightnessSlider(
label: String,
value: Float,
onValueChange: (Float) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SettingsLabel(label)
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"A",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
Slider(
value = value.coerceIn(0.3f, 1.0f),
onValueChange = { onValueChange(it.coerceIn(0.3f, 1.0f)) },
valueRange = 0.3f..1.0f,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
)
Text(
"A",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ThemeSwatch(
color: Color,
selected: Boolean,
label: String,
onClick: () -> Unit,
onDelete: (() -> Unit)? = null
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(color)
.border(
width = if (selected) 3.dp else 1.dp,
color = if (selected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant,
shape = CircleShape
)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (selected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = if (color.luminance() > 0.5f) Color.Black else Color.White
)
}
}
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 72.dp)
)
if (onDelete != null) {
Icon(
Icons.Default.Close,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp).clickable(onClick = onDelete)
)
}
}
}
}
@Composable
private fun SharedCreateAppThemeDialog(
initialColor: Color = Color(0xFF6750A4),
onDismiss: () -> Unit,
onSave: (String, Color) -> Unit
) {
var name by remember { mutableStateOf("") }
var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) }
val color = hsv.toComposeColor()
fun updateFromColor(nextColor: Color) {
hsv = nextColor.toSharedHsvColor()
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create theme") },
text = {
Column(
modifier = Modifier.widthIn(max = 560.dp).verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Theme name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
SharedSpectrumBox(
hue = hsv.hue,
saturation = hsv.saturation,
currentColor = color,
onHueSatChanged = { hue, saturation ->
hsv = hsv.copy(hue = hue, saturation = saturation)
},
modifier = Modifier.fillMaxWidth().height(220.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)
)
}
}
}
},
confirmButton = {
Button(
onClick = { onSave(name.trim().ifBlank { "Custom" }, color) },
colors = ButtonDefaults.buttonColors(
containerColor = color,
contentColor = if (color.luminance() > 0.5f) Color.Black else Color.White
)
) {
Text("Save", fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun SharedSpectrumBox(
hue: Float,
saturation: Float,
currentColor: Color,
onHueSatChanged: (Float, Float) -> Unit,
modifier: Modifier = Modifier
) {
val rainbowColors = listOf(
Color.Red,
Color.Yellow,
Color.Green,
Color.Cyan,
Color.Blue,
Color.Magenta,
Color.Red
)
val touchPadding = 12.dp
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val paddingPx = touchPadding.toPx()
val activeWidth = size.width.toFloat() - (paddingPx * 2)
val activeHeight = size.height.toFloat() - (paddingPx * 2)
fun update(offset: Offset) {
val relativeX = offset.x - paddingPx
val relativeY = offset.y - paddingPx
val nextHue = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
val nextSaturation = (relativeY / activeHeight).coerceIn(0f, 1f)
onHueSatChanged(nextHue, nextSaturation)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(touchPadding)
.clip(RoundedCornerShape(12.dp))
) {
drawRect(brush = Brush.horizontalGradient(rainbowColors))
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.White, Color.White.copy(alpha = 0f))
)
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
val paddingPx = touchPadding.toPx()
val activeWidth = size.width - (paddingPx * 2)
val activeHeight = size.height - (paddingPx * 2)
val x = paddingPx + (hue / 360f) * activeWidth
val y = paddingPx + saturation * activeHeight
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(x, y + 1.dp.toPx())
)
drawCircle(
color = currentColor.copy(alpha = 1f),
radius = pointerRadius,
center = Offset(x, y)
)
drawCircle(
color = Color.White,
radius = pointerRadius,
center = Offset(x, y),
style = Stroke(width = strokeWidth)
)
}
}
}
@Composable
private fun SharedBrightnessSlider(
hue: Float,
saturation: Float,
value: Float,
onValueChanged: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val baseColor = remember(hue, saturation) {
Color.hsv(hue, saturation, 1f)
}
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
fun update(offset: Offset) {
val nextValue = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
onValueChanged(nextValue)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(Color.Black, baseColor)
)
)
drawCircle(
color = Color.White,
radius = 8.dp.toPx(),
center = Offset(value.coerceIn(0f, 1f) * size.width, size.height / 2)
)
}
}
}
@Composable
private fun SharedRgbInputColumn(
label: String,
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val intValue = (value.coerceIn(0f, 1f) * 255).roundToInt()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
text = label,
color = Color.Gray,
fontSize = 11.sp,
maxLines = 1
)
Spacer(Modifier.height(4.dp))
SharedRgbInput(value = intValue, onValueChange = onValueChange)
}
}
@Composable
private fun SharedRgbInput(
value: Int,
onValueChange: (Float) -> Unit
) {
var text by remember(value) { mutableStateOf(value.coerceIn(0, 255).toString()) }
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 3 && newText.all { it.isDigit() }) {
text = newText
newText.toIntOrNull()?.let { channel ->
onValueChange(channel.coerceIn(0, 255) / 255f)
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 13.sp
),
singleLine = true,
cursorBrush = SolidColor(Color.White),
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(vertical = 9.dp)
)
}
@Composable
private fun SharedHexInput(
color: Color,
onHexChanged: (Color) -> Unit
) {
val hexValue = color.toSharedHexString().removePrefix("#")
var text by remember(hexValue) { mutableStateOf(hexValue) }
Row(
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "#",
color = Color.Gray,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 6) {
val uppercased = newText.uppercase()
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
text = uppercased
if (uppercased.length == 6) {
uppercased.toSharedHexColorOrNull()?.let(onHexChanged)
}
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Start,
fontSize = 13.sp
),
singleLine = true,
cursorBrush = SolidColor(Color.White),
modifier = Modifier
.padding(start = 2.dp)
.width(50.dp)
)
}
}
@Composable
private fun SharedColorComparePill(
oldColor: Color,
newColor: Color,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) {
drawRect(
color = oldColor.copy(alpha = 1f),
size = Size(size.width / 2, size.height)
)
drawRect(
color = newColor.copy(alpha = 1f),
topLeft = Offset(size.width / 2, 0f),
size = Size(size.width / 2, size.height)
)
}
}
private fun ColorScheme.withTextDimFactor(factor: Float): ColorScheme {
val dimFactor = factor.coerceIn(0.3f, 1.0f)
if (dimFactor >= 1.0f) return this
return copy(
primary = primary.copy(alpha = dimFactor),
secondary = secondary.copy(alpha = dimFactor),
tertiary = tertiary.copy(alpha = dimFactor),
error = error.copy(alpha = dimFactor),
primaryContainer = primaryContainer.copy(alpha = dimFactor),
secondaryContainer = secondaryContainer.copy(alpha = dimFactor),
tertiaryContainer = tertiaryContainer.copy(alpha = dimFactor),
errorContainer = errorContainer.copy(alpha = dimFactor),
outline = outline.copy(alpha = dimFactor),
outlineVariant = outlineVariant.copy(alpha = dimFactor),
inversePrimary = inversePrimary.copy(alpha = dimFactor),
inverseOnSurface = inverseOnSurface.copy(alpha = dimFactor),
onPrimary = onPrimary.copy(alpha = dimFactor),
onSecondary = onSecondary.copy(alpha = dimFactor),
onTertiary = onTertiary.copy(alpha = dimFactor),
onBackground = onBackground.copy(alpha = dimFactor),
onSurface = onSurface.copy(alpha = dimFactor),
onSurfaceVariant = onSurfaceVariant.copy(alpha = dimFactor),
onError = onError.copy(alpha = dimFactor),
onPrimaryContainer = onPrimaryContainer.copy(alpha = dimFactor),
onSecondaryContainer = onSecondaryContainer.copy(alpha = dimFactor),
onTertiaryContainer = onTertiaryContainer.copy(alpha = dimFactor),
onErrorContainer = onErrorContainer.copy(alpha = dimFactor)
)
}
private data class AppThemePreset(
val name: String,
val color: Color
)
private val AppThemePresets = listOf(
AppThemePreset("Ocean", Color(0xFF00668B)),
AppThemePreset("Mint", Color(0xFF006C4C)),
AppThemePreset("Rose", Color(0xFF9C4146)),
AppThemePreset("Sepia", Color(0xFF705D49)),
AppThemePreset("Amethyst", Color(0xFF9B59B6)),
AppThemePreset("Amber", Color(0xFFFFC107)),
AppThemePreset("Sapphire", Color(0xFF0F52BA))
)
private val AppThemeMode.label: String
get() = when (this) {
AppThemeMode.SYSTEM -> "System"
AppThemeMode.LIGHT -> "Light"
AppThemeMode.DARK -> "Dark"
}
private val AppContrastOption.label: String
get() = when (this) {
AppContrastOption.STANDARD -> "Standard"
AppContrastOption.MEDIUM -> "Medium"
AppContrastOption.HIGH -> "High"
}
internal data class SharedHsvColor(
val hue: Float,
val saturation: Float,
val value: Float
) {
fun toComposeColor(): Color {
return Color.hsv(
hue.normalizedHue(),
saturation.coerceIn(0f, 1f),
value.coerceIn(0f, 1f)
)
}
}
internal fun Color.toSharedHsvColor(): SharedHsvColor {
val maximum = maxOf(red, green, blue)
val minimum = minOf(red, green, blue)
val delta = maximum - minimum
val hue = when {
delta == 0f -> 0f
maximum == red -> 60f * (((green - blue) / delta) % 6f)
maximum == green -> 60f * (((blue - red) / delta) + 2f)
else -> 60f * (((red - green) / delta) + 4f)
}
val saturation = if (maximum == 0f) 0f else delta / maximum
return SharedHsvColor(
hue = hue.normalizedHue(),
saturation = saturation.coerceIn(0f, 1f),
value = maximum.coerceIn(0f, 1f)
)
}
internal fun Color.toSharedHexString(): String {
val rgb = toArgb() and 0x00FFFFFF
return "#${rgb.toString(16).padStart(6, '0').uppercase()}"
}
internal fun String.toSharedHexColorOrNull(): Color? {
val normalized = trim().removePrefix("#")
if (normalized.length != 6 || normalized.any { !it.isDigit() && it.lowercaseChar() !in 'a'..'f' }) {
return null
}
val rgb = normalized.toLongOrNull(16) ?: return null
return Color((0xFF000000L or rgb).toInt())
}
private fun Float.normalizedHue(): Float {
return ((this % 360f) + 360f) % 360f
}

View file

@ -0,0 +1,242 @@
package com.aryan.reader.shared.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.Shelf
import com.aryan.reader.shared.Tag
import com.aryan.reader.shared.cardTitle
import com.aryan.reader.shared.formatFileSize
import com.aryan.reader.shared.parseTagList
@Composable
fun SharedTextInputDialog(
title: String,
label: String,
initialValue: String,
confirmLabel: String,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
var value by remember(initialValue) { mutableStateOf(initialValue) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text(label) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(onClick = { onConfirm(value) }, enabled = value.isNotBlank()) {
Text(confirmLabel)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
fun SharedConfirmDialog(
title: String,
body: String,
confirmLabel: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(body) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(confirmLabel)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
fun SharedAddToShelfDialog(
shelves: List<Shelf>,
onDismiss: () -> Unit,
onCreateShelf: () -> Unit,
onShelfSelected: (Shelf) -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add to shelf") },
text = {
if (shelves.isEmpty()) {
Text("Create a shelf first, then add selected books to it.")
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(shelves, key = { it.id }) { shelf ->
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth().clickable { onShelfSelected(shelf) }
) {
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.bookCount}", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = onCreateShelf) {
Text("New shelf")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
fun SharedBookInfoDialog(
book: BookItem,
onDismiss: () -> Unit,
onEdit: () -> 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")
}
}
)
}
@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 }) }
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)
}
}
},
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)
)
)
}
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@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)
}
}

View file

@ -0,0 +1,194 @@
package com.aryan.reader.shared.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
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.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.ReaderMarkdownBlock
import com.aryan.reader.shared.ReaderMarkdownParser
@Composable
fun SharedMarkdownText(
markdown: String,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.bodySmall
) {
val document = remember(markdown) { ReaderMarkdownParser.parse(markdown) }
val colorScheme = MaterialTheme.colorScheme
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
document.blocks.forEachIndexed { index, block ->
when (block) {
is ReaderMarkdownBlock.Heading -> {
val headingStyle = when (block.level) {
1 -> MaterialTheme.typography.titleLarge
2 -> MaterialTheme.typography.titleMedium
else -> MaterialTheme.typography.titleSmall
}
Text(
text = block.text.markdownInlineAnnotatedString(),
style = headingStyle,
fontWeight = FontWeight.SemiBold
)
}
is ReaderMarkdownBlock.Paragraph -> {
Text(text = block.text.markdownInlineAnnotatedString(), style = style)
}
is ReaderMarkdownBlock.Quote -> {
Text(
text = block.text.markdownInlineAnnotatedString(),
style = style,
color = colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.background(colorScheme.surfaceVariant.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
.padding(8.dp)
)
}
is ReaderMarkdownBlock.CodeBlock -> {
Surface(
color = colorScheme.surfaceVariant.copy(alpha = 0.6f),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = block.text,
style = style.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(8.dp)
)
}
}
is ReaderMarkdownBlock.ListItems -> {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
block.items.forEachIndexed { itemIndex, item ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = if (block.ordered) "${itemIndex + 1}." else "-",
style = style,
color = colorScheme.onSurfaceVariant
)
Text(
text = item.markdownInlineAnnotatedString(),
style = style,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
if (document.blocks.isEmpty() && markdown.isNotBlank()) {
Text(text = markdown, style = style)
}
}
}
@Composable
private fun String.markdownInlineAnnotatedString(): AnnotatedString {
val colorScheme = MaterialTheme.colorScheme
return remember(this, colorScheme.primary, colorScheme.surfaceVariant) {
buildAnnotatedString {
appendMarkdownInline(
text = this@markdownInlineAnnotatedString,
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
background = colorScheme.surfaceVariant.copy(alpha = 0.7f)
),
linkStyle = SpanStyle(
color = colorScheme.primary,
textDecoration = TextDecoration.Underline
)
)
}
}
}
private fun AnnotatedString.Builder.appendMarkdownInline(
text: String,
codeStyle: SpanStyle,
linkStyle: SpanStyle
) {
var index = 0
while (index < text.length) {
when {
text.startsWith("`", index) -> {
val end = text.indexOf('`', startIndex = index + 1)
if (end > index) {
withStyle(codeStyle) { append(text.substring(index + 1, end)) }
index = end + 1
} else {
append(text[index])
index += 1
}
}
text.startsWith("**", index) -> {
val end = text.indexOf("**", startIndex = index + 2)
if (end > index) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
appendMarkdownInline(text.substring(index + 2, end), codeStyle, linkStyle)
}
index = end + 2
} else {
append(text[index])
index += 1
}
}
text.startsWith("*", index) -> {
val end = text.indexOf('*', startIndex = index + 1)
if (end > index) {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
appendMarkdownInline(text.substring(index + 1, end), codeStyle, linkStyle)
}
index = end + 1
} else {
append(text[index])
index += 1
}
}
text[index] == '[' -> {
val labelEnd = text.indexOf("](", startIndex = index + 1)
val urlEnd = if (labelEnd > index) text.indexOf(')', startIndex = labelEnd + 2) else -1
if (labelEnd > index && urlEnd > labelEnd) {
withStyle(linkStyle) {
appendMarkdownInline(text.substring(index + 1, labelEnd), codeStyle, linkStyle)
}
index = urlEnd + 1
} else {
append(text[index])
index += 1
}
}
else -> {
append(text[index])
index += 1
}
}
}
}

View file

@ -0,0 +1,841 @@
package com.aryan.reader.shared.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.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.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.itemsIndexed
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.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
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
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.opds.OpdsAcquisition
import com.aryan.reader.shared.opds.OpdsCatalog
import com.aryan.reader.shared.opds.OpdsEntry
import com.aryan.reader.shared.opds.SharedOpdsDownloadState
import com.aryan.reader.shared.opds.SharedOpdsScreenState
import com.aryan.reader.shared.opds.SharedOpdsText
@Composable
fun SharedOpdsScreen(
state: SharedOpdsScreenState,
localLibraryBooks: List<BookItem>,
onOpenCatalog: (OpdsCatalog) -> Unit,
onOpenFeedUrl: (String) -> Unit,
onNavigateBack: () -> Unit,
onSearch: (String) -> Unit,
onLoadNextPage: () -> Unit,
onAddCatalog: (String, String, String?, String?) -> Unit,
onUpdateCatalog: (String, String, String, String?, String?) -> Unit,
onRemoveCatalog: (OpdsCatalog) -> Unit,
onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit,
onReadBook: (BookItem) -> Unit,
onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onClearError: () -> Unit,
modifier: Modifier = Modifier
) {
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
var showCatalogDialog by remember { mutableStateOf(false) }
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
var catalogToDelete by remember { mutableStateOf<OpdsCatalog?>(null) }
Box(modifier.fillMaxSize()) {
if (!state.isViewingCatalog) {
SharedOpdsCatalogList(
catalogs = state.catalogs,
onOpenCatalog = onOpenCatalog,
onEditCatalog = { catalog ->
editingCatalog = catalog
showCatalogDialog = true
},
onDeleteCatalog = { catalogToDelete = it },
onAddCatalog = {
editingCatalog = null
showCatalogDialog = true
}
)
} else {
SharedOpdsFeedView(
state = state,
localLibraryBooks = localLibraryBooks,
onNavigateBack = onNavigateBack,
onSearch = onSearch,
onOpenFeedUrl = onOpenFeedUrl,
onLoadNextPage = onLoadNextPage,
onDownloadBook = onDownloadBook,
onReadBook = onReadBook,
onStreamBook = { entry -> onStreamBook(entry, state.currentCatalog) },
onEntrySelected = { selectedEntry = it }
)
}
state.errorMessage?.let { error ->
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
TextButton(onClick = onClearError) {
Text("Dismiss")
}
}
}
}
}
if (showCatalogDialog) {
SharedOpdsCatalogDialog(
catalog = editingCatalog,
onDismiss = {
showCatalogDialog = false
editingCatalog = null
},
onSave = { title, url, username, password ->
val editing = editingCatalog
if (editing == null) {
onAddCatalog(title, url, username, password)
} else {
onUpdateCatalog(editing.id, title, url, username, password)
}
showCatalogDialog = false
editingCatalog = null
}
)
}
catalogToDelete?.let { catalog ->
AlertDialog(
onDismissRequest = { catalogToDelete = null },
title = { Text("Delete catalog") },
text = { Text("Delete \"${catalog.title}\"? Streamed books from this catalog may stop opening if credentials change later.") },
confirmButton = {
TextButton(
onClick = {
onRemoveCatalog(catalog)
catalogToDelete = null
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { catalogToDelete = null }) {
Text("Cancel")
}
}
)
}
selectedEntry?.let { entry ->
SharedOpdsEntryDetailsDialog(
entry = entry,
localLibraryBook = entry.findLocalBook(localLibraryBooks),
downloadState = state.downloadingState[entry.id],
onDismiss = { selectedEntry = null },
onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) },
onReadBook = onReadBook,
onStreamBook = {
onStreamBook(entry, state.currentCatalog)
selectedEntry = null
},
onOpenFeedUrl = { url ->
onOpenFeedUrl(url)
selectedEntry = null
},
onSearch = { query ->
onSearch(query)
selectedEntry = null
}
)
}
}
@Composable
private fun SharedOpdsCatalogList(
catalogs: List<OpdsCatalog>,
onOpenCatalog: (OpdsCatalog) -> Unit,
onEditCatalog: (OpdsCatalog) -> Unit,
onDeleteCatalog: (OpdsCatalog) -> Unit,
onAddCatalog: () -> Unit
) {
Column(Modifier.fillMaxSize()) {
SharedScreenScaffold(
title = "OPDS",
subtitle = "Browse catalogs, streams, and downloads",
trailing = {
Button(onClick = onAddCatalog) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Catalog")
}
}
) {
if (catalogs.isEmpty()) {
SharedOpdsEmptyState(onAddCatalog = onAddCatalog, modifier = Modifier.weight(1f))
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(320.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(catalogs, key = { it.id }) { catalog ->
SharedOpdsCatalogCard(
catalog = catalog,
onOpenCatalog = { onOpenCatalog(catalog) },
onEditCatalog = { onEditCatalog(catalog) },
onDeleteCatalog = { onDeleteCatalog(catalog) }
)
}
}
}
}
}
}
@Composable
private fun SharedOpdsFeedView(
state: SharedOpdsScreenState,
localLibraryBooks: List<BookItem>,
onNavigateBack: () -> Unit,
onSearch: (String) -> Unit,
onOpenFeedUrl: (String) -> Unit,
onLoadNextPage: () -> Unit,
onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit,
onReadBook: (BookItem) -> Unit,
onStreamBook: (OpdsEntry) -> Unit,
onEntrySelected: (OpdsEntry) -> Unit
) {
var showSearch by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize()) {
Surface(color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp) {
Column(Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
if (showSearch) {
showSearch = false
query = ""
} else {
onNavigateBack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
if (showSearch) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search catalog") },
singleLine = true,
modifier = Modifier.weight(1f),
trailingIcon = {
IconButton(onClick = {
if (query.isNotBlank()) {
onSearch(query)
query = ""
showSearch = false
}
}) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
}
)
} else {
Column(Modifier.weight(1f)) {
Text(
text = state.currentFeed?.title ?: "Loading",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
state.currentCatalog?.title?.let { catalogTitle ->
Text(
catalogTitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (state.searchUrlTemplate != null) {
IconButton(onClick = { showSearch = true }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
}
}
}
if (state.isLoading) {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
}
}
val facets = state.currentFeed?.facets.orEmpty()
if (facets.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
facets.groupBy { it.group }.forEach { (groupName, groupFacets) ->
item(key = groupName) {
SharedOpdsFacetMenu(
groupName = groupName,
facets = groupFacets,
onOpenFeedUrl = onOpenFeedUrl
)
}
}
}
}
val entries = state.currentFeed?.entries.orEmpty()
if (entries.isEmpty() && !state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("This feed is empty.")
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
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 {
SharedOpdsBookCard(
entry = entry,
localLibraryBook = entry.findLocalBook(localLibraryBooks),
downloadState = state.downloadingState[entry.id],
onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) },
onReadBook = onReadBook,
onStreamBook = { onStreamBook(entry) },
onClick = { onEntrySelected(entry) }
)
}
}
}
}
}
}
@Composable
private fun SharedOpdsCatalogCard(
catalog: OpdsCatalog,
onOpenCatalog: () -> Unit,
onEditCatalog: () -> Unit,
onDeleteCatalog: () -> Unit
) {
Surface(
onClick = onOpenCatalog,
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Default.Cloud, contentDescription = null)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(catalog.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
catalog.url,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (catalog.isDefault) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(6.dp)
) {
Text(
"Preset",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
Spacer(Modifier.weight(1f))
if (!catalog.isDefault) {
IconButton(onClick = onEditCatalog) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
IconButton(onClick = onDeleteCatalog) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
}
}
}
@Composable
private fun SharedOpdsEmptyState(onAddCatalog: () -> Unit, modifier: Modifier = Modifier) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary)
Text("No catalogs", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Add an OPDS catalog to browse remote books.", color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = onAddCatalog) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Add catalog")
}
}
}
}
}
@Composable
private fun SharedOpdsFacetMenu(
groupName: String,
facets: List<com.aryan.reader.shared.opds.OpdsFacet>,
onOpenFeedUrl: (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val activeFacet = facets.firstOrNull { it.isActive } ?: facets.firstOrNull()
Box {
FilterChip(
selected = activeFacet?.isActive == true,
onClick = { expanded = true },
label = { Text("$groupName: ${activeFacet?.title ?: "Select"}") },
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
facets.forEach { facet ->
DropdownMenuItem(
text = { Text(facet.title) },
onClick = {
expanded = false
onOpenFeedUrl(facet.url)
},
trailingIcon = if (facet.isActive) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else {
null
}
)
}
}
}
}
@Composable
private fun SharedOpdsNavigationCard(entry: OpdsEntry, onOpenFeedUrl: (String) -> Unit) {
Surface(
onClick = { entry.navigationUrl?.let(onOpenFeedUrl) },
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
val summary = SharedOpdsText.cleanSummary(entry.summary)
if (summary.isNotBlank()) {
Text(
summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@Composable
private fun SharedOpdsBookCard(
entry: OpdsEntry,
localLibraryBook: BookItem?,
downloadState: SharedOpdsDownloadState?,
onDownloadBook: (OpdsAcquisition) -> Unit,
onReadBook: (BookItem) -> Unit,
onStreamBook: () -> Unit,
onClick: () -> Unit
) {
val uniqueAcquisitions = remember(entry.acquisitions) {
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
}
val isDownloading = downloadState?.isDownloading == true
var showFormatMenu by remember { mutableStateOf(false) }
Surface(
onClick = onClick,
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
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)
}
Column(Modifier.weight(1f)) {
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
entry.author?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
val summary = SharedOpdsText.cleanSummary(entry.summary)
if (summary.isNotBlank()) {
Text(summary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
}
Spacer(Modifier.height(8.dp))
when {
localLibraryBook != null -> {
OutlinedButton(onClick = { onReadBook(localLibraryBook) }, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Read")
}
}
isDownloading -> SharedOpdsDownloadProgress(downloadState)
else -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
if (entry.isStreamable) {
FilledTonalButton(onClick = onStreamBook, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Stream")
}
}
Box {
FilledTonalButton(
onClick = {
when (uniqueAcquisitions.size) {
0 -> Unit
1 -> onDownloadBook(uniqueAcquisitions.first())
else -> showFormatMenu = true
}
},
enabled = uniqueAcquisitions.isNotEmpty(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
) {
Icon(
if (uniqueAcquisitions.isEmpty()) Icons.Default.Info else Icons.Default.Download,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(if (uniqueAcquisitions.isEmpty()) "Unavailable" else "Download")
}
DropdownMenu(expanded = showFormatMenu, onDismissRequest = { showFormatMenu = false }) {
uniqueAcquisitions.forEach { acquisition ->
DropdownMenuItem(
text = { Text(acquisition.formatName) },
onClick = {
showFormatMenu = false
onDownloadBook(acquisition)
}
)
}
}
}
}
}
}
}
}
}
@Composable
private fun SharedOpdsDownloadProgress(downloadState: SharedOpdsDownloadState?) {
val progress = downloadState?.progress
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Downloading", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.weight(1f))
if (progress != null) {
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
}
}
Spacer(Modifier.height(4.dp))
if (progress != null) {
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
private fun SharedOpdsEntryDetailsDialog(
entry: OpdsEntry,
localLibraryBook: BookItem?,
downloadState: SharedOpdsDownloadState?,
onDismiss: () -> Unit,
onDownloadBook: (OpdsAcquisition) -> Unit,
onReadBook: (BookItem) -> Unit,
onStreamBook: () -> Unit,
onOpenFeedUrl: (String) -> Unit,
onSearch: (String) -> Unit
) {
val uniqueAcquisitions = remember(entry.acquisitions) {
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Column {
Text(entry.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
entry.author?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
text = {
Column(
modifier = Modifier
.heightIn(max = 520.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
localLibraryBook?.let { book ->
Button(onClick = { onReadBook(book) }, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Check, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Read")
}
}
if (downloadState?.isDownloading == true) {
SharedOpdsDownloadProgress(downloadState)
} else {
if (entry.isStreamable) {
Button(onClick = onStreamBook, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Cloud, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Stream now")
}
}
if (uniqueAcquisitions.isNotEmpty()) {
Text("Download format", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
uniqueAcquisitions.take(4).forEach { acquisition ->
FilledTonalButton(onClick = { onDownloadBook(acquisition) }) {
Text(acquisition.formatName)
}
}
}
}
}
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)
entry.authors.forEach { author ->
TextButton(
onClick = {
if (author.url != null) onOpenFeedUrl(author.url) else onSearch(author.name)
}
) {
Text(author.name)
}
}
}
}
if (entry.categories.isNotEmpty()) {
Text("Categories", style = MaterialTheme.typography.labelLarge)
entry.categories.distinct().take(8).forEach { category ->
TextButton(onClick = { onSearch(category) }) {
Text(category)
}
}
}
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)
Text(summary, style = MaterialTheme.typography.bodyMedium)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Close")
}
}
)
}
@Composable
private fun SharedOpdsCatalogDialog(
catalog: OpdsCatalog?,
onDismiss: () -> Unit,
onSave: (String, String, String?, String?) -> Unit
) {
var title by remember(catalog) { mutableStateOf(catalog?.title.orEmpty()) }
var url by remember(catalog) { mutableStateOf(catalog?.url.orEmpty()) }
var username by remember(catalog) { mutableStateOf(catalog?.username.orEmpty()) }
var password by remember(catalog) { mutableStateOf(catalog?.password.orEmpty()) }
val isEditMode = catalog != null
AlertDialog(
onDismissRequest = onDismiss,
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)
Text("Authentication optional", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
OutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
}
},
confirmButton = {
TextButton(
onClick = { onSave(title, url, username, password) },
enabled = title.isNotBlank() && url.isNotBlank()
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
private fun OpdsEntry.findLocalBook(localLibraryBooks: List<BookItem>): BookItem? {
return localLibraryBooks.firstOrNull {
it.title.equals(title, ignoreCase = true) || it.displayName.equals(title, ignoreCase = true)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,261 @@
package com.aryan.reader.shared.ui
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import com.aryan.reader.shared.pdf.SharedPdfRichTextController
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
import com.aryan.reader.shared.pdf.withoutTrailingSharedPdfPageBreak
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
@Composable
fun SharedPdfRichTextHiddenInput(
controller: SharedPdfRichTextController,
enabled: Boolean,
modifier: Modifier = Modifier
) {
LaunchedEffect(enabled, controller.activePageIndex) {
SharedPdfRichTextLog.d(
"ui.hiddenInput enabled=$enabled activePage=${controller.activePageIndex} " +
"editingLen=${controller.editingValue.text.length} selection=${controller.editingValue.selection}"
)
if (enabled && controller.activePageIndex != -1) {
controller.requestEditingFocus()
delay(16)
controller.requestEditingFocus()
}
}
if (!enabled) return
BasicTextField(
value = controller.editingValue,
onValueChange = controller::onValueChanged,
textStyle = TextStyle(
color = controller.currentStyle.color,
fontSize = controller.currentStyle.fontSize,
fontWeight = controller.currentStyle.fontWeight,
fontStyle = controller.currentStyle.fontStyle,
textDecoration = controller.currentStyle.textDecoration
),
modifier = modifier
.size(1.dp)
.alpha(0f)
.clearAndSetSemantics { }
.focusRequester(controller.focusRequester)
.onKeyEvent { event ->
event.type == KeyEventType.KeyDown &&
event.key == Key.Backspace &&
controller.handleBackspaceAtStart()
}
)
}
@Composable
fun SharedPdfRichTextLayer(
pageIndex: Int,
controller: SharedPdfRichTextController,
pageWidth: Float,
pageHeight: Float,
isTextEditingEnabled: Boolean,
centeringOffsetX: Float = 0f,
centeringOffsetY: Float = 0f,
isDarkMode: Boolean = false,
isScrolling: Boolean = false,
onPageTapped: (Int) -> Unit = {}
) {
LaunchedEffect(pageIndex, pageWidth, pageHeight, isTextEditingEnabled) {
if (pageWidth <= 0f || pageHeight <= 0f) {
SharedPdfRichTextLog.d(
"ui.layer invalidSize page=$pageIndex size=${pageWidth.richTextUiFloat()}x${pageHeight.richTextUiFloat()} " +
"editing=$isTextEditingEnabled"
)
}
}
if (pageWidth <= 0f || pageHeight <= 0f) return
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
LaunchedEffect(pageWidth, pageHeight, density, textMeasurer) {
controller.updateLayoutConfig(pageWidth, pageHeight, density, textMeasurer)
}
val pageLayout = remember(controller.pageLayouts, pageIndex) {
controller.pageLayouts.find { it.pageIndex == pageIndex }
}
LaunchedEffect(
pageIndex,
pageWidth,
pageHeight,
isTextEditingEnabled,
controller.activePageIndex,
pageLayout?.globalStartIndex,
pageLayout?.globalEndIndex
) {
SharedPdfRichTextLog.d(
"ui.layer page=$pageIndex size=${pageWidth.richTextUiFloat()}x${pageHeight.richTextUiFloat()} " +
"editing=$isTextEditingEnabled activePage=${controller.activePageIndex} " +
"layout=${pageLayout?.globalStartIndex}-${pageLayout?.globalEndIndex} " +
"visibleLen=${pageLayout?.visibleText?.length ?: 0}"
)
}
val marginX = pageWidth * 0.1f
val marginY = pageHeight * 0.08f
val editorWidth = (pageWidth - (marginX * 2f)).coerceAtLeast(10f)
val editorHeight = (pageHeight - (marginY * 2f)).coerceAtLeast(10f)
val editorWidthDp = with(density) { editorWidth.toDp() }
val editorHeightDp = with(density) { editorHeight.toDp() }
Box(
modifier = Modifier
.offset {
IntOffset(
(centeringOffsetX + marginX).roundToInt(),
(centeringOffsetY + marginY).roundToInt()
)
}
.size(editorWidthDp, editorHeightDp)
.graphicsLayer()
.clipToBounds()
.then(
if (isTextEditingEnabled) {
Modifier.pointerInput(
pageIndex,
editorWidth,
editorHeight,
controller.activePageIndex,
pageLayout?.globalStartIndex,
pageLayout?.globalEndIndex
) {
detectTapGestures { tapOffset ->
SharedPdfRichTextLog.d(
"ui.layer.tap page=$pageIndex offset=${tapOffset.richTextUiOffsetSummary()} " +
"editor=${editorWidth.richTextUiFloat()}x${editorHeight.richTextUiFloat()} " +
"activePage=${controller.activePageIndex} hasLayout=${pageLayout != null}"
)
onPageTapped(pageIndex)
controller.handleTapOnPage(pageIndex, tapOffset)
}
}
} else {
Modifier
}
)
) {
val textToRender = if (controller.activePageIndex == pageIndex) {
controller.localTextFieldValue.annotatedString
} else {
pageLayout?.visibleText?.withoutTrailingSharedPdfPageBreak()
} ?: return@Box
val measureResult = remember(textToRender, editorWidth, density) {
textMeasurer.measure(
text = textToRender,
style = TextStyle(fontSize = 16.sp),
constraints = Constraints(maxWidth = editorWidth.toInt()),
density = density
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
measureResult.multiParagraph.paint(drawContext.canvas)
}
if (isTextEditingEnabled && controller.activePageIndex == pageIndex) {
val selection = controller.editingValue.selection
val localStart = selection.start.coerceIn(0, textToRender.length)
val localEnd = selection.end.coerceIn(0, textToRender.length)
if (localStart != localEnd) {
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
Canvas(modifier = Modifier.fillMaxSize()) {
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
}
}
if (selection.collapsed && controller.isCursorVisible) {
val alpha = if (isScrolling) {
1f
} else {
val infiniteTransition = rememberInfiniteTransition(label = "pdfRichCursor")
infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0f,
animationSpec = infiniteRepeatable(tween(500), RepeatMode.Reverse),
label = "pdfRichCursorAlpha"
).value
}
val cursorRect = measureResult.getCursorRect(localStart)
val styleFontSize = controller.currentStyle.fontSize
val cursorHeight = if (styleFontSize.isSpecified) {
with(density) { styleFontSize.toPx() } * 1.2f
} else {
cursorRect.height
}
val centerY = cursorRect.center.y
val cursorColor = if (isDarkMode) Color.White else Color.Black
Canvas(modifier = Modifier.fillMaxSize()) {
drawLine(
color = cursorColor.copy(alpha = alpha),
start = Offset(cursorRect.left, centerY - cursorHeight / 2f),
end = Offset(cursorRect.left, centerY + cursorHeight / 2f),
strokeWidth = 2.dp.toPx()
)
}
}
}
}
}
private fun Float.richTextUiFloat(): String {
return if (isFinite()) {
val rounded = kotlin.math.round(this * 10f) / 10f
rounded.toString()
} else {
toString()
}
}
private fun Offset.richTextUiOffsetSummary(): String {
return "(${x.richTextUiFloat()},${y.richTextUiFloat()})"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,600 @@
package com.aryan.reader.shared.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Feedback
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aryan.reader.shared.CustomFontItem
@Composable
fun SharedCustomFontsScreen(
fonts: List<CustomFontItem>,
onImportFont: () -> Unit,
onDeleteFont: (CustomFontItem) -> Unit,
googleFontsAvailable: Boolean = false,
getGoogleFonts: () -> List<String> = { emptyList() },
onDownloadGoogleFont: (String, () -> Unit) -> Unit = { _, onComplete -> onComplete() },
fontFamilyForPreview: (CustomFontItem) -> FontFamily? = { null },
modifier: Modifier = Modifier
) {
var fontPendingDelete by remember { mutableStateOf<CustomFontItem?>(null) }
var showGoogleFontsDialog by remember { mutableStateOf(false) }
SharedScreenScaffold(
title = "Custom Fonts",
subtitle = "Imported fonts for the reader",
modifier = modifier,
trailing = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
if (googleFontsAvailable) {
Button(onClick = { showGoogleFontsDialog = true }) {
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Google Fonts")
}
}
Button(onClick = onImportFont) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Import")
}
}
}
) {
val activeFonts = fonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }
if (activeFonts.isEmpty()) {
SharedUtilityEmptyState(
icon = { Icon(Icons.Default.TextFields, contentDescription = null, modifier = Modifier.size(56.dp)) },
title = "No custom fonts",
body = "Import TTF, OTF, or WOFF2 files to use them in books.",
actionLabel = "Import font",
onAction = onImportFont,
modifier = Modifier.weight(1f)
)
} else {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(activeFonts, key = { it.id }) { font ->
SharedFontListItem(
font = font,
onDelete = { fontPendingDelete = font },
fontFamilyForPreview = fontFamilyForPreview
)
}
}
}
}
if (googleFontsAvailable && showGoogleFontsDialog) {
SharedGoogleFontsDialog(
existingFonts = fonts,
getGoogleFonts = getGoogleFonts,
onDownloadGoogleFont = onDownloadGoogleFont,
onDismiss = { showGoogleFontsDialog = false }
)
}
fontPendingDelete?.let { font ->
AlertDialog(
onDismissRequest = { fontPendingDelete = null },
title = { Text("Delete font?") },
text = { Text("Delete ${font.displayName}? Books using it will fall back to the default font.") },
confirmButton = {
TextButton(
onClick = {
onDeleteFont(font)
fontPendingDelete = null
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { fontPendingDelete = null }) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun SharedGoogleFontsDialog(
existingFonts: List<CustomFontItem>,
getGoogleFonts: () -> List<String>,
onDownloadGoogleFont: (String, () -> Unit) -> Unit,
onDismiss: () -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
var downloadingFontName by remember { mutableStateOf<String?>(null) }
val popularPresets = remember {
listOf(
"Merriweather",
"Open Sans",
"Playfair Display",
"Montserrat",
"Oswald",
"Raleway",
"Nunito",
"Poppins",
"Ubuntu",
"Fira Sans",
"Quicksand",
"Crimson Text",
"Literata",
"EB Garamond",
"Libre Baskerville",
"Inter",
"Work Sans"
)
}
val displayList = remember(searchQuery) {
if (searchQuery.isBlank()) {
popularPresets
} else {
getGoogleFonts()
.filter { it.contains(searchQuery, ignoreCase = true) }
.take(50)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("Browse Google Fonts", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Search 1900+ fonts...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true,
shape = RoundedCornerShape(8.dp)
)
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (searchQuery.isBlank()) {
item {
Text(
text = "Popular choices",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
} else if (displayList.isEmpty()) {
item {
Text(
text = "No fonts found matching '$searchQuery'",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
}
}
items(displayList, key = { it }) { fontName ->
val isDownloaded = existingFonts.any { it.displayName.equals(fontName, ignoreCase = true) }
val isDownloading = downloadingFontName == fontName
fun startDownload() {
downloadingFontName = fontName
onDownloadGoogleFont(fontName) {
if (downloadingFontName == fontName) {
downloadingFontName = null
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (isDownloaded) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
RoundedCornerShape(8.dp)
)
.clickable(enabled = !isDownloaded && !isDownloading) { startDownload() }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = fontName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isDownloaded) FontWeight.Bold else FontWeight.Medium,
color = if (isDownloaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
Box(
modifier = Modifier.padding(start = 12.dp),
contentAlignment = Alignment.Center
) {
when {
isDownloaded -> Icon(Icons.Default.Check, contentDescription = "Already downloaded", tint = MaterialTheme.colorScheme.primary)
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
else -> Icon(Icons.Default.CloudDownload, contentDescription = "Download")
}
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Close")
}
}
)
}
@Composable
private fun SharedFontListItem(
font: CustomFontItem,
onDelete: () -> Unit,
fontFamilyForPreview: (CustomFontItem) -> FontFamily?
) {
val previewFontFamily = remember(font.path) { fontFamilyForPreview(font) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) {
Box(Modifier.size(42.dp), contentAlignment = Alignment.Center) {
Text("Aa", fontWeight = FontWeight.Bold)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = font.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = font.path,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Text(
text = font.fileExtension.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
Icon(Icons.Default.Delete, contentDescription = "Delete font", tint = MaterialTheme.colorScheme.error)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), RoundedCornerShape(8.dp))
.padding(12.dp)
) {
Text(
text = "Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;:",
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 18.sp),
fontFamily = previewFontFamily,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
@Composable
fun SharedHelpFeedbackScreen(
onOpenGitHubIssues: () -> Unit,
onEmailSupport: () -> Unit,
modifier: Modifier = Modifier
) {
SharedScreenScaffold(
title = "Help & Feedback",
subtitle = "Bug reports, feature requests, and support",
modifier = modifier
) {
SharedUtilityHeader(
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(52.dp)) },
title = "Get in touch",
body = "Report bugs, request features, or contact support directly."
)
SharedUtilityOptionCard(
title = "GitHub Issues",
body = "Report bugs, request features, and track development progress.",
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onOpenGitHubIssues
)
SharedUtilityOptionCard(
title = "Email Support",
body = "Contact us directly by email for anything else.",
icon = { Icon(Icons.Default.Email, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onEmailSupport
)
}
}
@Composable
fun SharedSupportProjectScreen(
onOpenGitHubSponsors: () -> Unit,
onOpenPatreon: () -> Unit,
modifier: Modifier = Modifier
) {
SharedScreenScaffold(
title = "Support Project",
subtitle = "Ways to support Episteme development",
modifier = modifier
) {
SharedUtilityHeader(
icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(52.dp)) },
title = "Support Episteme",
body = "Contributions help keep the reader improving across Android and desktop."
)
SharedUtilityOptionCard(
title = "GitHub Sponsors",
body = "Support development through GitHub Sponsors.",
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onOpenGitHubSponsors
)
SharedUtilityOptionCard(
title = "Patreon",
body = "Support the project on Patreon.",
icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onOpenPatreon
)
}
}
@Composable
fun SharedAboutScreen(
versionName: String,
buildLabel: String,
onOpenSource: () -> Unit,
onOpenIssues: () -> Unit,
modifier: Modifier = Modifier
) {
SharedScreenScaffold(
title = "About Episteme",
subtitle = "Desktop reader",
modifier = modifier
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
) {
Row(
modifier = Modifier.padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Box(Modifier.size(52.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Default.Info, contentDescription = null)
}
}
Column {
Text("Episteme", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(versionName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(buildLabel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
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
)
}
}
@Composable
private fun SharedUtilityHeader(
icon: @Composable () -> Unit,
title: String,
body: String
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
) {
Row(
modifier = Modifier.fillMaxWidth().padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Box(Modifier.size(58.dp), contentAlignment = Alignment.Center) {
icon()
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(
body,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun SharedUtilityOptionCard(
title: String,
body: String,
icon: @Composable () -> Unit,
onClick: () -> Unit
) {
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
icon()
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Open")
}
}
}
@Composable
private fun SharedUtilityEmptyState(
icon: @Composable () -> Unit,
title: String,
body: String,
actionLabel: String,
onAction: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
) {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surfaceVariant) {
Box(Modifier.padding(18.dp), contentAlignment = Alignment.Center) {
icon()
}
}
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
Text(
body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(0.7f)
)
TextButton(onClick = onAction) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(actionLabel)
}
}
}
}
}