Windows ga (#358)

* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app

* Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app

* Persist reader window state and improve slider interaction in desktop app

* Improve PDF page transitions and refine focus management in desktop app

* Refactor scrollbar interaction and adjust desktop modal focus handling

* Improve PDF sidecar synchronization and cross-platform metadata compatibility

* Refactor PDF annotation comment logic to shared module and implement Desktop UI

* Refactor reader screen to use tap-to-toggle and full-width styling in desktop app

* Refactor reader workspace layout and chrome-panel interactions

* Implement global search keyboard shortcuts and focusable chrome layers

* Implement flavor-specific legal links and update the About UI

* Refactor reader UI controls on desktop app

* Enhance desktop folder sync with background metadata extraction and improved error handling

* Refactor Library UI and remove redundant Home tab in desktop app

* Add custom tooltips to reader icon buttons in desktop app

* Integrate app theme controls into reader interfaces on desktop

* Add right-to-left pagination support and improve focus restoration on desktop app

* Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app

* Update desktop reader defaults and implement settings migration

* Implement block-based position tracking in ReaderLocator

* Enhance EPUB highlighting reliability in desktop app

* Add support for custom reader themes and update highlight palette logic in desktop app

* Replace the Tools panel with a "More" dropdown menu and refactor account UI

* Implement account profile header in desktop sidebar

* Implement cloud sync reliability improvements and sidebar toggle on desktop app

* Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app

* Integrate WebView2 for EPUB vertical rendering on Windows

* Refactor reader layout logic and enhance WebView2 diagnostics

* Improve vertical reading layout and WebView2 resizing on Desktop

* Refine vertical reading mode layout and margin handling

* Enhance reader locator precision and Desktop mode-switching reliability

* Implement chapter-level caching and warm-start pagination in desktop app

* Replace bundled KCEF with native system webviews via SWT

* Refactor EPUB page info bar visibility and layout logic

* Improve PDF toolbar persistence and fix tab reactivation logic

* Enable multi-selection and bulk operations for custom fonts

* Refactor instrumentation tests

* Add EPUB UI test fixture and initial instrumentation tests

* Expand EpubReader UI tests and improve accessibility

* Add instrumentation tests and test tags for library and reader screens

* Enhance OPDS parser logic and catalog integration

* Add support for toggling local synchronization on a per-folder basis.

* Implement tri-state sizing for the TTS overlay

* Persist TTS overlay size across sessions

* Refactor reader brightness control and add incremental step buttons

* Improve CSS support, pagination control, and style-aware semantic caching

* Improve link handling, interaction, and diagnostics in the paginated reader

* crash fixes

* Implement persistent pending removal for external files

* Implement book-specific word replacements

* Add native vertical reading mode with custom renderer

* Implement text selection and navigation improvements for the native vertical reader

* Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub

* Implement lazy loading and chapter prefetching for native vertical reader

* Improve window lifecycle and disposal handling on Desktop

* Optimize vertical reading performance in desktop app

* Enhance TTS start accuracy and diagnostic logging on desktop

* Refactor AI settings visibility on desktop

* Improve pagination height measurement and enhance cutoff diagnostics

* Implement lifecycle management and improve justified text splitting for pagination

* Refine AI usage tracking and force AI feature visibility on Desktop

* Add descriptive context comments and usage examples to string and plural resources.

* Optimize performance and memory usage in search and state mapping

* Replace reader page sliders with minimal slider and navigation controls

* Add support for CBT comic archives

* Harden file path validation and XML parsing to prevent security vulnerabilities

* Implement local account profile caching and optimize desktop performance

* Improve desktop persistence reliability and add Linux secure storage support

* Improved PDF zoom stability and layout prediction during zoom commits

* Improved PDF spread layout prediction, reader focus restoration, and account profile caching

* Enhance highlight precision and scoping using block-local offsets and CFIs

* Enhance cloud book content synchronization and background downloads

* Implement granular timestamp tracking for reading positions and PDF annotations

* Restrict diagnostic logging and stack traces to debug builds

* Refine PDF page gaps and reader chrome interaction logic

* Refactor PDF highlight rendering and overhaul Desktop sidebar UI

* Implement a new interaction dock and undo/redo history for PDF annotations in desktop

* Enhance PDF color picker and improve navigation scroll restoration

* Add highlight palette customization and improve selection menu UI in desktop app epub reader

* Enhance desktop shelf management and library organization
This commit is contained in:
Aryan 2026-06-02 00:51:42 +05:30 committed by GitHub
parent 5971eaa571
commit 83dcafa4b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
444 changed files with 47279 additions and 8096 deletions

View file

@ -3,7 +3,7 @@ plugins {
id("com.android.library")
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose.multiplatform)
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kover)
}
@ -28,7 +28,6 @@ kotlin {
commonMain.dependencies {
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation(compose.ui)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
@ -48,4 +47,8 @@ android {
defaultConfig {
minSdk = 26
}
buildFeatures {
buildConfig = true
}
}

View file

@ -1,5 +1,17 @@
package com.aryan.reader.shared.reader
internal actual val SharedReaderDiagnosticsEnabled: Boolean = false
import android.util.Log
import com.aryan.reader.shared.BuildConfig
internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean = false
internal actual val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG
internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean {
if (!BuildConfig.DEBUG) return false
return tag == SharedEpubCutoffDiagnosticsTag ||
runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false)
}
internal actual fun writeSharedReaderDiagnostic(tag: String, message: String) {
if (!BuildConfig.DEBUG) return
Log.d(tag, message)
}

View file

@ -0,0 +1,12 @@
package androidx.compose.material.icons
object Icons {
object Filled
val Default: Filled get() = Filled
object Outlined
object AutoMirrored {
object Filled
}
}

View file

@ -0,0 +1,261 @@
@file:Suppress("ObjectPropertyName", "unused")
package androidx.compose.material.icons.automirrored.filled
import androidx.compose.material.icons.Icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.dp
val Icons.AutoMirrored.Filled.ArrowBack: ImageVector
get() = EpistemeAutoMirroredFilledIcons.arrowBack
val Icons.AutoMirrored.Filled.ArrowForward: ImageVector
get() = EpistemeAutoMirroredFilledIcons.arrowForward
val Icons.AutoMirrored.Filled.KeyboardArrowRight: ImageVector
get() = EpistemeAutoMirroredFilledIcons.keyboardArrowRight
val Icons.AutoMirrored.Filled.LibraryBooks: ImageVector
get() = EpistemeAutoMirroredFilledIcons.libraryBooks
val Icons.AutoMirrored.Filled.List: ImageVector
get() = EpistemeAutoMirroredFilledIcons.list
val Icons.AutoMirrored.Filled.MenuBook: ImageVector
get() = EpistemeAutoMirroredFilledIcons.menuBook
val Icons.AutoMirrored.Filled.NavigateBefore: ImageVector
get() = EpistemeAutoMirroredFilledIcons.navigateBefore
val Icons.AutoMirrored.Filled.NavigateNext: ImageVector
get() = EpistemeAutoMirroredFilledIcons.navigateNext
val Icons.AutoMirrored.Filled.OpenInNew: ImageVector
get() = EpistemeAutoMirroredFilledIcons.openInNew
val Icons.AutoMirrored.Filled.Redo: ImageVector
get() = EpistemeAutoMirroredFilledIcons.redo
val Icons.AutoMirrored.Filled.Sort: ImageVector
get() = EpistemeAutoMirroredFilledIcons.sort
val Icons.AutoMirrored.Filled.Undo: ImageVector
get() = EpistemeAutoMirroredFilledIcons.undo
val Icons.AutoMirrored.Filled.VolumeUp: ImageVector
get() = EpistemeAutoMirroredFilledIcons.volumeUp
private object EpistemeAutoMirroredFilledIcons {
val arrowBack: ImageVector by lazy {
materialIcon(
name = "ArrowBack",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z"""
)
)
}
val arrowForward: ImageVector by lazy {
materialIcon(
name = "ArrowForward",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M647,520L160,520L160,440L647,440L423,216L480,160L800,480L480,800L423,744L647,520Z"""
)
)
}
val keyboardArrowRight: ImageVector by lazy {
materialIcon(
name = "KeyboardArrowRight",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z"""
)
)
}
val libraryBooks: ImageVector by lazy {
materialIcon(
name = "LibraryBooks",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M400,560L560,560L560,480L400,480L400,560ZM400,440L720,440L720,360L400,360L400,440ZM400,320L720,320L720,240L400,240L400,320ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z"""
)
)
}
val list: ImageVector by lazy {
materialIcon(
name = "List",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M280,360L280,280L840,280L840,360L280,360ZM280,520L280,440L840,440L840,520L280,520ZM280,680L280,600L840,600L840,680L280,680ZM160,360Q143,360 131.5,348.5Q120,337 120,320Q120,303 131.5,291.5Q143,280 160,280Q177,280 188.5,291.5Q200,303 200,320Q200,337 188.5,348.5Q177,360 160,360ZM160,520Q143,520 131.5,508.5Q120,497 120,480Q120,463 131.5,451.5Q143,440 160,440Q177,440 188.5,451.5Q200,463 200,480Q200,497 188.5,508.5Q177,520 160,520ZM160,680Q143,680 131.5,668.5Q120,657 120,640Q120,623 131.5,611.5Q143,600 160,600Q177,600 188.5,611.5Q200,623 200,640Q200,657 188.5,668.5Q177,680 160,680Z"""
)
)
}
val menuBook: ImageVector by lazy {
materialIcon(
name = "MenuBook",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Z"""
)
)
}
val navigateBefore: ImageVector by lazy {
materialIcon(
name = "NavigateBefore",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M560,720L320,480L560,240L616,296L432,480L616,664L560,720Z"""
)
)
}
val navigateNext: ImageVector by lazy {
materialIcon(
name = "NavigateNext",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z"""
)
)
}
val openInNew: ImageVector by lazy {
materialIcon(
name = "OpenInNew",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L480,120L480,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,480L840,480L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM388,628L332,572L704,200L560,200L560,120L840,120L840,400L760,400L760,256L388,628Z"""
)
)
}
val redo: ImageVector by lazy {
materialIcon(
name = "Redo",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M396,760Q299,760 229.5,697Q160,634 160,540Q160,446 229.5,383Q299,320 396,320L648,320L544,216L600,160L800,360L600,560L544,504L648,400L396,400Q333,400 286.5,440Q240,480 240,540Q240,600 286.5,640Q333,680 396,680L680,680L680,760L396,760Z"""
)
)
}
val sort: ImageVector by lazy {
materialIcon(
name = "Sort",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M120,720L120,640L360,640L360,720L120,720ZM120,520L120,440L600,440L600,520L120,520ZM120,320L120,240L840,240L840,320L120,320Z"""
)
)
}
val undo: ImageVector by lazy {
materialIcon(
name = "Undo",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M280,760L280,680L564,680Q627,680 673.5,640Q720,600 720,540Q720,480 673.5,440Q627,400 564,400L312,400L416,504L360,560L160,360L360,160L416,216L312,320L564,320Q661,320 730.5,383Q800,446 800,540Q800,634 730.5,697Q661,760 564,760L280,760Z"""
)
)
}
val volumeUp: ImageVector by lazy {
materialIcon(
name = "VolumeUp",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = true,
paths = listOf(
"""M560,829L560,747Q650,721 705,647Q760,573 760,479Q760,385 705,311Q650,237 560,211L560,129Q684,157 762,254.5Q840,352 840,479Q840,606 762,703.5Q684,801 560,829ZM120,600L120,360L280,360L480,160L480,800L280,600L120,600ZM560,640L560,318Q607,340 633.5,384Q660,428 660,480Q660,531 633.5,574.5Q607,618 560,640ZM400,354L314,440L200,440L200,520L314,520L400,606L400,354ZM300,480L300,480L300,480L300,480L300,480L300,480Z"""
)
)
}
}
private fun materialIcon(
name: String,
defaultWidth: Float,
defaultHeight: Float,
viewportWidth: Float,
viewportHeight: Float,
autoMirror: Boolean,
paths: List<String>
): ImageVector {
return ImageVector.Builder(
name = name,
defaultWidth = defaultWidth.dp,
defaultHeight = defaultHeight.dp,
viewportWidth = viewportWidth,
viewportHeight = viewportHeight,
autoMirror = autoMirror
).apply {
paths.forEach { pathData ->
addPath(
pathData = PathParser().parsePathString(pathData).toNodes(),
fill = SolidColor(Color.Black)
)
}
}.build()
}

View file

@ -0,0 +1,159 @@
@file:Suppress("ObjectPropertyName", "unused")
package androidx.compose.material.icons.outlined
import androidx.compose.material.icons.Icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.dp
val Icons.Outlined.AccountCircle: ImageVector
get() = EpistemeOutlinedIcons.accountCircle
val Icons.Outlined.Email: ImageVector
get() = EpistemeOutlinedIcons.email
val Icons.Outlined.FavoriteBorder: ImageVector
get() = EpistemeOutlinedIcons.favoriteBorder
val Icons.Outlined.Feedback: ImageVector
get() = EpistemeOutlinedIcons.feedback
val Icons.Outlined.FileOpen: ImageVector
get() = EpistemeOutlinedIcons.fileOpen
val Icons.Outlined.Gavel: ImageVector
get() = EpistemeOutlinedIcons.gavel
val Icons.Outlined.Policy: ImageVector
get() = EpistemeOutlinedIcons.policy
private object EpistemeOutlinedIcons {
val accountCircle: ImageVector by lazy {
materialIcon(
name = "AccountCircle",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = false,
paths = listOf(
"""M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520Q421,520 380.5,479.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800Q533,800 580,784.5ZM523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440Q506,440 523,423ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z"""
)
)
}
val email: ImageVector by lazy {
materialIcon(
name = "Email",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = false,
paths = listOf(
"""M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320L480,520ZM480,440L800,240L160,240L480,440ZM160,320L160,240L160,240L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z"""
)
)
}
val favoriteBorder: ImageVector by lazy {
materialIcon(
name = "FavoriteBorder",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = false,
paths = listOf(
"""M480,840L422,788Q321,697 255,631Q189,565 150,512.5Q111,460 95.5,416Q80,372 80,326Q80,232 143,169Q206,106 300,106Q352,106 399,128Q446,150 480,190Q514,150 561,128Q608,106 660,106Q754,106 817,169Q880,232 880,326Q880,372 864.5,416Q849,460 810,512.5Q771,565 705,631Q639,697 538,788L480,840ZM480,732Q576,646 638,584.5Q700,523 736,477.5Q772,432 786,396.5Q800,361 800,326Q800,266 760,226Q720,186 660,186Q613,186 573,212.5Q533,239 518,280L518,280L442,280L442,280Q427,239 387,212.5Q347,186 300,186Q240,186 200,226Q160,266 160,326Q160,361 174,396.5Q188,432 224,477.5Q260,523 322,584.5Q384,646 480,732ZM480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459L480,459L480,459L480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Z"""
)
)
}
val feedback: ImageVector by lazy {
materialIcon(
name = "Feedback",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = false,
paths = listOf(
"""M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z"""
)
)
}
val fileOpen: ImageVector by lazy {
materialIcon(
name = "FileOpen",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = false,
paths = listOf(
"""M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,560L720,560L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L600,800L600,880L240,880ZM878,895L760,777L760,866L680,866L680,640L906,640L906,720L816,720L934,838L878,895ZM240,800L240,560L240,560L240,360L240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800Z"""
)
)
}
val gavel: ImageVector by lazy {
materialIcon(
name = "Gavel",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = false,
paths = listOf(
"""M160,840L160,760L640,760L640,840L160,840ZM386,646L160,420L244,334L472,560L386,646ZM640,392L414,164L500,80L726,306L640,392ZM824,800L302,278L358,222L880,744L824,800Z"""
)
)
}
val policy: ImageVector by lazy {
materialIcon(
name = "Policy",
defaultWidth = 24f,
defaultHeight = 24f,
viewportWidth = 960f,
viewportHeight = 960f,
autoMirror = false,
paths = listOf(
"""M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,529 771,607.5Q742,686 688,746L560,618Q542,629 521.5,634.5Q501,640 480,640Q414,640 367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,502 634.5,522.5Q629,543 618,562L678,622Q698,581 709,536Q720,491 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796Q506,788 529.5,775.5Q553,763 576,746L632,802Q599,829 560.5,849Q522,869 480,880ZM536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560Q513,560 536.5,536.5ZM488,483L488,483Q488,483 488,483Q488,483 488,483L488,483Q488,483 488,483Q488,483 488,483L488,483L488,483L488,483L488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Z"""
)
)
}
}
private fun materialIcon(
name: String,
defaultWidth: Float,
defaultHeight: Float,
viewportWidth: Float,
viewportHeight: Float,
autoMirror: Boolean,
paths: List<String>
): ImageVector {
return ImageVector.Builder(
name = name,
defaultWidth = defaultWidth.dp,
defaultHeight = defaultHeight.dp,
viewportWidth = viewportWidth,
viewportHeight = viewportHeight,
autoMirror = autoMirror
).apply {
paths.forEach { pathData ->
addPath(
pathData = PathParser().parsePathString(pathData).toNodes(),
fill = SolidColor(Color.Black)
)
}
}.build()
}

View file

@ -78,7 +78,20 @@ data class BlockStyle(
@ProtoNumber(31) @Serializable(with = DpSerializer::class) val borderTopRightRadius: Dp = 0.dp,
@ProtoNumber(32) @Serializable(with = DpSerializer::class) val borderBottomRightRadius: Dp = 0.dp,
@ProtoNumber(33) @Serializable(with = DpSerializer::class) val borderBottomLeftRadius: Dp = 0.dp,
@ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp
@ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp,
@ProtoNumber(35) @Serializable(with = DpSerializer::class) val minWidth: Dp = Dp.Unspecified,
@ProtoNumber(36) @Serializable(with = DpSerializer::class) val minHeight: Dp = Dp.Unspecified,
@ProtoNumber(37) @Serializable(with = DpSerializer::class) val maxHeight: Dp = Dp.Unspecified,
@ProtoNumber(38) val overflow: String? = null,
@ProtoNumber(39) val breakBefore: String? = null,
@ProtoNumber(40) val breakAfter: String? = null,
@ProtoNumber(41) val breakInside: String? = null,
@ProtoNumber(42) val widows: Int = 2,
@ProtoNumber(43) val orphans: Int = 2,
@ProtoNumber(44) val visibility: String? = null,
@ProtoNumber(45) val objectFit: String? = null,
@ProtoNumber(46) val objectPosition: String? = null,
@ProtoNumber(47) val backgroundImage: String? = null
) {
fun merge(other: BlockStyle): BlockStyle {
return BlockStyle(
@ -125,7 +138,20 @@ data class BlockStyle(
horizontalAlign = other.horizontalAlign ?: this.horizontalAlign,
filter = other.filter ?: this.filter,
borderCollapse = other.borderCollapse ?: this.borderCollapse,
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing,
minWidth = if (other.minWidth.isSpecified) other.minWidth else this.minWidth,
minHeight = if (other.minHeight.isSpecified) other.minHeight else this.minHeight,
maxHeight = if (other.maxHeight.isSpecified) other.maxHeight else this.maxHeight,
overflow = other.overflow ?: this.overflow,
breakBefore = other.breakBefore ?: this.breakBefore,
breakAfter = other.breakAfter ?: this.breakAfter,
breakInside = other.breakInside ?: this.breakInside,
widows = if (other.widows != 2) other.widows else this.widows,
orphans = if (other.orphans != 2) other.orphans else this.orphans,
visibility = other.visibility ?: this.visibility,
objectFit = other.objectFit ?: this.objectFit,
objectPosition = other.objectPosition ?: this.objectPosition,
backgroundImage = other.backgroundImage ?: this.backgroundImage
)
}
}
@ -307,7 +333,10 @@ data class CssStyle(
@ProtoNumber(13) @Serializable(with = TextUnitSerializer::class) val wordSpacing: TextUnit = TextUnit.Unspecified,
@ProtoNumber(14) val textDecorationStyle: String? = null,
@ProtoNumber(15) @Serializable(with = ColorSerializer::class) val textDecorationColor: Color = Color.Unspecified,
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified,
@ProtoNumber(17) val whiteSpace: String? = null,
@ProtoNumber(18) val verticalAlign: String? = null,
@ProtoNumber(19) val customProperties: Map<String, String> = emptyMap()
) {
fun merge(other: CssStyle): CssStyle {
return CssStyle(
@ -326,7 +355,10 @@ data class CssStyle(
wordSpacing = if (other.wordSpacing.isSpecified) other.wordSpacing else this.wordSpacing,
textDecorationStyle = other.textDecorationStyle ?: this.textDecorationStyle,
textDecorationColor = if (other.textDecorationColor.isSpecified) other.textDecorationColor else this.textDecorationColor,
textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset
textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset,
whiteSpace = other.whiteSpace ?: this.whiteSpace,
verticalAlign = other.verticalAlign ?: this.verticalAlign,
customProperties = this.customProperties + other.customProperties
)
}
}
@ -340,7 +372,9 @@ data class CssSelector(
@Serializable
data class CssRule(
@ProtoNumber(1) val selector: CssSelector,
@ProtoNumber(2) val style: CssStyle
@ProtoNumber(2) val style: CssStyle,
@ProtoNumber(3) val pseudoElement: String? = null,
@ProtoNumber(4) val sourceOrder: Int = 0
)
@Serializable
@ -371,7 +405,8 @@ data class OptimizedCssRules(
@ProtoNumber(1) val byTag: Map<String, List<CssRule>> = emptyMap(),
@ProtoNumber(2) val byClass: Map<String, List<CssRule>> = emptyMap(),
@ProtoNumber(3) val byId: Map<String, List<CssRule>> = emptyMap(),
@ProtoNumber(4) val otherComplex: List<CssRule> = emptyList()
@ProtoNumber(4) val otherComplex: List<CssRule> = emptyList(),
@ProtoNumber(5) val allRules: List<CssRule> = emptyList()
) {
fun merge(other: OptimizedCssRules): OptimizedCssRules {
fun mergeMap(
@ -397,16 +432,17 @@ data class OptimizedCssRules(
byTag = mergeMap(this.byTag, other.byTag),
byClass = mergeMap(this.byClass, other.byClass),
byId = mergeMap(this.byId, other.byId),
otherComplex = this.otherComplex + other.otherComplex
otherComplex = this.otherComplex + other.otherComplex,
allRules = this.toFlatList() + other.toFlatList()
)
}
fun toFlatList(): List<CssRule> {
return byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex
return allRules.ifEmpty { byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex }
}
}
data class OptimizedCssParseResult(
val rules: OptimizedCssRules,
val fontFaces: List<FontFaceInfo>
)
)

View file

@ -76,6 +76,7 @@ sealed interface AppAction {
data class AppFontPreferenceChanged(val preference: AppFontPreference) : AppAction
data class CustomAppThemeAdded(val theme: CustomAppTheme) : AppAction
data class CustomAppThemeDeleted(val themeId: String) : AppAction
data class CustomReaderThemesChanged(val themes: List<ReaderTheme>) : AppAction
data class SyncEnabledChanged(val enabled: Boolean) : AppAction
data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction
data class TabsEnabledChanged(val enabled: Boolean) : AppAction

View file

@ -272,6 +272,7 @@ data class SharedReaderScreenState(
val appSeedColor: Color? = null,
val appFontPreference: AppFontPreference = AppFontPreference.System,
val customAppThemes: List<CustomAppTheme> = emptyList(),
val customReaderThemes: List<ReaderTheme> = emptyList(),
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
val allTags: List<Tag> = emptyList(),

View file

@ -0,0 +1,102 @@
package com.aryan.reader.shared
enum class SharedCloudBookMetadataWinner {
LOCAL,
REMOTE,
SAME
}
fun sharedCloudBookReadingMetadataWinner(
localModifiedTimestamp: Long?,
remoteModifiedTimestamp: Long
): SharedCloudBookMetadataWinner {
val localTimestamp = localModifiedTimestamp ?: Long.MIN_VALUE
return when {
localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL
remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE
else -> SharedCloudBookMetadataWinner.SAME
}
}
fun sharedCloudBookMetadataWinner(
localModifiedTimestamp: Long?,
remoteModifiedTimestamp: Long,
localSidecarModifiedTimestamp: Long = 0L
): SharedCloudBookMetadataWinner {
val localTimestamp = maxOf(localModifiedTimestamp ?: Long.MIN_VALUE, localSidecarModifiedTimestamp)
return when {
localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL
remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE
else -> SharedCloudBookMetadataWinner.SAME
}
}
fun shouldApplyRemoteCloudBookMetadataUpdate(
localModifiedTimestamp: Long?,
remoteModifiedTimestamp: Long
): Boolean {
return sharedCloudBookReadingMetadataWinner(
localModifiedTimestamp = localModifiedTimestamp,
remoteModifiedTimestamp = remoteModifiedTimestamp
) == SharedCloudBookMetadataWinner.REMOTE
}
fun shouldUploadLocalCloudBookMetadataUpdate(
localModifiedTimestamp: Long,
remoteModifiedTimestamp: Long
): Boolean {
return sharedCloudBookReadingMetadataWinner(
localModifiedTimestamp = localModifiedTimestamp,
remoteModifiedTimestamp = remoteModifiedTimestamp
) == SharedCloudBookMetadataWinner.LOCAL
}
fun shouldApplyRemoteCloudBookUpdate(
localModifiedTimestamp: Long?,
remoteModifiedTimestamp: Long,
localSidecarModifiedTimestamp: Long = 0L
): Boolean {
return sharedCloudBookMetadataWinner(
localModifiedTimestamp = localModifiedTimestamp,
remoteModifiedTimestamp = remoteModifiedTimestamp,
localSidecarModifiedTimestamp = localSidecarModifiedTimestamp
) == SharedCloudBookMetadataWinner.REMOTE
}
fun shouldUploadLocalCloudBookUpdate(
localModifiedTimestamp: Long,
remoteModifiedTimestamp: Long,
localSidecarModifiedTimestamp: Long = 0L
): Boolean {
return sharedCloudBookMetadataWinner(
localModifiedTimestamp = localModifiedTimestamp,
remoteModifiedTimestamp = remoteModifiedTimestamp,
localSidecarModifiedTimestamp = localSidecarModifiedTimestamp
) == SharedCloudBookMetadataWinner.LOCAL
}
fun shouldDownloadRemoteCloudBookContent(
localFileAvailable: Boolean,
localContentModifiedTimestamp: Long,
remoteContentModifiedTimestamp: Long,
remoteDeleted: Boolean = false
): Boolean {
return !remoteDeleted &&
remoteContentModifiedTimestamp > 0L &&
(!localFileAvailable || remoteContentModifiedTimestamp > localContentModifiedTimestamp)
}
fun shouldUploadLocalCloudBookContent(
localFileAvailable: Boolean,
localContentModifiedTimestamp: Long,
remoteContentModifiedTimestamp: Long?
): Boolean {
return localFileAvailable &&
localContentModifiedTimestamp > 0L &&
localContentModifiedTimestamp > (remoteContentModifiedTimestamp ?: 0L)
}
fun sharedCloudBookContentFileName(bookId: String, type: FileType): String? {
val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
return "$bookId.$extension"
}

View file

@ -89,6 +89,10 @@ object SharedFileCapabilities {
"application/x-rar-compressed",
"application/x-cb7",
"application/x-7z-compressed",
"application/vnd.comicbook+tar",
"application/x-cbt",
"application/x-tar",
"application/tar",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
@ -166,6 +170,13 @@ object SharedFileCapabilities {
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
),
FileTypeCapability(
type = FileType.CBT,
displayName = "CBT",
extensions = setOf("cbt"),
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
),
FileTypeCapability(
type = FileType.DOCX,
displayName = "DOCX",
@ -211,11 +222,13 @@ object SharedFileCapabilities {
FileType.CBZ to "application/zip",
FileType.CBR to "application/zip",
FileType.CB7 to "application/zip",
FileType.CBT to "application/x-tar",
FileType.DOCX to "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
FileType.PPTX to "application/vnd.openxmlformats-officedocument.presentationml.presentation",
FileType.ODT to "application/vnd.oasis.opendocument.text",
FileType.FODT to "application/x-vnd.oasis.opendocument.text-flat-xml"
)
val comicArchiveTypes: Set<FileType> = setOf(FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT)
val knownFileTypes: Set<FileType> = all.mapTo(mutableSetOf()) { it.type }
fun capabilityFor(type: FileType): FileTypeCapability? {
@ -234,6 +247,10 @@ object SharedFileCapabilities {
return mimeTypesByType[type]
}
fun isComicArchive(type: FileType): Boolean {
return type in comicArchiveTypes
}
fun fileTypeForName(fileName: String): FileType {
return resolveFileTypeForName(fileName) ?: FileType.UNKNOWN
}
@ -267,6 +284,9 @@ object SharedFileCapabilities {
"application/x-cb7", "application/x-7z-compressed" -> {
if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null
}
"application/vnd.comicbook+tar", "application/x-cbt", "application/x-tar", "application/tar" -> {
if (fileName?.endsWith(".cbt", ignoreCase = true) == true) FileType.CBT else null
}
"application/pdf" -> FileType.PDF
"application/epub+zip" -> FileType.EPUB
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2

View file

@ -5,7 +5,7 @@ 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, PPTX, UNKNOWN
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, CBT, DOCX, ODT, FODT, PPTX, UNKNOWN
}
val PDF_VIEWER_FILE_TYPES: Set<FileType>
@ -67,7 +67,8 @@ data class SyncedFolder(
val uriString: String,
val name: String,
val lastScanTime: Long,
val allowedFileTypes: Set<FileType> = SharedFileCapabilities.knownFileTypes
val allowedFileTypes: Set<FileType> = SharedFileCapabilities.knownFileTypes,
val localSyncEnabled: Boolean = true
)
data class BookItem(
@ -99,7 +100,8 @@ data class BookItem(
val readerSettings: ReaderSettings? = null,
val readerBookmarks: List<ReaderBookmark> = emptyList(),
val readerHighlights: List<UserHighlight> = emptyList(),
val pdfReaderViewport: SharedPdfReaderViewport? = null
val pdfReaderViewport: SharedPdfReaderViewport? = null,
val readingPositionModifiedTimestamp: Long = 0L
)
data class Shelf(

View file

@ -99,6 +99,46 @@ object SharedLibraryEditor {
)
}
fun createShelfWithBooks(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
name: String,
bookIds: Iterable<String>,
clearSelection: Boolean = true,
nowMillis: Long = currentTimestamp()
): SharedLibraryMutationResult? {
val trimmed = cleanShelfName(name) ?: return null
val selectedBooks = cleanBookIds(bookIds)
val shelfId = "shelf_$nowMillis"
val newRefs = selectedBooks.map { bookId ->
BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
}
return SharedLibraryMutationResult(
state = state.copy(
selectedBookIds = if (clearSelection && selectedBooks.isNotEmpty()) emptySet() else state.selectedBookIds,
bannerMessage = if (selectedBooks.isEmpty()) {
BannerMessage.string(
"banner_shelf_created",
"Created shelf \"%1\$s\".",
trimmed
)
} else {
BannerMessage.quantity(
"banner_shelf_created_with_books",
selectedBooks.size,
"Created shelf \"%1\$s\" with %2\$d book.",
"Created shelf \"%1\$s\" with %2\$d books.",
trimmed,
selectedBooks.size
)
}
),
shelfRecords = shelfRecords + ShelfRecord(id = shelfId, name = trimmed),
shelfRefs = shelfRefs + newRefs
)
}
fun createSmartShelf(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
@ -239,25 +279,73 @@ object SharedLibraryEditor {
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 addBooksToShelves(
state = state,
shelfRecords = shelfRecords,
shelfRefs = shelfRefs,
bookIds = state.selectedBookIds,
shelfIds = listOf(shelfId),
clearSelection = true,
nowMillis = nowMillis,
bannerName = "banner_books_added_to_shelf",
singularMessage = "%1\$d book added to shelf.",
pluralMessage = "%1\$d books added to shelf."
)
}
fun addBooksToShelves(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
bookIds: Iterable<String>,
shelfIds: Iterable<String>,
clearSelection: Boolean = true,
nowMillis: Long = currentTimestamp()
): SharedLibraryMutationResult? {
return addBooksToShelves(
state = state,
shelfRecords = shelfRecords,
shelfRefs = shelfRefs,
bookIds = bookIds,
shelfIds = shelfIds,
clearSelection = clearSelection,
nowMillis = nowMillis,
bannerName = "banner_books_added_to_shelves",
singularMessage = "%1\$d shelf entry added.",
pluralMessage = "%1\$d shelf entries added."
)
}
fun replaceShelfBooks(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
shelfId: String,
bookIds: Iterable<String>,
nowMillis: Long = currentTimestamp()
): SharedLibraryMutationResult? {
val cleanShelfId = shelfId.trim()
if (!canMutateShelf(cleanShelfId)) return null
val selectedBooks = cleanBookIds(bookIds)
val shelfName = state.shelves.firstOrNull { it.id == cleanShelfId }?.name
?: shelfRecords.firstOrNull { it.id == cleanShelfId }?.name
?: cleanShelfId
return SharedLibraryMutationResult(
state = state.copy(
selectedBookIds = emptySet(),
bannerMessage = BannerMessage.quantity(
"banner_books_added_to_shelf",
additions.size,
"%1\$d book added to shelf.",
"%1\$d books added to shelf.",
additions.size
"banner_shelf_books_updated",
selectedBooks.size,
"Updated \"%1\$s\" with %2\$d book.",
"Updated \"%1\$s\" with %2\$d books.",
shelfName,
selectedBooks.size
)
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs + additions
shelfRefs = shelfRefs.filterNot { it.shelfId == cleanShelfId } +
selectedBooks.map { bookId ->
BookShelfRef(bookId = bookId, shelfId = cleanShelfId, addedAt = nowMillis)
}
)
}
@ -325,6 +413,51 @@ object SharedLibraryEditor {
shelfRefs = shelfRefs
)
}
private fun addBooksToShelves(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
bookIds: Iterable<String>,
shelfIds: Iterable<String>,
clearSelection: Boolean,
nowMillis: Long,
bannerName: String,
singularMessage: String,
pluralMessage: String
): SharedLibraryMutationResult? {
val selectedBooks = cleanBookIds(bookIds)
val targetShelfIds = shelfIds
.map { it.trim() }
.filter { canMutateShelf(it) }
.distinct()
if (selectedBooks.isEmpty() || targetShelfIds.isEmpty()) return null
val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId }
val additions = targetShelfIds.flatMap { shelfId ->
selectedBooks.mapNotNull { bookId ->
if (!existing.add(bookId to shelfId)) {
null
} else {
BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
}
}
}
return SharedLibraryMutationResult(
state = state.copy(
selectedBookIds = if (clearSelection) emptySet() else state.selectedBookIds,
bannerMessage = BannerMessage.quantity(
bannerName,
additions.size,
singularMessage,
pluralMessage,
additions.size
)
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs + additions
)
}
}
fun parseTagList(input: String, knownTags: List<Tag>, nowMillis: Long = currentTimestamp()): List<Tag> {

View file

@ -141,6 +141,13 @@ data class SharedFolderBookMetadata(
seriesIndex = existing?.seriesIndex,
lastPageIndex = lastPage,
readerPosition = parsedReaderPosition ?: existing?.readerPosition,
readingPositionModifiedTimestamp = if (
parsedReaderPosition != null || lastPage != null || progressPercentage > 0f
) {
metadataTimestamp
} else {
existing?.readingPositionModifiedTimestamp ?: 0L
},
readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(),
readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty()
)
@ -152,6 +159,9 @@ data class SharedFolderBookMetadata(
chapterIndex = lastChapterIndex,
cfi = lastPositionCfi,
pageIndex = lastPage
).withFallbacks(
blockIndex = locatorBlockIndex,
charOffset = locatorCharOffset
)
}
@ -272,6 +282,15 @@ object LocalFolderSyncEngine {
nowMillis: Long = currentTimestamp(),
metadataOnly: Boolean = false
): LocalFolderSyncResult {
if (!folder.localSyncEnabled) {
return LocalFolderSyncResult(
state = state,
idMigrations = emptyMap(),
removedBookIds = emptySet(),
stats = LocalFolderSyncStats()
)
}
val folderRoot = folder.uriString
val allowedTypes = folder.allowedFileTypes
val booksById = linkedMapOf<String, BookItem>()
@ -439,16 +458,7 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
!bookmarksJson.isNullOrBlank() ||
!highlightsJson.isNullOrBlank()
if (!isDirty) return null
val positionCfi = position?.cfi ?: position?.let { locator ->
val chapterIndex = locator.chapterIndex
val startOffset = locator.startOffset
val endOffset = locator.endOffset ?: startOffset
if (chapterIndex != null && startOffset != null && endOffset != null) {
"desktop:$chapterIndex:$startOffset:$endOffset"
} else {
null
}
}
val positionCfi = position?.toStablePositionCfi()
return SharedFolderBookMetadata(
bookId = id,
@ -463,8 +473,8 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
isRecent = isRecent,
lastModifiedTimestamp = localFolderModifiedTimestamp(),
bookmarksJson = bookmarksJson,
locatorBlockIndex = null,
locatorCharOffset = null,
locatorBlockIndex = position?.blockIndex,
locatorCharOffset = position?.charOffset,
customName = null,
highlightsJson = highlightsJson,
seriesName = null,

View file

@ -42,11 +42,15 @@ data class ReaderLocator(
val pageIndex: Int? = null,
val startOffset: Int? = null,
val endOffset: Int? = null,
val blockIndex: Int? = null,
val charOffset: Int? = null,
val textQuote: String? = null,
val cfi: String? = null
) {
val hasTextRange: Boolean
get() = startOffset != null && endOffset != null && endOffset >= startOffset
val hasBlockPosition: Boolean
get() = blockIndex != null && charOffset != null
fun withFallbacks(
chapterIndex: Int? = null,
@ -55,6 +59,8 @@ data class ReaderLocator(
pageIndex: Int? = null,
startOffset: Int? = null,
endOffset: Int? = null,
blockIndex: Int? = null,
charOffset: Int? = null,
textQuote: String? = null,
cfi: String? = null
): ReaderLocator {
@ -65,6 +71,8 @@ data class ReaderLocator(
pageIndex = this.pageIndex ?: pageIndex,
startOffset = this.startOffset ?: startOffset,
endOffset = this.endOffset ?: endOffset,
blockIndex = this.blockIndex ?: blockIndex,
charOffset = this.charOffset ?: charOffset,
textQuote = this.textQuote ?: textQuote,
cfi = this.cfi ?: cfi
)
@ -74,6 +82,10 @@ data class ReaderLocator(
val sameChapter = chapterIndex == null || other.chapterIndex == null || chapterIndex == other.chapterIndex
if (!sameChapter) return false
if (hasBlockPosition && other.hasBlockPosition) {
return blockIndex == other.blockIndex && charOffset == other.charOffset
}
if (hasTextRange && other.hasTextRange) {
return startOffset == other.startOffset && endOffset == other.endOffset
}
@ -92,21 +104,40 @@ data class ReaderLocator(
pageIndex: Int? = null,
textQuote: String? = null
): ReaderLocator {
val desktopParts = cfi
val stableCfi = cfi?.toStableReaderPositionCfi()
val desktopParts = stableCfi
?.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 androidLocatorParts = stableCfi
?.takeIf { it.startsWith("android-locator:") }
?.split(':')
.orEmpty()
val parsedAndroidChapterIndex = androidLocatorParts.getOrNull(1)?.toIntOrNull()
val parsedBlockIndex = androidLocatorParts.getOrNull(2)?.toIntOrNull()
val parsedCharOffset = androidLocatorParts.getOrNull(3)?.toIntOrNull()
?.takeIf { it >= 0 }
val parsedAndroidEndOffset = parsedCharOffset
?.let { start -> textQuote?.takeIf { it.isNotBlank() }?.let { start + it.length } }
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 parsedStartOffset = when {
hasOffsetRange -> possibleStartOffset
parsedBlockIndex != null && parsedAndroidEndOffset != null -> parsedCharOffset
else -> null
}
val parsedEndOffset = when {
hasOffsetRange -> possibleEndOffset
parsedBlockIndex != null -> parsedAndroidEndOffset
else -> null
}
val parsedPageIndex = when {
pageIndex != null -> pageIndex
desktopParts.size == 3 || desktopParts.size >= 5 || (desktopParts.size == 4 && !hasOffsetRange) ->
@ -114,23 +145,56 @@ data class ReaderLocator(
else -> null
}
return ReaderLocator(
chapterIndex = chapterIndex ?: parsedChapterIndex,
chapterIndex = chapterIndex ?: parsedChapterIndex ?: parsedAndroidChapterIndex,
pageIndex = parsedPageIndex,
startOffset = parsedStartOffset,
endOffset = parsedEndOffset,
blockIndex = parsedBlockIndex,
charOffset = parsedCharOffset,
textQuote = textQuote,
cfi = cfi
cfi = stableCfi ?: cfi
)
}
}
}
fun String.toStableReaderPositionCfi(): String {
val trimmed = trim()
if (!trimmed.startsWith("desktop-scroll:")) return trimmed
return trimmed
.split(':', limit = 4)
.getOrNull(3)
?.takeIf { it.isNotBlank() }
?: trimmed
}
fun ReaderLocator.toStablePositionCfi(): String? {
cfi
?.toStableReaderPositionCfi()
?.takeIf { it.isNotBlank() }
?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") }
?.let { return it }
val chapter = chapterIndex
val start = startOffset
val end = endOffset ?: start
return when {
chapter != null && blockIndex != null && charOffset != null ->
"android-locator:$chapter:$blockIndex:$charOffset"
chapter != null && start != null && end != null ->
"desktop:$chapter:$start:$end"
chapter != null && pageIndex != null ->
"desktop:$chapter:$pageIndex"
else -> null
}
}
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 })
val knownColors = colors.filter { it in HighlightColor.entries }
return copy(colors = knownColors.takeIf { it.size == PaletteSize } ?: defaultColors)
}
fun contains(color: HighlightColor): Boolean {
@ -147,14 +211,13 @@ data class ReaderHighlightPalette(
}
companion object {
const val PaletteSize: Int = 4
val defaultColors: List<HighlightColor>
get() = listOf(
HighlightColor.YELLOW,
HighlightColor.GREEN,
HighlightColor.BLUE,
HighlightColor.RED,
HighlightColor.PURPLE,
HighlightColor.ORANGE
HighlightColor.RED
)
}
}

View file

@ -238,6 +238,8 @@ object EpubAnnotationSerializer {
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
blockIndex?.let { put("blockIndex", JsonPrimitive(it)) }
charOffset?.let { put("charOffset", JsonPrimitive(it)) }
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
cfi?.let { put("cfi", JsonPrimitive(it)) }
}
@ -253,6 +255,8 @@ object EpubAnnotationSerializer {
pageIndex = obj.int("pageIndex"),
startOffset = obj.int("startOffset"),
endOffset = obj.int("endOffset"),
blockIndex = obj.int("blockIndex"),
charOffset = obj.int("charOffset"),
textQuote = obj.string("textQuote"),
cfi = obj.string("cfi")
)

View file

@ -101,13 +101,29 @@ data class ReaderTheme(
val isCustom: Boolean = false
)
val BuiltInReaderThemes = listOf(
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false),
fun List<ReaderTheme>.sanitizeCustomReaderThemes(): List<ReaderTheme> {
val seenIds = mutableSetOf<String>()
return asReversed()
.filter { theme ->
theme.isCustom &&
theme.id.isNotBlank() &&
theme.name.isNotBlank() &&
theme.backgroundColor.isSpecified &&
theme.textColor.isSpecified &&
seenIds.add(theme.id)
}
.asReversed()
}
private val StandardReaderSolidThemes = listOf(
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("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
)
private val StandardReaderTexturedThemes = listOf(
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),
@ -116,21 +132,16 @@ val BuiltInReaderThemes = listOf(
ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
)
val BuiltInReaderThemes = listOf(
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false)
) + StandardReaderSolidThemes + StandardReaderTexturedThemes
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)
)
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true)
) + StandardReaderSolidThemes + StandardReaderTexturedThemes.map { theme ->
theme.copy(id = "pdf_${theme.id}")
}
fun FormatSettings.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings {
val horizontalMarginPx = (ReaderAppearanceDefaults.marginPx * horizontalMargin).roundToInt()
@ -169,6 +180,59 @@ fun ReaderTheme.toReaderSettings(base: ReaderSettings = ReaderSettings()): Reade
)
}
fun ReaderSettings.resetReaderFormatSettings(): ReaderSettings {
val defaults = ReaderSettings()
return copy(
fontSize = defaults.fontSize,
lineSpacing = defaults.lineSpacing,
margin = defaults.margin,
horizontalMargin = defaults.horizontalMargin,
verticalMargin = defaults.verticalMargin,
textAlign = defaults.textAlign,
pageWidth = defaults.pageWidth,
fontFamily = defaults.fontFamily,
paragraphSpacing = defaults.paragraphSpacing,
imageScale = defaults.imageScale,
customFontPath = defaults.customFontPath
)
}
fun ReaderSettings.withHorizontalReaderMargin(horizontalMarginPx: Int): ReaderSettings {
val nextHorizontal = horizontalMarginPx.coerceIn(
ReaderAppearanceDefaults.minMarginPx,
ReaderAppearanceDefaults.maxMarginPx
)
val currentVertical = resolvedVerticalMargin.coerceIn(
ReaderAppearanceDefaults.minMarginPx,
ReaderAppearanceDefaults.maxMarginPx
)
return copy(
margin = max(nextHorizontal, currentVertical),
horizontalMargin = nextHorizontal,
verticalMargin = currentVertical
)
}
fun ReaderSettings.withVerticalReaderMargin(verticalMarginPx: Int): ReaderSettings {
val currentHorizontal = resolvedHorizontalMargin.coerceIn(
ReaderAppearanceDefaults.minMarginPx,
ReaderAppearanceDefaults.maxMarginPx
)
val nextVertical = verticalMarginPx.coerceIn(
ReaderAppearanceDefaults.minMarginPx,
ReaderAppearanceDefaults.maxMarginPx
)
return copy(
margin = max(currentHorizontal, nextVertical),
horizontalMargin = currentHorizontal,
verticalMargin = nextVertical
)
}
fun ReaderSettings.shouldShowPageWidthFormatControl(): Boolean {
return readingMode == ReaderReadingMode.PAGINATED
}
fun readerThemeById(themeId: String?): ReaderTheme? {
return BuiltInReaderThemes.firstOrNull { it.id == themeId }
}

View file

@ -10,11 +10,13 @@ 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
import com.aryan.reader.shared.reader.logSharedReaderDiagnostic
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
private const val ReaderTtsStartTraceLogTag = "EpistemeDesktopTtsStartTrace"
data class ReaderCloudTtsVoice(
val id: String,
@ -325,15 +327,42 @@ object ReaderTtsPlanner {
val anchor = session.navigationLocator
val pageIndex = anchor?.pageIndex ?: session.reader.currentPageIndex
val pages = session.reader.pages.dropWhile { it.pageIndex < pageIndex.coerceAtLeast(0) }
val syntheticDesktopAnchor = anchor?.isSyntheticDesktopTtsAnchor() == true
val chunks = chunksForPages(session.reader.book, pages)
val chapterIndex = anchor?.chapterIndex
val startOffset = anchor?.startOffset
if (chapterIndex == null && startOffset == null) return chunks
var nextIndex = 0
return chunks.mapNotNull { chunk ->
chunk.afterLocator(chapterIndex = chapterIndex, startOffset = startOffset)
?.copy(index = nextIndex++)
logReaderTtsStartTrace {
"event=planner_from_here_start pageIndex=$pageIndex pages=${pages.size} chunks=${chunks.size} " +
"syntheticDesktop=$syntheticDesktopAnchor " +
"anchor=${anchor.readerTtsLocatorSummary()} first=${chunks.firstOrNull().readerTtsChunkSummary()} " +
"second=${chunks.getOrNull(1).readerTtsChunkSummary()}"
}
if (chapterIndex == null && startOffset == null) return chunks
val target = anchor?.toTtsChunkTarget()
val startChunkIndex = findReaderTtsChunkStartIndex(chunks, target)
?: chunks.indexOfFirst { it.isOnOrAfterLocator(chapterIndex, startOffset) }.takeIf { it >= 0 }
?: run {
logReaderTtsStartTrace {
"event=planner_from_here_empty reason=no_start_chunk target=${target.readerTtsTargetSummary()} " +
"anchor=${anchor.readerTtsLocatorSummary()} chunks=${chunks.size}"
}
return emptyList()
}
val initialChunk = anchor?.let { chunks[startChunkIndex].sliceFromLocator(it) }
val sessionChunks = if (initialChunk == null) {
chunks.drop(startChunkIndex + 1)
} else {
chunks.withInitialChunkOverride(startChunkIndex, initialChunk).drop(startChunkIndex)
}
logReaderTtsStartTrace {
"event=planner_from_here_result target=${target.readerTtsTargetSummary()} startChunkIndex=$startChunkIndex " +
"sourceChunk=${chunks.getOrNull(startChunkIndex).readerTtsChunkSummary()} " +
"initialChunk=${initialChunk.readerTtsChunkSummary()} resultChunks=${sessionChunks.size} " +
"resultFirst=${sessionChunks.firstOrNull().readerTtsChunkSummary()}"
}
return sessionChunks
.filter { it.text.isNotBlank() }
.mapIndexed { index, chunk -> chunk.copy(index = index) }
}
fun chunksForText(
@ -401,28 +430,51 @@ object ReaderTtsPlanner {
}
}
private fun ReaderTtsChunk.afterLocator(chapterIndex: Int?, startOffset: Int?): ReaderTtsChunk? {
private fun ReaderTtsChunk.isOnOrAfterLocator(chapterIndex: Int?, startOffset: Int?): Boolean {
if (chapterIndex != null) {
if (this.chapterIndex < chapterIndex) return null
if (this.chapterIndex > chapterIndex) return this
if (this.chapterIndex < chapterIndex) return false
if (this.chapterIndex > chapterIndex) return true
}
val anchorOffset = startOffset ?: return this
if (endOffset <= anchorOffset) return null
if (anchorOffset <= this.startOffset) return this
return trimStartTo(anchorOffset)
val anchorOffset = startOffset ?: return true
return endOffset > anchorOffset
}
private fun ReaderTtsChunk.trimStartTo(sourceOffset: Int): ReaderTtsChunk? {
val boundedOffset = sourceOffset.coerceIn(startOffset, endOffset)
if (boundedOffset <= startOffset) return this
if (boundedOffset >= endOffset) return null
val rawDrop = (boundedOffset - startOffset).coerceIn(0, text.length)
val remaining = text.drop(rawDrop)
private fun ReaderTtsChunk.sliceFromLocator(locator: ReaderLocator): ReaderTtsChunk? {
if (locator.chapterIndex != null && locator.chapterIndex != chapterIndex) return this
val sourceOffset = locator.startOffset ?: return this
val rawDrop = (sourceOffset - startOffset).coerceIn(0, text.length)
val drop = rawDrop
if (drop <= 0) {
logReaderTtsStartTrace {
"event=planner_slice_keep reason=drop_at_start rawDrop=$rawDrop " +
"locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}"
}
return this
}
if (drop >= text.length) {
logReaderTtsStartTrace {
"event=planner_slice_skip reason=drop_past_end rawDrop=$rawDrop " +
"chosenDrop=$drop locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}"
}
return null
}
val remaining = text.drop(drop)
val leadingWhitespace = remaining.indexOfFirst { !it.isWhitespace() }
if (leadingWhitespace < 0) return null
if (leadingWhitespace < 0) {
logReaderTtsStartTrace {
"event=planner_slice_skip reason=blank_after_drop rawDrop=$rawDrop " +
"chosenDrop=$drop locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}"
}
return null
}
val nextText = remaining.drop(leadingWhitespace)
if (nextText.isBlank()) return null
val nextStartOffset = (boundedOffset + leadingWhitespace).coerceAtMost(endOffset)
val nextStartOffset = (sourceOffset + leadingWhitespace).coerceAtMost(endOffset)
logReaderTtsStartTrace {
"event=planner_slice_result rawDrop=$rawDrop chosenDrop=$drop " +
"leadingWhitespace=$leadingWhitespace nextStart=$nextStartOffset locator=${locator.readerTtsLocatorSummary()} " +
"chunk=${readerTtsChunkSummary()} nextText=\"${nextText.readerTtsLogPreview()}\""
}
return copy(
text = nextText,
spokenText = nextText,
@ -545,7 +597,7 @@ object ReaderTtsPlanner {
chunks += ReaderTtsTextRange(
text = currentText.toString(),
start = currentStart,
end = currentStart + currentText.length
end = currentEnd
)
}
currentText = StringBuilder()
@ -554,19 +606,25 @@ object ReaderTtsPlanner {
}
for (sentence in sentenceRanges) {
val appendText = if (currentText.isEmpty()) {
sentence.text
} else {
val gapStart = (currentEnd - sourceStart).coerceIn(0, source.length)
val gapEnd = (sentence.end - sourceStart).coerceIn(gapStart, source.length)
source.substring(gapStart, gapEnd)
}
if (sentence.text.length > maxLength) {
flushCurrent()
chunks += sentence
continue
}
if (currentText.isNotEmpty() && currentText.length + sentence.text.length + 1 > maxLength) {
if (currentText.isNotEmpty() && currentText.length + appendText.length > maxLength) {
flushCurrent()
currentText.append(sentence.text)
currentStart = sentence.start
currentEnd = sentence.end
} else {
if (currentText.isNotEmpty()) currentText.append(" ")
currentText.append(sentence.text)
currentText.append(appendText)
if (currentStart < 0) currentStart = sentence.start
currentEnd = sentence.end
}
@ -612,6 +670,162 @@ object ReaderTtsPlanner {
val start: Int,
val end: Int
)
private data class ReaderTtsChunkTarget(
val text: String,
val sourceCfi: String?,
val startOffset: Int
)
private fun ReaderLocator.toTtsChunkTarget(): ReaderTtsChunkTarget? {
val offset = startOffset ?: return null
val sourceCfi = cfi
?.readerTtsSourceCfiBase()
?.takeIf { it.startsWith("/") }
return ReaderTtsChunkTarget(
text = textQuote.orEmpty(),
sourceCfi = sourceCfi,
startOffset = offset
)
}
private fun ReaderLocator.isSyntheticDesktopTtsAnchor(): Boolean {
val value = cfi.orEmpty()
return value.startsWith("desktop:") ||
value.startsWith("desktop-scroll:") ||
value.startsWith("desktop-scroll-page:")
}
private fun findReaderTtsChunkStartIndex(
chunks: List<ReaderTtsChunk>,
target: ReaderTtsChunkTarget?
): Int? {
if (target == null) return null
val exactIndex = chunks.indexOfFirst {
readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
it.startOffset == target.startOffset &&
it.text.normalizedReaderTtsText() == target.text.normalizedReaderTtsText()
}
if (exactIndex >= 0) return exactIndex
val sourceAndOffsetIndex = chunks.indexOfFirst {
readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
target.startOffset >= it.startOffset &&
target.startOffset < it.endOffset
}
if (sourceAndOffsetIndex >= 0) return sourceAndOffsetIndex
val sourceAndTextIndex = chunks.indexOfFirst {
readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
readerTtsTextMatches(it.text, target.text)
}
if (sourceAndTextIndex >= 0) return sourceAndTextIndex
val sourceNearestOffsetIndex = chunks
.mapIndexedNotNull { index, chunk ->
if (readerSameTtsChunkSource(chunk.sourceCfi, target.sourceCfi)) {
index to kotlin.math.abs(chunk.startOffset - target.startOffset)
} else {
null
}
}
.minByOrNull { it.second }
?.first
if (sourceNearestOffsetIndex != null) return sourceNearestOffsetIndex
return findUniqueReaderTtsTextMatch(chunks, target.text)
}
private fun List<ReaderTtsChunk>.withInitialChunkOverride(
startChunkIndex: Int,
initialChunk: ReaderTtsChunk?
): List<ReaderTtsChunk> {
if (initialChunk == null || startChunkIndex !in indices) return this
val existing = this[startChunkIndex]
if (
existing.text == initialChunk.text &&
existing.sourceCfi == initialChunk.sourceCfi &&
existing.startOffset == initialChunk.startOffset
) {
return this
}
return toMutableList().also { it[startChunkIndex] = initialChunk }
}
private fun readerSameTtsChunkSource(first: String?, second: String?): Boolean {
val firstSource = first.orEmpty()
val secondSource = second.orEmpty()
if (firstSource.isBlank() || secondSource.isBlank()) return firstSource == secondSource
val firstPath = firstSource.readerTtsSourceCfiBase()
val secondPath = secondSource.readerTtsSourceCfiBase()
return firstPath == secondPath ||
readerTtsCfiPathContains(firstPath, secondPath) ||
readerTtsCfiPathContains(secondPath, firstPath)
}
private fun readerTtsCfiPathContains(parentPath: String, childPath: String): Boolean {
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
val parentParts = parentPath.split('/').filter { it.isNotEmpty() }
val childParts = childPath.split('/').filter { it.isNotEmpty() }
return parentParts.size < childParts.size && childParts.take(parentParts.size) == parentParts
}
private fun readerTtsTextMatches(first: String, second: String): Boolean {
val firstNormalized = first.normalizedReaderTtsText()
val secondNormalized = second.normalizedReaderTtsText()
if (firstNormalized.isBlank() || secondNormalized.isBlank()) return false
return firstNormalized == secondNormalized ||
firstNormalized.startsWith(secondNormalized) ||
secondNormalized.startsWith(firstNormalized)
}
private fun findUniqueReaderTtsTextMatch(chunks: List<ReaderTtsChunk>, text: String): Int? {
val matches = chunks.mapIndexedNotNull { index, chunk ->
index.takeIf { readerTtsTextMatches(chunk.text, text) }
}
return matches.singleOrNull()
}
private fun String.readerTtsSourceCfiBase(): String {
return substringBefore('|').substringBefore(':')
}
private fun String.normalizedReaderTtsText(): String {
return replace(Regex("\\s+"), " ").trim()
}
private inline fun logReaderTtsStartTrace(message: () -> String) {
logSharedReaderDiagnostic(ReaderTtsStartTraceLogTag, message)
}
private fun ReaderLocator?.readerTtsLocatorSummary(maxTextLength: Int = 120): String {
if (this == null) return "null"
return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " +
"offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " +
"block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " +
"cfi=\"${cfi.orEmpty().readerTtsLogPreview(180)}\" text=\"${textQuote.orEmpty().readerTtsLogPreview(maxTextLength)}\""
}
private fun ReaderTtsChunk?.readerTtsChunkSummary(maxTextLength: Int = 120): String {
if (this == null) return "null"
return "index=$index page=$pageIndex chapter=$chapterIndex offsets=$startOffset..$endOffset " +
"sourceCfi=\"${sourceCfi.orEmpty().readerTtsLogPreview(160)}\" textChars=${text.length} " +
"text=\"${text.readerTtsLogPreview(maxTextLength)}\" spoken=\"${spokenText.readerTtsLogPreview(maxTextLength)}\""
}
private fun ReaderTtsChunkTarget?.readerTtsTargetSummary(maxTextLength: Int = 120): String {
if (this == null) return "null"
return "offset=$startOffset sourceCfi=\"${sourceCfi.orEmpty().readerTtsLogPreview(160)}\" " +
"text=\"${text.readerTtsLogPreview(maxTextLength)}\""
}
private fun String.readerTtsLogPreview(maxLength: Int = 120): String {
return replace(Regex("\\s+"), " ")
.trim()
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
.replace("\"", "\\\"")
}
}
data class ReaderCloudTtsState(
@ -625,6 +839,31 @@ data class ReaderCloudTtsState(
val cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary()
)
data class ReaderCloudTtsControlsModel(
val isVisible: Boolean,
val canPauseResume: Boolean,
val canSkipPrevious: Boolean,
val canSkipNext: Boolean,
val canLocateCurrentChunk: Boolean
)
fun readerCloudTtsControlsModel(cloudTts: ReaderCloudTtsState): ReaderCloudTtsControlsModel {
val progress = cloudTts.progress
val visible = cloudTts.isLoading || cloudTts.isPlaying || cloudTts.isPaused
val hasCurrentChunk = progress.currentChunk != null
return ReaderCloudTtsControlsModel(
isVisible = visible,
canPauseResume = cloudTts.isPlaying || cloudTts.isPaused,
canSkipPrevious = !cloudTts.isLoading &&
progress.currentChunkIndex > 0 &&
progress.chunks.isNotEmpty(),
canSkipNext = !cloudTts.isLoading &&
progress.currentChunkIndex >= 0 &&
progress.currentChunkIndex < progress.chunks.lastIndex,
canLocateCurrentChunk = hasCurrentChunk
)
}
data class ReaderAiResultState(
val title: String? = null,
val text: String = "",

View file

@ -5,9 +5,7 @@ private val DefaultReaderBottomToolIds: Set<String>
ReaderTool.SLIDER.id,
ReaderTool.TOC.id,
ReaderTool.FORMAT.id,
ReaderTool.SEARCH.id,
ReaderTool.AI_FEATURES.id,
ReaderTool.TTS_CONTROLS.id
ReaderTool.SEARCH.id
)
enum class ReaderTool(
@ -22,8 +20,8 @@ enum class ReaderTool(
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),
AI_FEATURES("ai_features", "AI Features", "Bottom Bar"),
TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar"),
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"),
@ -31,7 +29,6 @@ enum class ReaderTool(
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");

View file

@ -90,22 +90,11 @@ data class ReaderTtsReplacementApplyResult(
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.",
)
},
)
val validation = ReaderWordReplacementEngine.validate(rule.toWordReplacementRule())
return ReaderTtsReplacementValidation(
isValid = validation.isValid,
message = validation.message,
)
}
fun apply(
@ -117,52 +106,31 @@ object ReaderTtsReplacementEngine {
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
}
}
val result = ReaderWordReplacementEngine.apply(
text = text,
rules = preferences.activeRulesForBook(bookId).map { it.toWordReplacementRule() },
)
return ReaderTtsReplacementApplyResult(
text = current,
appliedRuleIds = applied,
errors = errors,
text = result.text,
appliedRuleIds = result.appliedRuleIds,
errors = result.errors.map {
ReaderTtsReplacementError(ruleId = it.ruleId, message = it.message)
},
)
}
}
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)
}
private fun ReaderTtsReplacementRule.toWordReplacementRule(): ReaderWordReplacementRule {
return ReaderWordReplacementRule(
id = id,
from = from,
to = to,
enabled = enabled,
isRegex = isRegex,
matchCase = matchCase,
wholeWord = wholeWord,
)
}
fun ReaderTtsChunk.withTtsReplacements(

View file

@ -0,0 +1,195 @@
package com.aryan.reader.shared
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class ReaderWordReplacementRule(
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 ReaderWordReplacementValidation(
val isValid: Boolean,
val message: String? = null,
)
data class ReaderWordReplacementError(
val ruleId: String,
val message: String,
)
data class ReaderWordReplacementApplyResult(
val text: String,
val appliedRuleIds: List<String> = emptyList(),
val errors: List<ReaderWordReplacementError> = emptyList(),
)
object ReaderWordReplacementEngine {
fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation {
if (rule.from.isBlank()) {
return ReaderWordReplacementValidation(isValid = false, message = "Enter text to replace.")
}
if (!rule.isRegex) {
return ReaderWordReplacementValidation(isValid = true)
}
return runCatching { rule.toRegex() }
.fold(
onSuccess = { ReaderWordReplacementValidation(isValid = true) },
onFailure = {
ReaderWordReplacementValidation(
isValid = false,
message = it.message ?: "This regex is not valid.",
)
},
)
}
fun apply(
text: String,
rules: List<ReaderWordReplacementRule>,
): ReaderWordReplacementApplyResult {
if (text.isEmpty() || rules.isEmpty()) {
return ReaderWordReplacementApplyResult(text = text)
}
var current = text
val applied = mutableListOf<String>()
val errors = mutableListOf<ReaderWordReplacementError>()
rules.forEach { rule ->
if (!rule.enabled || rule.from.isBlank()) return@forEach
val regex = runCatching { rule.toRegex() }
.onFailure {
errors += ReaderWordReplacementError(
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 += ReaderWordReplacementError(
ruleId = rule.id,
message = it.message ?: "Invalid replacement.",
)
}
.getOrNull() ?: return@forEach
if (next != current) {
applied += rule.id
current = next
}
}
return ReaderWordReplacementApplyResult(
text = current,
appliedRuleIds = applied,
errors = errors,
)
}
private fun ReaderWordReplacementRule.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)
}
}
@Serializable
data class ReaderBookReplacementPreferences(
val fileRules: Map<String, List<ReaderWordReplacementRule>> = emptyMap(),
) {
fun rulesForFile(fileId: String?): List<ReaderWordReplacementRule> {
return fileRules[fileId.orEmpty()].orEmpty()
}
fun activeRulesForFile(fileId: String?): List<ReaderWordReplacementRule> {
return rulesForFile(fileId).filter { it.enabled && it.from.isNotBlank() }
}
fun withFileRules(
fileId: String?,
rules: List<ReaderWordReplacementRule>,
): ReaderBookReplacementPreferences {
val key = fileId.orEmpty()
val nextRules = if (rules.isEmpty()) {
fileRules - key
} else {
fileRules + (key to rules)
}
return copy(fileRules = nextRules)
}
fun scopedToFile(fileId: String?): ReaderBookReplacementPreferences {
val key = fileId.orEmpty()
val rules = rulesForFile(key)
return if (rules.isEmpty()) {
ReaderBookReplacementPreferences()
} else {
ReaderBookReplacementPreferences(fileRules = mapOf(key to rules))
}
}
fun signatureForFile(fileId: String?): String {
return activeRulesForFile(fileId).joinToString(separator = "|") { rule ->
listOf(
rule.id,
rule.from,
rule.to,
rule.enabled.toString(),
rule.isRegex.toString(),
rule.matchCase.toString(),
rule.wholeWord.toString(),
).joinToString(separator = "\u001F")
}
}
}
object ReaderBookReplacementEngine {
fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation {
return ReaderWordReplacementEngine.validate(rule)
}
fun apply(
text: String,
preferences: ReaderBookReplacementPreferences,
fileId: String?,
): ReaderWordReplacementApplyResult {
return ReaderWordReplacementEngine.apply(
text = text,
rules = preferences.activeRulesForFile(fileId),
)
}
}
object ReaderBookReplacementPreferencesJson {
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = false
encodeDefaults = true
}
fun encode(preferences: ReaderBookReplacementPreferences): String {
return json.encodeToString(preferences)
}
fun decodeOrEmpty(raw: String?): ReaderBookReplacementPreferences {
if (raw.isNullOrBlank()) return ReaderBookReplacementPreferences()
return runCatching {
json.decodeFromString<ReaderBookReplacementPreferences>(raw)
}.getOrNull() ?: ReaderBookReplacementPreferences()
}
}

View file

@ -646,6 +646,7 @@ data class SharedSettingsHubInput(
val isSignedIn: Boolean = false,
val isProUser: Boolean = false,
val accountAvailable: Boolean = true,
val includeAccountAuthActions: Boolean = true,
val syncAvailable: Boolean = true,
val folderSyncAvailable: Boolean = true,
val aiSettingsAvailable: Boolean = true,
@ -750,7 +751,7 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode
SharedSettingsSectionModel(
section = SharedSettingsSection.SYNC_ACCOUNTS,
items = buildList {
if (input.accountAvailable && input.featurePolicy.aiAndCloud) {
if (input.includeAccountAuthActions && input.accountAvailable && input.featurePolicy.aiAndCloud) {
if (input.isSignedIn) {
add(
SharedSettingsItemModel(

View file

@ -11,6 +11,11 @@ data class SharedFeaturePolicy(
) {
companion object {
val Standard = SharedFeaturePolicy()
val OssOnline = SharedFeaturePolicy(
networkAccess = true,
aiAndCloud = true,
byokAi = true
)
val OssOffline = SharedFeaturePolicy(
networkAccess = false,
opdsCatalogs = false,

View file

@ -0,0 +1,34 @@
package com.aryan.reader.shared
const val EPISTEME_POLICY_BASE_URL = "https://aryan-raj3112.github.io/reader-policy"
enum class SharedLegalProfile {
STANDARD,
OSS
}
data class SharedLegalLinks(
val privacyPolicyUrl: String,
val termsUrl: String,
val licensesUrl: String
)
fun sharedLegalLinksForProfile(profile: SharedLegalProfile): SharedLegalLinks {
val privacyPath: String
val termsPath: String
when (profile) {
SharedLegalProfile.STANDARD -> {
privacyPath = "privacy-policy.html"
termsPath = "terms-and-conditions.html"
}
SharedLegalProfile.OSS -> {
privacyPath = "oss-privacy-policy.html"
termsPath = "oss-terms-of-service.html"
}
}
return SharedLegalLinks(
privacyPolicyUrl = "$EPISTEME_POLICY_BASE_URL/$privacyPath",
termsUrl = "$EPISTEME_POLICY_BASE_URL/$termsPath",
licensesUrl = "$EPISTEME_POLICY_BASE_URL/licenses.html"
)
}

View file

@ -45,8 +45,10 @@ data class SharedLibrarySnapshot(
val appSeedColor: Color? = null,
val appFontPreference: AppFontPreference = AppFontPreference.System,
val customAppThemes: List<CustomAppTheme> = emptyList(),
val customReaderThemes: List<ReaderTheme> = emptyList(),
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
val desktopReaderDefaultsVersion: Int = 0,
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(),
@ -54,7 +56,7 @@ data class SharedLibrarySnapshot(
)
object SharedLibrarySnapshotJson {
private const val SCHEMA_VERSION = 20
private const val SCHEMA_VERSION = 22
private val json = Json {
prettyPrint = true
@ -106,11 +108,15 @@ object SharedLibrarySnapshotJson {
?.asAppFontPreferenceOrNull()
?: AppFontPreference.System,
customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() },
customReaderThemes = root.array("customReaderThemes")
.mapNotNull { it.asReaderThemeOrNull() }
.sanitizeCustomReaderThemes(),
readerDefaultSettings = readerDefaultSettings.migrateLegacyDefaultReadingMode(schemaVersion),
pdfReaderDefaultSettings = root["pdfReaderDefaultSettings"]
?.takeUnless { it is JsonNull }
?.asReaderSettingsOrNull()
?: ReaderSettings(themeId = "no_theme"),
desktopReaderDefaultsVersion = root.int("desktopReaderDefaultsVersion", 0),
readerToolbarPreferences = root["readerToolbarPreferences"]
?.takeUnless { it is JsonNull }
?.asReaderToolbarPreferencesOrNull()
@ -154,8 +160,12 @@ object SharedLibrarySnapshotJson {
"appSeedColor" to snapshot.appSeedColor.asJson(),
"appFontPreference" to snapshot.appFontPreference.sanitized().toJsonObject(),
"customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }),
"customReaderThemes" to JsonArray(
snapshot.customReaderThemes.sanitizeCustomReaderThemes().map { it.toJsonObject() }
),
"readerDefaultSettings" to snapshot.readerDefaultSettings.asJson(),
"pdfReaderDefaultSettings" to snapshot.pdfReaderDefaultSettings.asJson(),
"desktopReaderDefaultsVersion" to JsonPrimitive(snapshot.desktopReaderDefaultsVersion),
"readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(),
"readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(),
"pdfHighlighterPalette" to snapshot.pdfHighlighterPalette.sanitized().toJsonObject(),
@ -279,7 +289,8 @@ private fun JsonElement.asBookItemOrNull(): BookItem? {
readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(),
readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() },
readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() },
pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull()
pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull(),
readingPositionModifiedTimestamp = obj.long("readingPositionModifiedTimestamp")
)
}
@ -336,7 +347,8 @@ private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? {
.mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() }
.filter { it in SharedFileCapabilities.knownFileTypes }
.toSet()
.ifEmpty { SharedFileCapabilities.knownFileTypes }
.ifEmpty { SharedFileCapabilities.knownFileTypes },
localSyncEnabled = obj.boolean("localSyncEnabled", true)
)
}
@ -349,6 +361,19 @@ private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? {
)
}
private fun JsonElement.asReaderThemeOrNull(): ReaderTheme? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return ReaderTheme(
id = obj.string("id") ?: return null,
name = obj.string("name") ?: return null,
backgroundColor = obj.int("bgColor")?.let { Color(it) } ?: return null,
textColor = obj.int("textColor")?.let { Color(it) } ?: return null,
isDark = obj.boolean("isDark", false),
textureId = obj.string("textureId")?.takeIf { it.isNotBlank() },
isCustom = true
)
}
private fun JsonElement.asAppFontPreferenceOrNull(): AppFontPreference? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val kind = obj.string("kind")
@ -391,7 +416,8 @@ private fun BookItem.toJsonObject(): JsonObject {
"readerSettings" to readerSettings.asJson(),
"readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }),
"readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() }),
"pdfReaderViewport" to pdfReaderViewport.asJson()
"pdfReaderViewport" to pdfReaderViewport.asJson(),
"readingPositionModifiedTimestamp" to JsonPrimitive(readingPositionModifiedTimestamp)
)
)
}
@ -451,7 +477,8 @@ private fun SyncedFolder.toJsonObject(): JsonObject {
.filter { it in SharedFileCapabilities.knownFileTypes }
.map { it.name }
.sorted()
.asJsonArray()
.asJsonArray(),
"localSyncEnabled" to JsonPrimitive(localSyncEnabled)
)
)
}
@ -466,6 +493,18 @@ private fun CustomAppTheme.toJsonObject(): JsonObject {
)
}
private fun ReaderTheme.toJsonObject(): JsonObject {
val values = mutableMapOf<String, JsonElement>(
"id" to JsonPrimitive(id),
"name" to JsonPrimitive(name),
"bgColor" to JsonPrimitive(backgroundColor.toArgb()),
"textColor" to JsonPrimitive(textColor.toArgb()),
"isDark" to JsonPrimitive(isDark)
)
textureId?.let { values["textureId"] = JsonPrimitive(it) }
return JsonObject(values)
}
private fun AppFontPreference.toJsonObject(): JsonObject {
val sanitized = sanitized()
return JsonObject(
@ -529,6 +568,7 @@ private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
pageSpreadMode = obj.string("pageSpreadMode")
?.let { runCatching { ReaderPageSpreadMode.valueOf(it) }.getOrNull() }
?: defaults.pageSpreadMode,
rightToLeftPagination = obj.boolean("rightToLeftPagination", defaults.rightToLeftPagination),
pdfVerticalPageGapVisible = obj.boolean(
"pdfVerticalPageGapVisible",
defaults.pdfVerticalPageGapVisible
@ -650,6 +690,8 @@ private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? {
pageIndex = obj.int("pageIndex"),
startOffset = obj.int("startOffset"),
endOffset = obj.int("endOffset"),
blockIndex = obj.int("blockIndex"),
charOffset = obj.int("charOffset"),
textQuote = obj.string("textQuote"),
cfi = obj.string("cfi")
)
@ -681,6 +723,7 @@ private fun ReaderSettings?.asJson(): JsonElement {
"pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name),
"pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name),
"pageSpreadMode" to JsonPrimitive(settings.pageSpreadMode.name),
"rightToLeftPagination" to JsonPrimitive(settings.rightToLeftPagination),
"pdfVerticalPageGapVisible" to JsonPrimitive(settings.pdfVerticalPageGapVisible),
"pdfPageNumberOverlayVisible" to JsonPrimitive(settings.pdfPageNumberOverlayVisible),
"pdfFirstPageStandaloneInSpread" to JsonPrimitive(settings.pdfFirstPageStandaloneInSpread),
@ -767,6 +810,8 @@ private fun ReaderLocator.toJsonObject(): JsonObject {
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
blockIndex?.let { put("blockIndex", JsonPrimitive(it)) }
charOffset?.let { put("charOffset", JsonPrimitive(it)) }
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
cfi?.let { put("cfi", JsonPrimitive(it)) }
}

View file

@ -89,6 +89,9 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
appSeedColor = if (shouldClearSeed) null else appSeedColor
)
}
is AppAction.CustomReaderThemesChanged -> copy(
customReaderThemes = action.themes.sanitizeCustomReaderThemes()
)
is AppAction.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled)
is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled)
is AppAction.TabsEnabledChanged -> copy(
@ -101,9 +104,11 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
if (bookId.isBlank()) {
this
} else {
val currentTabIds = openTabIds.distinct()
val nextTabIds = if (bookId in currentTabIds) currentTabIds else currentTabIds + bookId
copy(
isTabsEnabled = true,
openTabIds = (openTabIds - bookId) + bookId,
openTabIds = nextTabIds,
activeTabBookId = bookId
)
}

View file

@ -47,10 +47,18 @@ data class OpdsAcquisition(
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("cbt", ignoreCase = true) ||
mimeType.contains("comicbook+tar", ignoreCase = true) ||
mimeType.contains("x-tar", ignoreCase = true) ||
mimeType.equals("application/tar", ignoreCase = true) -> "CBT"
mimeType.contains("cbr", ignoreCase = true) ||
mimeType.contains("comicbook-rar", ignoreCase = true) ||
mimeType.contains("rar", ignoreCase = true) -> "CBR"
mimeType.contains("cb7", ignoreCase = true) ||
mimeType.contains("7z", ignoreCase = true) -> "CB7"
mimeType.contains("cbz", ignoreCase = true) ||
mimeType.contains("comicbook+zip", ignoreCase = true) ||
mimeType.contains("comicbook", ignoreCase = true) -> "CBZ"
mimeType.contains("txt", ignoreCase = true) ||
mimeType.contains("text/plain", ignoreCase = true) -> "TXT"
else -> mimeType.substringAfterLast("/").uppercase()
@ -63,7 +71,7 @@ data class OpdsAcquisition(
"PPTX" -> 4
"MOBI" -> 3
"FB2", "MD", "HTML" -> 2
"CBZ", "CBR", "CB7" -> 1
"CBZ", "CBR", "CB7", "CBT" -> 1
"TXT" -> 0
else -> -1
}

View file

@ -1,5 +1,6 @@
package com.aryan.reader.shared.opds
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.SharedFileCapabilities
object SharedOpdsSearch {
@ -18,7 +19,14 @@ object SharedOpdsSearch {
fun expandSearchTemplate(template: String, query: String): String {
val encoded = query.percentEncode()
val expandedSearchTerms = template.replace("{searchTerms}", encoded)
val expandedSearchTerms = template
.replace("{searchTerms}", encoded)
.replace("{count}", DefaultSearchCount)
.replace("{startPage}", DefaultSearchStartPage)
.replace("{startIndex}", DefaultSearchStartIndex)
.replace("{language}", DefaultSearchLanguage)
.replace("{inputEncoding}", DefaultSearchEncoding)
.replace("{outputEncoding}", DefaultSearchEncoding)
if (expandedSearchTerms != template) return expandedSearchTerms
val queryTemplate = Regex("""\{([?&])([^}]+)\}""").find(template)
@ -56,6 +64,12 @@ object SharedOpdsSearch {
contains("{query}") ||
contains("{keyword}")
}
private const val DefaultSearchCount = "12"
private const val DefaultSearchStartPage = "1"
private const val DefaultSearchStartIndex = "1"
private const val DefaultSearchLanguage = "*"
private const val DefaultSearchEncoding = "UTF-8"
}
object SharedOpdsDownloadNamer {
@ -82,6 +96,7 @@ object SharedOpdsDownloadNamer {
"CBZ" -> ".cbz"
"CBR" -> ".cbr"
"CB7" -> ".cb7"
"CBT" -> ".cbt"
"MD" -> ".md"
"HTML" -> ".html"
"TXT" -> ".txt"
@ -125,6 +140,101 @@ object SharedOpdsDownloadNamer {
}
}
object SharedOpdsLocalBookMatcher {
fun findBook(entry: OpdsEntry, books: List<BookItem>): BookItem? {
return find(
entry = entry,
books = books,
title = { it.title },
displayName = { it.displayName },
path = { it.path }
)
}
fun <T> find(
entry: OpdsEntry,
books: List<T>,
title: (T) -> String?,
displayName: (T) -> String?,
path: (T) -> String?
): T? {
val entryKeys = entry.matchKeys()
return books.firstOrNull { book ->
book.matchKeys(title, displayName, path).any { it in entryKeys }
}
}
private fun OpdsEntry.matchKeys(): Set<String> {
return buildSet {
addNormalized(title)
val safeTitle = SharedOpdsDownloadNamer.safeFileStem(title)
addNormalized(safeTitle)
addNormalized(safeTitle.take(50))
acquisitions.forEach { acquisition ->
addFileNameKeys(acquisition.url)
}
}
}
private fun <T> T.matchKeys(
title: (T) -> String?,
displayName: (T) -> String?,
path: (T) -> String?
): Set<String> {
return buildSet {
addNormalized(title(this@matchKeys))
addFileNameKeys(displayName(this@matchKeys))
addFileNameKeys(path(this@matchKeys))
}
}
private fun MutableSet<String>.addFileNameKeys(value: String?) {
val decodedName = value
?.substringBefore('?')
?.substringBefore('#')
?.substringAfterLast('/')
?.substringAfterLast('\\')
?.percentDecode()
?.takeIf { it.isNotBlank() }
?: return
addNormalized(decodedName)
addNormalized(decodedName.withoutKnownExtension())
addNormalized(decodedName.withoutKnownExtension().withoutOpdsDownloadPrefix())
}
private fun MutableSet<String>.addNormalized(value: String?) {
val normalized = value?.normalizedMatchKey() ?: return
if (normalized.isNotBlank()) add(normalized)
}
private fun String.withoutKnownExtension(): String {
val knownSuffix = SharedFileCapabilities.fileExtensionSuffixForName(this)
if (knownSuffix != null && endsWith(knownSuffix, ignoreCase = true)) {
return dropLast(knownSuffix.length)
}
val extension = substringAfterLast('.', missingDelimiterValue = "")
return if (extension.length in 1..8 && extension.all { it.isLetterOrDigit() }) {
substringBeforeLast('.')
} else {
this
}
}
private fun String.normalizedMatchKey(): String {
return percentDecode()
.withoutOpdsDownloadPrefix()
.replace(Regex("""[^\p{L}\p{N}]+"""), " ")
.trim()
.lowercase()
.replace(Regex("""\s+"""), " ")
.removePrefix("opds dl ")
}
private fun String.withoutOpdsDownloadPrefix(): String {
return replace(Regex("""^opds[_\-\s]+dl[_\-\s]+""", RegexOption.IGNORE_CASE), "")
}
}
object SharedOpdsStreamUri {
private const val SCHEME_PREFIX = "opds-pse://stream"

View file

@ -48,6 +48,38 @@ data class SharedPdfAnnotationComment(
val modifiedAt: Long = 0L
)
const val DEFAULT_SHARED_PDF_COMMENT_AUTHOR = "Reader"
fun List<SharedPdfAnnotationComment>.visiblePdfAnnotationComments(): List<SharedPdfAnnotationComment> {
val visibleCommentIds = filter { it.contents.isNotBlank() }.map { it.id }.toSet()
return filter { it.contents.isNotBlank() }
.map { comment ->
if (comment.parentId != null && comment.parentId !in visibleCommentIds) {
comment.copy(parentId = null)
} else {
comment
}
}
}
fun List<SharedPdfAnnotationComment>.pdfCommentChildren(parentId: String?): List<SharedPdfAnnotationComment> {
return filter { it.parentId == parentId }
.sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id }))
}
fun List<SharedPdfAnnotationComment>.withoutPdfCommentThread(commentId: String): List<SharedPdfAnnotationComment> {
val childrenByParentId = groupBy { it.parentId }
val idsToRemove = mutableSetOf<String>()
fun collect(id: String) {
if (!idsToRemove.add(id)) return
childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) }
}
collect(commentId)
return filterNot { it.id in idsToRemove }
}
@Serializable
data class SharedPdfAnnotation(
val id: String,
@ -162,13 +194,7 @@ object SharedPdfAnnotationDefaults {
0xFFFFFFFF.toInt()
)
val highlighterPalette: List<Int> = listOf(
0x8CFF9800.toInt(),
0x8CFFEB3B.toInt(),
0x8C81C784.toInt(),
0x8C64B5F6.toInt(),
0x8CE1BEE7.toInt()
)
val highlighterPalette: List<Int> = SharedPdfAndroidHighlightColors.palette.take(4)
fun configFor(tool: PdfInkTool): PdfToolConfig {
return when (tool) {
@ -176,8 +202,8 @@ object SharedPdfAnnotationDefaults {
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.HIGHLIGHTER -> PdfToolConfig(highlighterPalette[0], 0.035f)
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(highlighterPalette[1], 0.035f)
PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 0.03f)
PdfInkTool.TEXT -> PdfToolConfig(0xFF000000.toInt(), 0.02f)
}
@ -209,9 +235,11 @@ data class SharedPdfHighlighterPalette(
companion object {
const val DefaultAlpha: Int = 0x8C
const val MaxColors: Int = 5
const val MaxColors: Int = 4
val defaultColors: List<Int>
get() = SharedPdfAnnotationDefaults.highlighterPalette.map { it.withPdfHighlighterAlpha() }
get() = SharedPdfAnnotationDefaults.highlighterPalette
.take(MaxColors)
.map { it.withPdfHighlighterAlpha() }
}
}
@ -219,18 +247,21 @@ object SharedPdfAndroidHighlightColors {
const val StoredAlpha: Int = 0x8C
const val RenderAlpha: Float = 0.4f
val orderedNames: List<String> = listOf("ORANGE", "YELLOW", "GREEN", "BLUE", "PURPLE")
val colorsByName: Map<String, Int> = mapOf(
"YELLOW" to 0xFFFBC02D.toInt(),
"GREEN" to 0xFF388E3C.toInt(),
"BLUE" to 0xFF1976D2.toInt(),
"RED" to 0xFFD32F2F.toInt()
"ORANGE" to 0xFFFF9800.toInt(),
"YELLOW" to 0xFFFFEB3B.toInt(),
"GREEN" to 0xFF81C784.toInt(),
"BLUE" to 0xFF64B5F6.toInt(),
"PURPLE" to 0xFFE1BEE7.toInt()
)
val palette: List<Int>
get() = colorsByName.keys.map(::argbForName)
get() = orderedNames.map(::argbForName)
fun argbForName(name: String): Int {
val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("YELLOW")
val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("ORANGE")
return (StoredAlpha shl 24) or (opaqueArgb and 0x00FFFFFF)
}
@ -242,7 +273,7 @@ object SharedPdfAndroidHighlightColors {
val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF)
val db = (rgb and 0xFF) - (candidate and 0xFF)
dr * dr + dg * dg + db * db
}?.key ?: "YELLOW"
}?.key ?: "ORANGE"
}
fun nearestArgb(argb: Int): Int {

View file

@ -186,12 +186,20 @@ data class SharedPdfReaderState(
val isTextSelectionMode: Boolean = false,
val bookmarks: List<SharedPdfBookmark> = emptyList(),
val selectedAnnotationId: String? = null,
val annotations: List<SharedPdfAnnotation> = emptyList()
val annotations: List<SharedPdfAnnotation> = emptyList(),
val toolConfigs: Map<PdfInkTool, PdfToolConfig> = emptyMap(),
val penPalette: List<Int> = SharedPdfAnnotationDefaults.penPalette,
val lastActivePenTool: PdfInkTool = PdfInkTool.PEN,
val lastActiveHighlighterTool: PdfInkTool = PdfInkTool.HIGHLIGHTER,
val annotationUndoStack: List<SharedPdfAnnotationHistoryAction> = emptyList(),
val annotationRedoStack: List<SharedPdfAnnotationHistoryAction> = 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 canUndoAnnotationEdit: Boolean get() = annotationUndoStack.isNotEmpty()
val canRedoAnnotationEdit: Boolean get() = annotationRedoStack.isNotEmpty()
val progressPercent: Float get() = ((pageIndex + 1).toFloat() / safePageCount.coerceAtLeast(1)) * 100f
fun coerced(zoomSpec: PdfZoomSpec = PdfZoomSpec()): SharedPdfReaderState {
@ -202,6 +210,10 @@ data class SharedPdfReaderState(
activeSearchResultIndex = activeSearchResultIndex.coerceAtLeast(-1),
zoom = zoomSpec.clamp(zoom),
bookmarks = bookmarks.normalizedBookmarks(lastPageIndex),
penPalette = penPalette.sanitizedSharedPdfPenPalette(),
lastActivePenTool = lastActivePenTool.takeIf { it.isSharedPdfPenTool } ?: PdfInkTool.PEN,
lastActiveHighlighterTool = lastActiveHighlighterTool.takeIf { it.isSharedPdfHighlighterTool }
?: PdfInkTool.HIGHLIGHTER,
selectedAnnotationId = selectedAnnotationId?.takeIf { selectedId ->
annotations.any { it.id == selectedId }
}
@ -225,6 +237,11 @@ data class SharedPdfReaderState(
}
}
sealed interface SharedPdfAnnotationHistoryAction {
data class Add(val pageIndex: Int, val annotation: SharedPdfAnnotation) : SharedPdfAnnotationHistoryAction
data class Remove(val itemsByPage: Map<Int, List<SharedPdfAnnotation>>) : SharedPdfAnnotationHistoryAction
}
sealed interface SharedPdfReaderAction {
data class GoToPage(val pageIndex: Int) : SharedPdfReaderAction
data object PreviousPage : SharedPdfReaderAction
@ -248,6 +265,7 @@ sealed interface SharedPdfReaderAction {
data class ToolSelected(val tool: PdfInkTool) : SharedPdfReaderAction
data class ColorSelected(val colorArgb: Int) : SharedPdfReaderAction
data class StrokeWidthChanged(val strokeWidth: Float) : SharedPdfReaderAction
data class PenPaletteChanged(val colors: List<Int>) : SharedPdfReaderAction
data class TextSelectionModeChanged(val enabled: Boolean) : SharedPdfReaderAction
data class BookmarksLoaded(val bookmarks: List<SharedPdfBookmark>) : SharedPdfReaderAction
data class BookmarkToggled(
@ -262,6 +280,8 @@ sealed interface SharedPdfReaderAction {
data class AnnotationDeleted(val annotationId: String) : SharedPdfReaderAction
data class AnnotationsChanged(val annotations: List<SharedPdfAnnotation>) : SharedPdfReaderAction
data class UndoLastAnnotationOnPage(val pageIndex: Int) : SharedPdfReaderAction
data object UndoAnnotationEdit : SharedPdfReaderAction
data object RedoAnnotationEdit : SharedPdfReaderAction
data class ClearPageAnnotations(val pageIndex: Int) : SharedPdfReaderAction
}
@ -327,16 +347,23 @@ fun SharedPdfReaderState.reduce(
}
}
is SharedPdfReaderAction.ToolSelected -> {
val config = SharedPdfAnnotationDefaults.configFor(action.tool)
val config = toolConfigFor(action.tool)
copy(
selectedTool = action.tool,
selectedColorArgb = config.colorArgb,
strokeWidth = config.strokeWidth,
isTextSelectionMode = false
isTextSelectionMode = false,
lastActivePenTool = if (action.tool.isSharedPdfPenTool) action.tool else lastActivePenTool,
lastActiveHighlighterTool = if (action.tool.isSharedPdfHighlighterTool) {
action.tool
} else {
lastActiveHighlighterTool
}
)
}
is SharedPdfReaderAction.ColorSelected -> copy(selectedColorArgb = action.colorArgb)
is SharedPdfReaderAction.StrokeWidthChanged -> copy(strokeWidth = action.strokeWidth.coerceAtLeast(0.0001f))
is SharedPdfReaderAction.ColorSelected -> withActiveToolColor(action.colorArgb)
is SharedPdfReaderAction.StrokeWidthChanged -> withActiveToolStrokeWidth(action.strokeWidth.coerceAtLeast(0.0001f))
is SharedPdfReaderAction.PenPaletteChanged -> copy(penPalette = action.colors.sanitizedSharedPdfPenPalette())
is SharedPdfReaderAction.TextSelectionModeChanged -> {
if (action.enabled) {
val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE)
@ -365,10 +392,19 @@ fun SharedPdfReaderState.reduce(
}
copy(bookmarks = nextBookmarks.normalizedBookmarks(lastPageIndex))
}
is SharedPdfReaderAction.AnnotationsLoaded -> copy(annotations = action.annotations.toList())
is SharedPdfReaderAction.AnnotationsLoaded -> copy(
annotations = action.annotations.toList(),
annotationUndoStack = emptyList(),
annotationRedoStack = emptyList()
)
is SharedPdfReaderAction.AnnotationAdded -> copy(
annotations = annotations + action.annotation,
selectedAnnotationId = action.annotation.id
selectedAnnotationId = action.annotation.id,
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Add(
pageIndex = action.annotation.pageIndex,
annotation = action.annotation
),
annotationRedoStack = emptyList()
)
is SharedPdfReaderAction.AnnotationSelected -> copy(
selectedAnnotationId = action.annotationId?.takeIf { id -> annotations.any { it.id == id } }
@ -378,36 +414,158 @@ fun SharedPdfReaderState.reduce(
if (index < 0) {
this
} else {
copy(annotations = annotations.toMutableList().also { it[index] = action.annotation })
copy(
annotations = annotations.toMutableList().also { it[index] = action.annotation },
annotationRedoStack = emptyList()
)
}
}
is SharedPdfReaderAction.AnnotationDeleted -> copy(
annotations = annotations.filterNot { it.id == action.annotationId },
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId }
is SharedPdfReaderAction.AnnotationDeleted -> {
val removed = annotations.firstOrNull { it.id == action.annotationId }
if (removed == null) {
this
} else {
copy(
annotations = annotations.filterNot { it.id == action.annotationId },
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId },
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
itemsByPage = mapOf(removed.pageIndex to listOf(removed))
),
annotationRedoStack = emptyList()
)
}
}
is SharedPdfReaderAction.AnnotationsChanged -> copy(
annotations = action.annotations.toList(),
annotationUndoStack = emptyList(),
annotationRedoStack = emptyList()
)
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 removed = annotations[index]
val removedId = annotations[index].id
copy(
annotations = annotations.toMutableList().also { it.removeAt(index) },
selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId }
selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId },
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
itemsByPage = mapOf(removed.pageIndex to listOf(removed))
),
annotationRedoStack = emptyList()
)
}
}
SharedPdfReaderAction.UndoAnnotationEdit -> undoSharedPdfAnnotationEdit()
SharedPdfReaderAction.RedoAnnotationEdit -> redoSharedPdfAnnotationEdit()
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 }
)
val removed = annotations.filter { it.pageIndex == action.pageIndex }
if (removed.isEmpty()) {
this
} else {
val removedIds = removed.mapTo(mutableSetOf()) { it.id }
copy(
annotations = annotations.filterNot { it.pageIndex == action.pageIndex },
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds },
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
itemsByPage = mapOf(action.pageIndex to removed)
),
annotationRedoStack = emptyList()
)
}
}
}.coerced(zoomSpec)
}
private fun SharedPdfReaderState.toolConfigFor(tool: PdfInkTool): PdfToolConfig {
return toolConfigs[tool] ?: SharedPdfAnnotationDefaults.configFor(tool)
}
private fun SharedPdfReaderState.withActiveToolColor(colorArgb: Int): SharedPdfReaderState {
if (!selectedTool.isSharedPdfConfigurableTool) {
return copy(selectedColorArgb = colorArgb)
}
val currentConfig = toolConfigFor(selectedTool)
return copy(
selectedColorArgb = colorArgb,
toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(colorArgb = colorArgb))
)
}
private fun SharedPdfReaderState.withActiveToolStrokeWidth(strokeWidth: Float): SharedPdfReaderState {
if (!selectedTool.isSharedPdfConfigurableTool) {
return copy(strokeWidth = strokeWidth)
}
val currentConfig = toolConfigFor(selectedTool)
return copy(
strokeWidth = strokeWidth,
toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(strokeWidth = strokeWidth))
)
}
private fun List<Int>.sanitizedSharedPdfPenPalette(): List<Int> {
val defaults = SharedPdfAnnotationDefaults.penPalette
val normalized = filter { it != 0 }.take(defaults.size)
val filled = if (normalized.isEmpty()) {
defaults
} else {
normalized + defaults.drop(normalized.size)
}
return filled.take(defaults.size)
}
private val PdfInkTool.isSharedPdfPenTool: Boolean
get() = this == PdfInkTool.FOUNTAIN_PEN || this == PdfInkTool.PEN || this == PdfInkTool.PENCIL
private val PdfInkTool.isSharedPdfHighlighterTool: Boolean
get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND
private val PdfInkTool.isSharedPdfConfigurableTool: Boolean
get() = this != PdfInkTool.NONE
private fun SharedPdfReaderState.undoSharedPdfAnnotationEdit(): SharedPdfReaderState {
val action = annotationUndoStack.lastOrNull() ?: return this
val nextUndoStack = annotationUndoStack.dropLast(1)
return when (action) {
is SharedPdfAnnotationHistoryAction.Add -> copy(
annotations = annotations.filterNot { it.id == action.annotation.id },
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotation.id },
annotationUndoStack = nextUndoStack,
annotationRedoStack = annotationRedoStack + action
)
is SharedPdfAnnotationHistoryAction.Remove -> copy(
annotations = annotations + action.itemsByPage.values.flatten(),
annotationUndoStack = nextUndoStack,
annotationRedoStack = annotationRedoStack + action
)
}
}
private fun SharedPdfReaderState.redoSharedPdfAnnotationEdit(): SharedPdfReaderState {
val action = annotationRedoStack.lastOrNull() ?: return this
val nextRedoStack = annotationRedoStack.dropLast(1)
return when (action) {
is SharedPdfAnnotationHistoryAction.Add -> copy(
annotations = annotations + action.annotation,
selectedAnnotationId = action.annotation.id,
annotationUndoStack = annotationUndoStack + action,
annotationRedoStack = nextRedoStack
)
is SharedPdfAnnotationHistoryAction.Remove -> {
val removedIds = action.itemsByPage.values.flatten().mapTo(mutableSetOf()) { it.id }
copy(
annotations = annotations.filterNot { it.id in removedIds },
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds },
annotationUndoStack = annotationUndoStack + action,
annotationRedoStack = nextRedoStack
)
}
}
}
object SharedPdfSearchEngine {
fun search(
pageTexts: List<String>,

View file

@ -59,7 +59,9 @@ object PdfSelectionGeometry {
chars: List<PdfTextCharBounds>,
lineTolerance: Float = DefaultCharLineTolerance
): List<PdfPageBounds> {
return chars.groupByLine(lineTolerance).map { it.toCharLineBounds() }
return mergeBoundsByLine(
bounds = chars.groupByLine(lineTolerance).map { it.toCharLineBounds() }
)
}
fun nearestCharOnLine(

View file

@ -36,6 +36,15 @@ object PdfSpreadLayout {
return listOf(start, start + 1).filter { it in 0 until pageCount }
}
fun visiblePageIndicesForDisplay(
pageIndex: Int,
pageCount: Int,
settings: ReaderSettings
): List<Int> {
val indices = visiblePageIndices(pageIndex, pageCount, settings)
return if (settings.rightToLeftPagination) indices.asReversed() else indices
}
fun spreadStartPageIndices(
pageCount: Int,
settings: ReaderSettings

View file

@ -2,8 +2,6 @@ package com.aryan.reader.shared.pdf
import kotlin.math.sqrt
private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader"
data class SharedPdfAnnotationExportPayload(
val inkAnnotations: List<SharedPdfInkAnnotationExport> = emptyList(),
val highlightAnnotations: List<SharedPdfHighlightAnnotationExport> = emptyList()
@ -208,7 +206,7 @@ private fun List<SharedPdfHighlightCommentExport>.toSingleVisiblePdfCommentThrea
SharedPdfHighlightCommentExport(
id = "${highlightId}_comments",
parentId = null,
author = root.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR },
author = root.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR },
contents = threadContents,
createdAt = createdAt,
modifiedAt = modifiedAt
@ -228,7 +226,7 @@ private fun List<SharedPdfHighlightCommentExport>.formatAsPdfCommentThread(): St
if (lines.isNotEmpty()) lines += ""
val indent = " ".repeat(depth)
val author = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
val author = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }
lines += "$indent$author:"
comment.contents.lines().forEach { line ->
lines += "$indent$line"

View file

@ -19,6 +19,7 @@ import kotlinx.serialization.json.longOrNull
object SharedPdfAnnotationSidecarCodec {
const val KEY_PDF_ANNOTATIONS = "pdfAnnotations"
const val KEY_PDF_ANNOTATION_DELETIONS = "pdfAnnotationDeletions"
const val KEY_LEGACY_INK = "ink"
const val KEY_LEGACY_TEXT_BOXES = "textBoxes"
const val KEY_LEGACY_HIGHLIGHTS = "highlights"
@ -38,16 +39,17 @@ object SharedPdfAnnotationSidecarCodec {
}
fun annotationsFromData(data: JsonObject): List<SharedPdfAnnotation> {
data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it) }
val deletedIds = annotationDeletionsFromData(data).keys
data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it).filterNot { annotation -> annotation.id in deletedIds } }
data[KEY_LEGACY_INK]?.let { ink ->
val decoded = decodeAnnotationsElement(ink)
if (decoded.isNotEmpty() || ink.looksLikeSharedAnnotationStore()) {
return decoded
return decoded.filterNot { annotation -> annotation.id in deletedIds }
}
}
return legacyAndroidAnnotationsFromData(data)
return legacyAndroidAnnotationsFromData(data).filterNot { annotation -> annotation.id in deletedIds }
}
fun withCanonicalAnnotations(data: JsonObject): JsonObject {
@ -62,6 +64,80 @@ object SharedPdfAnnotationSidecarCodec {
return json.encodeToString(JsonElement.serializer(), withCanonicalAnnotations(data))
}
fun mergeAnnotationDataJson(
localDataJson: String,
remoteDataJson: String,
preferRemoteOnConflict: Boolean
): String {
val localData = parseObjectOrNull(localDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap())
val remoteData = parseObjectOrNull(remoteDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap())
val localCanonical = withCanonicalAnnotations(localData)
val remoteCanonical = withCanonicalAnnotations(remoteData)
val localAnnotations = annotationsFromData(localCanonical)
val remoteAnnotations = annotationsFromData(remoteCanonical)
val mergedDeletions = mergeAnnotationDeletions(
annotationDeletionsFromData(localCanonical),
annotationDeletionsFromData(remoteCanonical)
)
val mergedById = linkedMapOf<String, SharedPdfAnnotation>()
val first = if (preferRemoteOnConflict) localAnnotations else remoteAnnotations
val second = if (preferRemoteOnConflict) remoteAnnotations else localAnnotations
first.forEach { annotation ->
if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation
}
second.forEach { annotation ->
if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation
}
val base = (if (preferRemoteOnConflict) remoteCanonical else localCanonical).toMutableMap()
base[KEY_PDF_ANNOTATIONS] = encodeAnnotationsElement(mergedById.values.toList().sortedForSync())
if (mergedDeletions.isNotEmpty()) {
base[KEY_PDF_ANNOTATION_DELETIONS] = encodeAnnotationDeletionsElement(mergedDeletions)
} else {
base.remove(KEY_PDF_ANNOTATION_DELETIONS)
}
return json.encodeToString(JsonElement.serializer(), JsonObject(base))
}
fun annotationCountFromDataJson(rawDataJson: String): Int {
val data = parseObjectOrNull(rawDataJson)?.sidecarDataObject() ?: return 0
return annotationsFromData(withCanonicalAnnotations(data)).size
}
fun annotationDeletionsFromData(data: JsonObject): Map<String, Long> {
return data[KEY_PDF_ANNOTATION_DELETIONS].parseAnnotationDeletions()
}
fun annotationDeletionsFromJson(rawJson: String): Map<String, Long> {
val element = runCatching { json.parseToJsonElement(rawJson) }.getOrNull() ?: return emptyMap()
return when (element) {
is JsonObject -> {
val data = element.sidecarDataObject()
annotationDeletionsFromData(data).ifEmpty { element.parseAnnotationDeletions() }
}
else -> element.parseAnnotationDeletions()
}
}
fun annotationDeletionsJson(deletions: Map<String, Long>): String {
return json.encodeToString(JsonElement.serializer(), encodeAnnotationDeletionsElement(deletions))
}
fun encodeAnnotationDeletionsElement(deletions: Map<String, Long>): JsonElement {
return JsonArray(
deletions
.filterKeys { it.isNotBlank() }
.toSortedMap()
.map { (id, deletedAt) ->
JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"deletedAt" to JsonPrimitive(deletedAt)
)
)
}
)
}
fun legacyAndroidDataFromAnnotations(
annotations: List<SharedPdfAnnotation>,
existingData: JsonObject = JsonObject(emptyMap())
@ -283,6 +359,47 @@ object SharedPdfAnnotationSidecarCodec {
return runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull()
}
private fun List<SharedPdfAnnotation>.sortedForSync(): List<SharedPdfAnnotation> {
return sortedWith(
compareBy<SharedPdfAnnotation> { it.pageIndex }
.thenBy { it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }
.thenBy { it.id }
)
}
private fun mergeAnnotationDeletions(
local: Map<String, Long>,
remote: Map<String, Long>
): Map<String, Long> {
if (local.isEmpty()) return remote
if (remote.isEmpty()) return local
return buildMap {
(local.keys + remote.keys).forEach { id ->
put(id, maxOf(local[id] ?: 0L, remote[id] ?: 0L))
}
}
}
private fun JsonElement?.parseAnnotationDeletions(): Map<String, Long> {
val element = this ?: return emptyMap()
val array = element.jsonArrayOrNull()
?: element.jsonObjectOrNull()?.array(KEY_PDF_ANNOTATION_DELETIONS)
?: return emptyMap()
return buildMap {
array.forEach { item ->
val primitiveId = item.jsonPrimitiveOrNull()?.contentOrNull
val obj = item.jsonObjectOrNull()
val id = primitiveId?.takeIf { it.isNotBlank() } ?: obj?.string("id")
if (id.isNullOrBlank()) return@forEach
val deletedAt = obj?.long("deletedAt")
?: obj?.long("timestamp")
?: obj?.long("ts")
?: 0L
put(id, maxOf(this[id] ?: 0L, deletedAt))
}
}
}
private fun stableAnnotationId(prefix: String, element: JsonElement): String {
return "${prefix}_${localFolderSyncSha256ShortHex(json.encodeToString(JsonElement.serializer(), element))}"
}
@ -314,6 +431,10 @@ object SharedPdfAnnotationSidecarCodec {
this[KEY_LEGACY_HIGHLIGHTS] != null
}
private fun JsonObject.sidecarDataObject(): JsonObject {
return this["data"]?.jsonObjectOrNull() ?: this
}
private fun JsonElement.jsonArrayOrNull(): JsonArray? {
if (this is JsonNull) return null
return runCatching { jsonArray }.getOrNull()
@ -324,6 +445,11 @@ object SharedPdfAnnotationSidecarCodec {
return runCatching { jsonObject }.getOrNull()
}
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? {
if (this is JsonNull) return null
return runCatching { jsonPrimitive }.getOrNull()
}
private fun JsonObject.array(name: String): JsonArray? = this[name]?.jsonArrayOrNull()
private fun JsonObject.objectValue(name: String): JsonObject? = this[name]?.jsonObjectOrNull()

View file

@ -52,6 +52,17 @@ const val SHARED_PDF_PAGE_BREAK_CHAR: Char = '\u000C'
private const val SHARED_PDF_ZWSP = "\u200B"
private const val SHARED_PDF_RICH_FONT_PATH_TAG = "pdf-rich-font-path"
internal fun sharedPdfRichTextSelectionBounds(
selectionStart: Int,
selectionEnd: Int,
textLength: Int
): Pair<Int, Int>? {
val safeLength = textLength.coerceAtLeast(0)
val localStart = minOf(selectionStart, selectionEnd).coerceIn(0, safeLength)
val localEnd = maxOf(selectionStart, selectionEnd).coerceIn(0, safeLength)
return if (localStart < localEnd) localStart to localEnd else null
}
object SharedPdfRichTextLog {
var enabled: Boolean = true

View file

@ -2,12 +2,16 @@ package com.aryan.reader.shared.reader
import com.aryan.reader.paginatedreader.SemanticBlock
import com.aryan.reader.paginatedreader.SemanticFlexContainer
import com.aryan.reader.paginatedreader.SemanticImage
import com.aryan.reader.paginatedreader.SemanticList
import com.aryan.reader.paginatedreader.SemanticMath
import com.aryan.reader.paginatedreader.SemanticSpacer
import com.aryan.reader.paginatedreader.SemanticTable
import com.aryan.reader.paginatedreader.SemanticTextBlock
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
import com.aryan.reader.shared.HighlightColor
import com.aryan.reader.shared.UserHighlight
import com.aryan.reader.shared.toStableReaderPositionCfi
sealed interface ReaderLinkTarget {
data class External(val url: String) : ReaderLinkTarget
@ -103,10 +107,10 @@ class ReaderEngine(
highlights: List<UserHighlight> = emptyList()
): ReaderSessionState {
val pages = pagesFor(book, settings)
val requestedInitialIndex = initialLocator
val locatorResolvedIndex = initialLocator
?.let { pages.findPageIndexForLocator(it) }
?.takeIf { it >= 0 }
?: initialPageIndex
val requestedInitialIndex = locatorResolvedIndex ?: initialPageIndex
val initialIndex = ReaderSpreadLayout.normalizePageIndex(requestedInitialIndex, pages.size, settings)
val reader = PaginatedReaderState(
book = book,
@ -114,7 +118,13 @@ class ReaderEngine(
currentPageIndex = initialIndex,
settings = settings
)
return ReaderSessionState(
logReaderPositionTrace {
"event=engine_create_session_start book=\"${book.title.positionTracePreview(120)}\" " +
"mode=${settings.readingMode} pages=${pages.size} initialPage=$initialPageIndex " +
"locatorResolved=${locatorResolvedIndex ?: "null"} requested=$requestedInitialIndex normalized=$initialIndex " +
"initialLocator=${initialLocator.positionTraceSummary()}"
}
val session = ReaderSessionState(
reader = reader,
bookmarks = bookmarks
.mapNotNull { it.normalizedForBook(book, pages) }
@ -128,6 +138,13 @@ class ReaderEngine(
?.normalizedForResolvedPage(book, pages, requestedInitialIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)))
?: reader.currentPage?.toLocator(book)
)
logReaderPositionTrace {
"event=engine_create_session_done book=\"${book.title.positionTracePreview(120)}\" " +
"mode=${settings.readingMode} currentPage=${session.reader.currentPageIndex} " +
"visiblePages=${session.reader.visiblePages.map { it.pageIndex }} " +
"navigationLocator=${session.navigationLocator.positionTraceSummary()}"
}
return session
}
fun next(state: ReaderSessionState): ReaderSessionState {
@ -149,10 +166,17 @@ class ReaderEngine(
fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings)
val page = state.reader.pages.getOrNull(target)
val locator = page?.let {
if (state.reader.settings.readingMode == ReaderReadingMode.VERTICAL) {
it.toVerticalScrollPageLocator(state.reader.book)
} else {
it.toLocator(state.reader.book)
}
}
return state.copy(
reader = state.reader.copy(currentPageIndex = target),
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target },
navigationLocator = page?.toLocator(state.reader.book),
navigationLocator = locator,
navigationRequestId = state.navigationRequestId + 1
)
}
@ -173,15 +197,14 @@ class ReaderEngine(
}
fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState {
val requestedPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
val requestedPageIndex = state.reader.pages.findPageIndexForLocator(locator)
.takeIf { it >= 0 }
?: locator.pageIndex
?.takeIf { it in state.reader.pages.indices }
?: return state
val pageIndex = ReaderSpreadLayout.normalizePageIndex(requestedPageIndex, state.reader.pages.size, state.reader.settings)
val page = state.reader.pages.getOrNull(pageIndex) ?: return state
val requestedPage = state.reader.pages.getOrNull(requestedPageIndex) ?: page
val requestedChapter = state.reader.book.chapters.getOrNull(requestedPage.chapterIndex)
val blockPosition = requestedPage.firstLocatorBlockPosition()
val normalizedLocator = locator.copy(pageIndex = requestedPageIndex).withFallbacks(
chapterIndex = requestedPage.chapterIndex,
chapterId = requestedChapter?.id,
@ -189,6 +212,8 @@ class ReaderEngine(
pageIndex = requestedPageIndex,
startOffset = requestedPage.startOffset,
endOffset = requestedPage.endOffset,
blockIndex = blockPosition?.blockIndex,
charOffset = blockPosition?.charOffset,
textQuote = locator.textQuote ?: requestedPage.text.preview(),
cfi = locator.cfi ?: requestedPage.toDesktopCfi()
)
@ -345,12 +370,26 @@ class ReaderEngine(
fun syncVisiblePage(state: ReaderSessionState, pageIndex: Int, locator: ReaderLocator? = null): ReaderSessionState {
val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings)
val normalizedLocator = locator?.normalizedForPage(state, pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)))
if (target == state.reader.currentPageIndex && normalizedLocator == null) return state
return state.copy(
if (target == state.reader.currentPageIndex && normalizedLocator == null) {
logReaderPositionTrace {
"event=engine_sync_visible_skip reason=unchanged_no_locator mode=${state.reader.settings.readingMode} " +
"inputPage=$pageIndex target=$target current=${state.reader.currentPageIndex}"
}
return state
}
val next = state.copy(
reader = state.reader.copy(currentPageIndex = target),
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex },
navigationLocator = normalizedLocator ?: state.navigationLocator
)
logReaderPositionTrace {
"event=engine_sync_visible_done mode=${state.reader.settings.readingMode} inputPage=$pageIndex " +
"target=$target previousPage=${state.reader.currentPageIndex} nextPage=${next.reader.currentPageIndex} " +
"inputLocator=${locator.positionTraceSummary()} normalizedLocator=${normalizedLocator.positionTraceSummary()} " +
"previousNavigation=${state.navigationLocator.positionTraceSummary()} " +
"nextNavigation=${next.navigationLocator.positionTraceSummary()}"
}
return next
}
fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState {
@ -509,13 +548,12 @@ class ReaderEngine(
chapterTitle: String? = null,
preview: String? = null
): ReaderSessionState {
val targetPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
val targetPageIndex = state.reader.pages.findPageIndexForLocator(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 blockPosition = page.firstLocatorBlockPosition()
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
chapterIndex = page.chapterIndex,
chapterId = chapter?.id,
@ -523,8 +561,12 @@ class ReaderEngine(
pageIndex = targetPageIndex,
startOffset = page.startOffset,
endOffset = page.endOffset,
blockIndex = blockPosition?.blockIndex,
charOffset = blockPosition?.charOffset,
textQuote = preview ?: page.text.preview(),
cfi = locator.cfi ?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}"
cfi = locator.cfi
?: blockPosition?.androidStyleCfi()
?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}"
)
val existing = state.bookmarks.firstOrNull {
it.locator.sameLocation(normalizedLocator) ||
@ -679,12 +721,13 @@ class ReaderEngine(
if (state.searchResults.isEmpty()) return state
val targetIndex = resultIndex.coerceIn(0, state.searchResults.lastIndex)
val result = state.searchResults[targetIndex]
val requestedPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) }
val requestedPage = state.reader.pages.findPageIndexForLocator(result.locator)
.takeIf { it >= 0 }
?: result.pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
val targetPage = ReaderSpreadLayout.normalizePageIndex(requestedPage, state.reader.pages.size, state.reader.settings)
val page = state.reader.pages.getOrNull(targetPage)
val chapter = page?.let { state.reader.book.chapters.getOrNull(it.chapterIndex) }
val blockPosition = page?.firstLocatorBlockPosition()
return state.copy(
reader = state.reader.copy(currentPageIndex = targetPage),
activeSearchResultIndex = targetIndex,
@ -692,7 +735,9 @@ class ReaderEngine(
chapterIndex = page?.chapterIndex,
chapterId = chapter?.id,
href = chapter?.baseHref,
pageIndex = requestedPage
pageIndex = requestedPage,
blockIndex = blockPosition?.blockIndex,
charOffset = blockPosition?.charOffset
),
navigationRequestId = state.navigationRequestId + 1
)
@ -731,7 +776,7 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
val start = locator.startOffset ?: return false
val end = locator.endOffset ?: start
return if (start == end) {
start in startOffset..endOffset
containsCollapsedOffset(start)
} else {
start < endOffset && end > startOffset
}
@ -740,11 +785,128 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
return targetPage != null && targetPage == pageIndex
}
private fun ReaderPage.containsCollapsedOffset(offset: Int): Boolean {
return if (startOffset == endOffset) {
offset == startOffset
} else {
offset >= startOffset && offset < endOffset
}
}
private fun List<ReaderPage>.findPageIndexForLocator(locator: ReaderLocator): Int {
return indexOfFirst { page -> page.contains(locator) }
.takeIf { it >= 0 }
?: locator.pageIndex?.takeIf { it in indices }
?: -1
if (locator.blockIndex != null) {
val blockIndex = findPageIndexForBlockLocator(locator)
if (blockIndex >= 0) return blockIndex
}
if (locator.hasTextRange) {
val textRangeIndex = indexOfFirst { page -> page.containsTextRange(locator) }
if (textRangeIndex >= 0) return textRangeIndex
if (locator.startOffset == locator.endOffset) {
val offset = locator.startOffset
val targetChapter = locator.chapterIndex
val finalBoundaryIndex = indexOfLast { page ->
(targetChapter == null || targetChapter == page.chapterIndex) &&
page.startOffset < page.endOffset &&
page.endOffset == offset
}
if (finalBoundaryIndex >= 0) return finalBoundaryIndex
}
}
return locator.pageIndex?.takeIf { it in indices } ?: -1
}
private fun ReaderPage.containsTextRange(locator: ReaderLocator): Boolean {
val targetChapter = locator.chapterIndex
if (targetChapter != null && targetChapter != chapterIndex) return false
val start = locator.startOffset ?: return false
val end = locator.endOffset ?: start
return if (start == end) {
containsCollapsedOffset(start)
} else {
start < endOffset && end > startOffset
}
}
private fun List<ReaderPage>.findPageIndexForBlockLocator(locator: ReaderLocator): Int {
val blockIndex = locator.blockIndex ?: return -1
val charOffset = locator.charOffset
val targetChapter = locator.chapterIndex
var fallbackPageIndex = -1
for ((pageIndex, page) in withIndex()) {
if (targetChapter != null && page.chapterIndex != targetChapter) continue
val blocks = page.semanticBlocks.flattenSemanticBlocks()
if (fallbackPageIndex < 0 && blocks.any { it.blockIndex == blockIndex }) {
fallbackPageIndex = pageIndex
}
if (charOffset == null) continue
for (block in blocks.filterIsInstance<SemanticTextBlock>()) {
if (block.blockIndex != blockIndex) continue
val start = block.startCharOffsetInSource
val end = start + block.text.length
if (charOffset in start until end || (block.text.isEmpty() && charOffset == start)) {
return pageIndex
}
}
}
return fallbackPageIndex
}
private data class ReaderBlockPosition(
val blockIndex: Int,
val charOffset: Int,
val cfi: String? = null,
val localCharOffset: Int = 0
) {
fun androidStyleCfi(): String? {
val base = cfi
?.takeIf { it.startsWith("/") }
?.substringBefore(':')
?: return null
return "$base:${localCharOffset.coerceAtLeast(0)}"
}
}
private fun ReaderPage.firstLocatorBlockPosition(): ReaderBlockPosition? {
val blocks = semanticBlocks.flattenSemanticBlocks()
val textBlock = blocks
.filterIsInstance<SemanticTextBlock>()
.firstOrNull { it.text.isNotBlank() }
?: blocks.filterIsInstance<SemanticTextBlock>().firstOrNull()
if (textBlock != null) {
return ReaderBlockPosition(
blockIndex = textBlock.blockIndex,
charOffset = textBlock.startCharOffsetInSource,
cfi = textBlock.cfi,
localCharOffset = 0
)
}
val firstBlock = blocks.firstOrNull() ?: return null
return ReaderBlockPosition(
blockIndex = firstBlock.blockIndex,
charOffset = 0,
cfi = firstBlock.cfi,
localCharOffset = 0
)
}
private fun List<SemanticBlock>.flattenSemanticBlocks(): List<SemanticBlock> {
return flatMap { it.flattenSemanticBlock() }
}
private fun SemanticBlock.flattenSemanticBlock(): List<SemanticBlock> {
return when (this) {
is SemanticList -> listOf(this) + items
is SemanticTable -> listOf(this) + rows.flatMap { row -> row.flatMap { cell -> cell.content.flattenSemanticBlocks() } }
is SemanticFlexContainer -> listOf(this) + children.flattenSemanticBlocks()
is SemanticWrappingBlock -> listOf(this, floatedImage) + paragraphsToWrap
is SemanticImage,
is SemanticMath,
is SemanticSpacer,
is SemanticTextBlock -> listOf(this)
}
}
private fun ReaderLocator.normalizedForResolvedPage(
@ -756,6 +918,7 @@ private fun ReaderLocator.normalizedForResolvedPage(
val chapter = book.chapters.getOrNull(page.chapterIndex)
val start = startOffset ?: page.startOffset
val end = (endOffset ?: start).coerceAtLeast(start)
val blockPosition = page.firstLocatorBlockPosition()
return copy(pageIndex = page.pageIndex).withFallbacks(
chapterIndex = page.chapterIndex,
chapterId = chapter?.id,
@ -763,18 +926,25 @@ private fun ReaderLocator.normalizedForResolvedPage(
pageIndex = page.pageIndex,
startOffset = start,
endOffset = end,
blockIndex = blockPosition?.blockIndex,
charOffset = blockPosition?.charOffset,
textQuote = textQuote ?: page.text.preview(),
cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end"
cfi = cfi
?.toStableReaderPositionCfi()
?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") }
?: blockPosition?.androidStyleCfi()
?: "desktop:${page.chapterIndex}:$start:$end"
)
}
private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List<ReaderPage>): ReaderBookmark? {
val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) }
val targetPageIndex = pages.findPageIndexForLocator(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 blockPosition = page.firstLocatorBlockPosition()
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
chapterIndex = page.chapterIndex,
chapterId = chapter?.id,
@ -782,8 +952,10 @@ private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List<R
pageIndex = targetPageIndex,
startOffset = page.startOffset,
endOffset = page.endOffset,
blockIndex = blockPosition?.blockIndex,
charOffset = blockPosition?.charOffset,
textQuote = preview.ifBlank { page.text.preview() },
cfi = locator.cfi ?: page.toDesktopCfi()
cfi = locator.cfi ?: blockPosition?.androidStyleCfi() ?: page.toDesktopCfi()
)
return copy(
pageIndex = targetPageIndex,
@ -800,6 +972,8 @@ private fun ReaderBookmark.locationKey(): String {
locator.pageIndex,
locator.startOffset,
locator.endOffset,
locator.blockIndex,
locator.charOffset,
locator.cfi
).joinToString(":")
}
@ -808,7 +982,9 @@ private fun bookmarkId(bookId: String, pageIndex: Int, locator: ReaderLocator):
val chapter = locator.chapterIndex ?: -1
val start = locator.startOffset ?: -1
val end = locator.endOffset ?: start
return "${bookId}_${pageIndex}_${chapter}_${start}_${end}"
val block = locator.blockIndex ?: -1
val char = locator.charOffset ?: -1
return "${bookId}_${pageIndex}_${chapter}_${start}_${end}_${block}_${char}"
}
private fun ReaderLocator.belongsTo(page: ReaderPage): Boolean {
@ -832,6 +1008,7 @@ private fun ReaderLocator.normalizedForPage(state: ReaderSessionState, pageIndex
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
val start = startOffset ?: page.startOffset
val end = (endOffset ?: start).coerceAtLeast(start)
val blockPosition = page.firstLocatorBlockPosition()
return copy(pageIndex = page.pageIndex).withFallbacks(
chapterIndex = page.chapterIndex,
chapterId = chapter?.id,
@ -839,13 +1016,20 @@ private fun ReaderLocator.normalizedForPage(state: ReaderSessionState, pageIndex
pageIndex = page.pageIndex,
startOffset = start,
endOffset = end,
blockIndex = blockPosition?.blockIndex,
charOffset = blockPosition?.charOffset,
textQuote = textQuote ?: page.text.preview(),
cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end"
cfi = cfi
?.toStableReaderPositionCfi()
?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") }
?: blockPosition?.androidStyleCfi()
?: "desktop:${page.chapterIndex}:$start:$end"
)
}
private fun ReaderPage.toLocator(book: SharedEpubBook): ReaderLocator {
val chapter = book.chapters.getOrNull(chapterIndex)
val blockPosition = firstLocatorBlockPosition()
return ReaderLocator(
chapterIndex = chapterIndex,
chapterId = chapter?.id,
@ -853,11 +1037,17 @@ private fun ReaderPage.toLocator(book: SharedEpubBook): ReaderLocator {
pageIndex = pageIndex,
startOffset = startOffset,
endOffset = endOffset,
blockIndex = blockPosition?.blockIndex,
charOffset = blockPosition?.charOffset,
textQuote = text.preview(),
cfi = toDesktopCfi()
cfi = blockPosition?.androidStyleCfi() ?: toDesktopCfi()
)
}
private fun ReaderPage.toVerticalScrollPageLocator(book: SharedEpubBook): ReaderLocator {
return toLocator(book).copy(cfi = "desktop-scroll-page:$pageIndex")
}
private fun ReaderPage.toDesktopCfi(): String {
return "desktop:$chapterIndex:$startOffset:$endOffset"
}
@ -1001,3 +1191,27 @@ private fun Char?.isWordChar(): Boolean {
private fun logReaderLink(message: String) {
logSharedReaderDiagnostic("ReaderLinkResolve") { message }
}
private const val ReaderPositionTraceLogTag = "EpistemeDesktopPositionTrace"
private fun logReaderPositionTrace(message: () -> String) {
logSharedReaderDiagnostic(ReaderPositionTraceLogTag, message)
}
private fun ReaderLocator?.positionTraceSummary(maxTextLength: Int = 90): String {
if (this == null) return "null"
return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " +
"offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " +
"block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " +
"chapterId=\"${chapterId.orEmpty().positionTracePreview(80)}\" " +
"href=\"${href.orEmpty().positionTracePreview(120)}\" " +
"cfi=\"${cfi.orEmpty().positionTracePreview(180)}\" " +
"text=\"${textQuote.orEmpty().positionTracePreview(maxTextLength)}\""
}
private fun String.positionTracePreview(maxLength: Int = 96): String {
return replace(Regex("\\s+"), " ")
.trim()
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
.replace("\"", "\\\"")
}

View file

@ -110,6 +110,8 @@ private fun ReaderLocator.jumpLocationKey(): String {
href.orEmpty(),
startOffset?.toString().orEmpty(),
endOffset?.toString().orEmpty(),
blockIndex?.toString().orEmpty(),
charOffset?.toString().orEmpty(),
stableCfi
).joinToString("|")
}
@ -120,6 +122,8 @@ private fun ReaderLocator.jumpLocationKey(): String {
pageIndex?.toString().orEmpty(),
startOffset?.toString().orEmpty(),
endOffset?.toString().orEmpty(),
blockIndex?.toString().orEmpty(),
charOffset?.toString().orEmpty(),
cfi.orEmpty()
).joinToString("|")
}

View file

@ -73,6 +73,7 @@ data class ReaderSettings(
val pageInfoMode: PageInfoMode = PageInfoMode.DEFAULT,
val pageInfoPosition: PageInfoPosition = PageInfoPosition.BOTTOM,
val pageSpreadMode: ReaderPageSpreadMode = ReaderPageSpreadMode.SINGLE,
val rightToLeftPagination: Boolean = false,
val pdfVerticalPageGapVisible: Boolean = true,
val pdfPageNumberOverlayVisible: Boolean = true,
val pdfFirstPageStandaloneInSpread: Boolean = false,
@ -171,7 +172,7 @@ data class PaginatedReaderState(
val canGoNext: Boolean get() = ReaderSpreadLayout.canGoNext(currentPageIndex, pages.size, settings)
val currentSpreadStartIndex: Int get() = ReaderSpreadLayout.normalizePageIndex(currentPageIndex, pages.size, settings)
val visiblePages: List<ReaderPage>
get() = ReaderSpreadLayout.visiblePageIndices(currentPageIndex, pages.size, settings)
get() = ReaderSpreadLayout.visiblePageIndicesForDisplay(currentPageIndex, pages.size, settings)
.mapNotNull { pages.getOrNull(it) }
}
@ -211,6 +212,11 @@ object ReaderSpreadLayout {
return listOf(start, start + 1).filter { it in 0 until pageCount }
}
fun visiblePageIndicesForDisplay(pageIndex: Int, pageCount: Int, settings: ReaderSettings): List<Int> {
val indices = visiblePageIndices(pageIndex, pageCount, settings)
return if (settings.isRightToLeftPaginationEnabled()) indices.asReversed() else indices
}
fun pageRangeLabel(pageIndex: Int, pageCount: Int, settings: ReaderSettings): String {
val total = pageCount.coerceAtLeast(1)
val pages = visiblePageIndices(pageIndex, total, settings).ifEmpty { listOf(0) }
@ -252,3 +258,7 @@ object ReaderSpreadLayout {
fun ReaderSettings.isTwoPageSpreadEnabled(): Boolean {
return readingMode == ReaderReadingMode.PAGINATED && pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE
}
fun ReaderSettings.isRightToLeftPaginationEnabled(): Boolean {
return readingMode == ReaderReadingMode.PAGINATED && rightToLeftPagination
}

View file

@ -2,12 +2,14 @@ package com.aryan.reader.shared.reader
internal const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics"
internal const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
internal const val SharedEpubCutoffDiagnosticsTag = "EpistemeEpubCutoff"
internal expect val SharedReaderDiagnosticsEnabled: Boolean
internal expect fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean
internal expect fun writeSharedReaderDiagnostic(tag: String, message: String)
internal inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) {
if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) {
println("$tag ${message()}")
writeSharedReaderDiagnostic(tag, message())
}
}

View file

@ -28,13 +28,33 @@ enum class SharedAppToolAction {
TABS_TOGGLE
}
enum class SharedAppMoreGroup {
LIBRARY,
ACCOUNT,
PREFERENCES,
HELP
}
data class SharedAppMoreSection(
val group: SharedAppMoreGroup,
val actions: List<SharedAppToolAction>
)
data class SharedAppShellModel(
val primaryTabs: List<SharedAppTab>,
val primaryActions: List<SharedAppToolAction>,
val selectedPrimaryTab: SharedAppTab,
val toolActions: List<SharedAppToolAction>,
val moreSections: List<SharedAppMoreSection>,
val showPrimaryNavigation: Boolean
)
data class SharedSidebarSyncToggleModel(
val visible: Boolean,
val enabled: Boolean,
val checked: Boolean
)
fun sharedAppShellModel(
selectedTab: SharedAppTab,
aiSettingsAvailable: Boolean,
@ -43,12 +63,16 @@ fun sharedAppShellModel(
val primaryTabs = buildList {
add(SharedAppTab.LIBRARY)
if (featurePolicy.opdsCatalogs) add(SharedAppTab.CATALOGS)
if (featurePolicy.aiAndCloud) add(SharedAppTab.PRO)
}
val primaryActions = buildList {
if (aiSettingsAvailable && featurePolicy.aiAndCloud && !featurePolicy.byokAi) {
add(SharedAppToolAction.AI_SETTINGS)
}
}
val selectedPrimaryTab = when (selectedTab) {
SharedAppTab.HOME -> SharedAppTab.LIBRARY
SharedAppTab.SHELVES -> SharedAppTab.LIBRARY
SharedAppTab.SETTINGS,
SharedAppTab.PRO,
SharedAppTab.CUSTOM_FONTS,
SharedAppTab.SUPPORT,
SharedAppTab.FEEDBACK,
@ -57,11 +81,7 @@ fun sharedAppShellModel(
}.takeIf { it in primaryTabs } ?: SharedAppTab.LIBRARY
val toolActions = buildList {
add(SharedAppToolAction.SETTINGS)
add(SharedAppToolAction.IMPORT_FILES)
add(SharedAppToolAction.IMPORT_FOLDER)
add(SharedAppToolAction.SYNC)
add(SharedAppToolAction.APP_THEME)
if (featurePolicy.aiAndCloud) add(SharedAppToolAction.PRO)
if (aiSettingsAvailable && featurePolicy.aiAndCloud) add(SharedAppToolAction.AI_SETTINGS)
add(SharedAppToolAction.CUSTOM_FONTS)
if (featurePolicy.projectLinks) {
@ -69,16 +89,58 @@ fun sharedAppShellModel(
add(SharedAppToolAction.SUPPORT)
}
add(SharedAppToolAction.ABOUT)
add(SharedAppToolAction.TABS_TOGGLE)
}
return SharedAppShellModel(
primaryTabs = primaryTabs,
primaryActions = primaryActions,
selectedPrimaryTab = selectedPrimaryTab,
toolActions = toolActions,
moreSections = sharedAppMoreSections(toolActions),
showPrimaryNavigation = selectedTab != SharedAppTab.READER
)
}
fun sharedAppMoreSections(actions: List<SharedAppToolAction>): List<SharedAppMoreSection> {
return listOf(
SharedAppMoreSection(
group = SharedAppMoreGroup.PREFERENCES,
actions = actions.filter {
it == SharedAppToolAction.SETTINGS ||
it == SharedAppToolAction.APP_THEME ||
it == SharedAppToolAction.AI_SETTINGS ||
it == SharedAppToolAction.CUSTOM_FONTS
}
),
SharedAppMoreSection(
group = SharedAppMoreGroup.HELP,
actions = actions.filter {
it == SharedAppToolAction.HELP_FEEDBACK ||
it == SharedAppToolAction.SUPPORT ||
it == SharedAppToolAction.ABOUT
}
)
).filter { it.actions.isNotEmpty() }
}
fun sharedSidebarSyncToggleModel(
isSignedIn: Boolean,
accountAvailable: Boolean,
syncAvailable: Boolean,
isProUser: Boolean,
isSyncEnabled: Boolean,
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard
): SharedSidebarSyncToggleModel {
val visible = isSignedIn &&
accountAvailable &&
syncAvailable &&
featurePolicy.aiAndCloud
return SharedSidebarSyncToggleModel(
visible = visible,
enabled = visible && isProUser,
checked = visible && isSyncEnabled
)
}
data class NonReaderHomeLayoutModel(
val continueBook: BookItem?,
val activeTabs: List<BookItem>,
@ -153,7 +215,7 @@ private val LibraryFileTypeGroupTemplates = listOf(
NonReaderLibraryFileTypeGroup(
titleKey = "desktop_file_type_group_comics",
titleFallback = "Comics",
fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7)
fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT)
)
)
@ -166,9 +228,20 @@ private val AndroidLibraryTabs = listOf(
private val DesktopLibraryTabs = listOf(
NonReaderLibraryTab.BOOKS,
NonReaderLibraryTab.SHELVES,
NonReaderLibraryTab.FOLDERS
NonReaderLibraryTab.FOLDERS,
NonReaderLibraryTab.UNREAD,
NonReaderLibraryTab.IN_PROGRESS,
NonReaderLibraryTab.COMPLETED
)
internal enum class NonReaderLibraryPrimaryAction {
NEW_SHELF
}
internal enum class NonReaderBookOverflowAction {
ADD_TO_SHELF
}
internal fun visibleNonReaderLibraryTabs(
platform: ReaderPlatform = ReaderPlatform.ANDROID
): List<NonReaderLibraryTab> {
@ -178,6 +251,42 @@ internal fun visibleNonReaderLibraryTabs(
}
}
internal fun primaryLibraryActionsForTab(
tab: NonReaderLibraryTab,
platform: ReaderPlatform = ReaderPlatform.ANDROID
): List<NonReaderLibraryPrimaryAction> {
return if (platform == ReaderPlatform.DESKTOP && tab.visibleLibraryTab(platform) == NonReaderLibraryTab.SHELVES) {
listOf(NonReaderLibraryPrimaryAction.NEW_SHELF)
} else {
emptyList()
}
}
internal fun bookOverflowActionsForPlatform(
platform: ReaderPlatform = ReaderPlatform.ANDROID
): Set<NonReaderBookOverflowAction> {
return when (platform) {
ReaderPlatform.DESKTOP -> setOf(NonReaderBookOverflowAction.ADD_TO_SHELF)
ReaderPlatform.ANDROID -> emptySet()
}
}
internal enum class LibraryCommandBarLayout {
INLINE,
STACKED
}
internal fun libraryCommandBarLayoutForWidth(
widthDp: Float,
platform: ReaderPlatform = ReaderPlatform.DESKTOP
): LibraryCommandBarLayout {
return if (platform == ReaderPlatform.DESKTOP && widthDp >= 980f) {
LibraryCommandBarLayout.INLINE
} else {
LibraryCommandBarLayout.STACKED
}
}
internal fun NonReaderLibraryTab.visibleLibraryTab(
platform: ReaderPlatform = ReaderPlatform.ANDROID
): NonReaderLibraryTab {
@ -221,7 +330,7 @@ internal fun nonReaderLibraryFileTypeGroups(
}
fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibraryOrganizationModel {
val books = rawLibraryBooks
val books = organizationBooks()
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(
@ -244,6 +353,13 @@ fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibr
)
}
private fun SharedReaderScreenState.organizationBooks(): List<BookItem> {
if (shelves.isEmpty()) return rawLibraryBooks
return shelves
.flatMap { it.books }
.distinctBy { it.id }
}
private fun LibraryFilters.activeFilterCount(): Int {
return fileTypes.size +
sourceFolders.size +

View file

@ -12,8 +12,6 @@ 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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -178,7 +176,18 @@ fun SharedHomeScreen(
showActiveTabs: Boolean = true,
modifier: Modifier = Modifier
) {
val model = state.toNonReaderHomeLayoutModel()
val model = remember(
state.recentBooks,
state.openTabs,
state.openTabIds,
state.activeTabBookId,
state.isTabsEnabled,
state.pinnedHomeBookIds,
state.selectedBookIds,
state.rawLibraryBooks
) {
state.toNonReaderHomeLayoutModel()
}
NonReaderScreenScaffold(
title = readerString("nav_home", "Home"),
subtitle = readerString("desktop_home_subtitle", "Continue reading and recent books"),
@ -323,12 +332,15 @@ fun SharedLibraryScreen(
onShowBookInfo: (BookItem) -> Unit = {},
onEditBook: (BookItem) -> Unit = {},
onCreateShelf: () -> Unit = {},
onCreateShelfWithBooks: (String, Set<String>) -> Unit = { _, _ -> },
onCreateSmartShelf: () -> Unit = {},
onRenameShelf: (Shelf) -> Unit = {},
onDeleteShelf: (Shelf) -> Unit = {},
onRemoveFolder: (Shelf) -> Unit = {},
onTagSelectedBooks: () -> Unit = {},
onAddSelectedBooksToShelf: () -> Unit = {},
onAddBooksToShelf: (Set<String>) -> Unit = {},
onManageShelfBooks: ((Shelf) -> Unit)? = null,
onImportFolder: () -> Unit = {},
onSyncFolderMetadata: () -> Unit = {},
onScanFolders: () -> Unit = {},
@ -337,7 +349,15 @@ fun SharedLibraryScreen(
useImportEmptyStateWhenLibraryEmpty: Boolean = false,
modifier: Modifier = Modifier
) {
val organization = state.toNonReaderLibraryOrganizationModel()
val organization = remember(
state.rawLibraryBooks,
state.shelves,
state.allTags,
state.syncedFolders,
state.libraryFilters
) {
state.toNonReaderLibraryOrganizationModel()
}
val activeLibraryTab = selectedTab.visibleLibraryTab(platform)
var showFilters by remember { mutableStateOf(false) }
var viewMode by remember { mutableStateOf(BookViewMode.COVERS) }
@ -399,8 +419,10 @@ fun SharedLibraryScreen(
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
LibraryToolbar(
state = state,
selectedTab = activeLibraryTab,
viewMode = viewMode,
showFilters = showFilters,
platform = platform,
onViewModeChange = { viewMode = it },
onToggleFilters = { showFilters = !showFilters },
onStateChange = onStateChange,
@ -419,11 +441,14 @@ fun SharedLibraryScreen(
onImportBooks = onImportBooks,
onImportFolder = onImportFolder,
useImportEmptyStateWhenLibraryEmpty = useImportEmptyStateWhenLibraryEmpty,
onCreateShelf = onCreateShelf,
onOpenBook = onOpenBook,
onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onTogglePinned = onTogglePinned,
onAddBooksToShelf = onAddBooksToShelf,
onManageShelfBooks = onManageShelfBooks,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
onRemoveFolder = onRemoveFolder,
@ -443,8 +468,10 @@ fun SharedLibraryScreen(
)
LibraryToolbar(
state = state,
selectedTab = activeLibraryTab,
viewMode = viewMode,
showFilters = showFilters,
platform = platform,
onViewModeChange = { viewMode = it },
onToggleFilters = { showFilters = !showFilters },
onStateChange = onStateChange,
@ -463,11 +490,14 @@ fun SharedLibraryScreen(
onImportBooks = onImportBooks,
onImportFolder = onImportFolder,
useImportEmptyStateWhenLibraryEmpty = useImportEmptyStateWhenLibraryEmpty,
onCreateShelf = onCreateShelf,
onOpenBook = onOpenBook,
onToggleSelection = onToggleSelection,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onTogglePinned = onTogglePinned,
onAddBooksToShelf = onAddBooksToShelf,
onManageShelfBooks = onManageShelfBooks,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
onRemoveFolder = onRemoveFolder,
@ -904,8 +934,10 @@ private fun LibraryNavItem(
@Composable
private fun LibraryToolbar(
state: SharedReaderScreenState,
selectedTab: NonReaderLibraryTab,
viewMode: BookViewMode,
showFilters: Boolean,
platform: ReaderPlatform,
onViewModeChange: (BookViewMode) -> Unit,
onToggleFilters: () -> Unit,
onStateChange: (SharedReaderScreenState) -> Unit,
@ -913,13 +945,129 @@ private fun LibraryToolbar(
onImportFolder: () -> Unit,
onCreateShelf: () -> Unit
) {
BoxWithConstraints {
val layout = libraryCommandBarLayoutForWidth(maxWidth.value, platform)
if (layout == LibraryCommandBarLayout.INLINE) {
DesktopLibraryCommandBar(
state = state,
selectedTab = selectedTab,
viewMode = viewMode,
showFilters = showFilters,
platform = platform,
onViewModeChange = onViewModeChange,
onToggleFilters = onToggleFilters,
onStateChange = onStateChange,
onImportBooks = onImportBooks,
onImportFolder = onImportFolder,
onCreateShelf = onCreateShelf
)
} else {
StackedLibraryCommandBar(
state = state,
selectedTab = selectedTab,
viewMode = viewMode,
showFilters = showFilters,
platform = platform,
onViewModeChange = onViewModeChange,
onToggleFilters = onToggleFilters,
onStateChange = onStateChange,
onImportBooks = onImportBooks,
onImportFolder = onImportFolder,
onCreateShelf = onCreateShelf
)
}
}
}
@Composable
private fun DesktopLibraryCommandBar(
state: SharedReaderScreenState,
selectedTab: NonReaderLibraryTab,
viewMode: BookViewMode,
showFilters: Boolean,
platform: ReaderPlatform,
onViewModeChange: (BookViewMode) -> Unit,
onToggleFilters: () -> Unit,
onStateChange: (SharedReaderScreenState) -> Unit,
onImportBooks: () -> Unit,
onImportFolder: () -> Unit,
onCreateShelf: () -> Unit
) {
val showCreateShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
primaryLibraryActionsForTab(selectedTab, platform)
Surface(
shape = RoundedCornerShape(SharedUiTokens.surfaceRadius),
color = MaterialTheme.colorScheme.surfaceContainerLow,
border = sharedSubtleBorder(alpha = 0.5f)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
LibrarySearchField(
state = state,
onStateChange = onStateChange,
modifier = Modifier.weight(1f).widthIn(min = 260.dp)
)
SortMenu(
sortOrder = state.sortOrder,
onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) }
)
LibraryFilterButton(
filters = state.libraryFilters,
showFilters = showFilters,
onToggleFilters = onToggleFilters
)
Button(onClick = onImportBooks) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("desktop_import_files", "Import files"))
}
OutlinedButton(onClick = onImportFolder) {
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("fab_add_folder", "Add folder"))
}
if (showCreateShelfPrimaryAction) {
Button(onClick = onCreateShelf) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("fab_new_shelf", "New shelf"))
}
}
LibraryMoreActionsMenu(
viewMode = viewMode,
onViewModeChange = onViewModeChange,
onCreateShelf = onCreateShelf,
showCreateShelfAction = !showCreateShelfPrimaryAction
)
}
}
}
@Composable
private fun StackedLibraryCommandBar(
state: SharedReaderScreenState,
selectedTab: NonReaderLibraryTab,
viewMode: BookViewMode,
showFilters: Boolean,
platform: ReaderPlatform,
onViewModeChange: (BookViewMode) -> Unit,
onToggleFilters: () -> Unit,
onStateChange: (SharedReaderScreenState) -> Unit,
onImportBooks: () -> Unit,
onImportFolder: () -> Unit,
onCreateShelf: () -> Unit
) {
val showCreateShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
primaryLibraryActionsForTab(selectedTab, platform)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SharedStableOutlinedTextField(
value = state.searchQuery,
onValueChange = { onStateChange(state.reduce(LibraryAction.SearchChanged(it))) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
label = { Text(readerString("library_search_placeholder", "Search books, authors, or tags")) },
singleLine = true,
LibrarySearchField(
state = state,
onStateChange = onStateChange,
modifier = Modifier.fillMaxWidth()
)
Row(
@ -927,52 +1075,135 @@ private fun LibraryToolbar(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
SortMenu(sortOrder = state.sortOrder, onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) })
OutlinedButton(onClick = { onViewModeChange(if (viewMode == BookViewMode.COVERS) BookViewMode.LIST else BookViewMode.COVERS) }) {
Icon(if (viewMode == BookViewMode.COVERS) Icons.AutoMirrored.Filled.List else Icons.Default.Book, contentDescription = null, modifier = Modifier.size(18.dp))
SortMenu(
sortOrder = state.sortOrder,
onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) }
)
LibraryFilterButton(
filters = state.libraryFilters,
showFilters = showFilters,
onToggleFilters = onToggleFilters
)
Button(onClick = onImportBooks) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(
if (viewMode == BookViewMode.COVERS) {
readerString("desktop_list_view", "List")
} else {
readerString("desktop_cover_view", "Covers")
}
)
}
OutlinedButton(onClick = onToggleFilters) {
Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(if (showFilters) readerString("desktop_hide_filters", "Hide filters") else readerString("filter_library", "Filters"))
if (state.libraryFilters.isActive) {
Spacer(Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Text(
state.libraryFilters.activeFilterBadge(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 7.dp, vertical = 2.dp)
)
}
}
}
OutlinedButton(onClick = onCreateShelf) {
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("fab_new_shelf", "New shelf"))
Text(readerString("desktop_import_files", "Import files"))
}
OutlinedButton(onClick = onImportFolder) {
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("fab_add_folder", "Add folder"))
}
Button(onClick = onImportBooks) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("desktop_import_files", "Import files"))
if (showCreateShelfPrimaryAction) {
Button(onClick = onCreateShelf) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("fab_new_shelf", "New shelf"))
}
}
LibraryMoreActionsMenu(
viewMode = viewMode,
onViewModeChange = onViewModeChange,
onCreateShelf = onCreateShelf,
showCreateShelfAction = !showCreateShelfPrimaryAction
)
}
}
}
@Composable
private fun LibrarySearchField(
state: SharedReaderScreenState,
onStateChange: (SharedReaderScreenState) -> Unit,
modifier: Modifier = Modifier
) {
SharedStableOutlinedTextField(
value = state.searchQuery,
onValueChange = { onStateChange(state.reduce(LibraryAction.SearchChanged(it))) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
label = { Text(readerString("library_search_placeholder", "Search books, authors, or tags")) },
singleLine = true,
modifier = modifier
)
}
@Composable
private fun LibraryFilterButton(
filters: LibraryFilters,
showFilters: Boolean,
onToggleFilters: () -> Unit
) {
OutlinedButton(onClick = onToggleFilters) {
Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(if (showFilters) readerString("desktop_hide_filters", "Hide filters") else readerString("filter_library", "Filters"))
if (filters.isActive) {
Spacer(Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Text(
filters.activeFilterBadge(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 7.dp, vertical = 2.dp)
)
}
}
}
}
@Composable
private fun LibraryMoreActionsMenu(
viewMode: BookViewMode,
onViewModeChange: (BookViewMode) -> Unit,
onCreateShelf: () -> Unit,
showCreateShelfAction: Boolean = true
) {
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { expanded = true }) {
Icon(
Icons.Default.MoreVert,
contentDescription = readerString("desktop_more", "More"),
modifier = Modifier.size(20.dp)
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
leadingIcon = {
Icon(
if (viewMode == BookViewMode.COVERS) Icons.AutoMirrored.Filled.List else Icons.Default.Book,
contentDescription = null
)
},
text = {
Text(
if (viewMode == BookViewMode.COVERS) {
readerString("desktop_list_view", "List")
} else {
readerString("desktop_cover_view", "Covers")
}
)
},
onClick = {
expanded = false
onViewModeChange(
if (viewMode == BookViewMode.COVERS) BookViewMode.LIST else BookViewMode.COVERS
)
}
)
if (showCreateShelfAction) {
DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) },
text = { Text(readerString("fab_new_shelf", "New shelf")) },
onClick = {
expanded = false
onCreateShelf()
}
)
}
}
}
@ -990,11 +1221,14 @@ private fun LibraryContent(
onImportBooks: () -> Unit,
onImportFolder: () -> Unit,
useImportEmptyStateWhenLibraryEmpty: Boolean = false,
onCreateShelf: () -> Unit,
onOpenBook: (BookItem) -> Unit,
onToggleSelection: (String) -> Unit,
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onTogglePinned: (BookItem) -> Unit,
onAddBooksToShelf: (Set<String>) -> Unit,
onManageShelfBooks: ((Shelf) -> Unit)?,
onRenameShelf: (Shelf) -> Unit,
onDeleteShelf: (Shelf) -> Unit,
onRemoveFolder: (Shelf) -> Unit,
@ -1003,9 +1237,38 @@ private fun LibraryContent(
modifier: Modifier = Modifier
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
val shelvesById = remember(state.shelves) { state.shelves.associateBy { it.id } }
val visibleBooks = remember(state.libraryBooks, selectedTab, platform) {
state.booksForNonReaderLibraryTab(selectedTab, platform)
}
val tagShelves = remember(state.shelves) {
state.shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 }
}
val browseShelves = remember(state.shelves) {
state.shelves.filter {
it.type != ShelfType.FOLDER &&
it.type != ShelfType.TAG &&
it.type != ShelfType.SMART
}
}
val smartShelves = remember(state.shelves) {
state.shelves.filter { it.type == ShelfType.SMART }
}
val rootFolderShelves = remember(state.shelves) {
state.shelves.filter { it.type == ShelfType.FOLDER && it.parentShelfId == null }
}
val addToShelfFromBookAction = if (NonReaderBookOverflowAction.ADD_TO_SHELF in bookOverflowActionsForPlatform(platform)) {
onAddBooksToShelf
} else {
null
}
val manageShelfBooksAction = if (platform == ReaderPlatform.DESKTOP) onManageShelfBooks else null
val showNewShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
primaryLibraryActionsForTab(selectedTab, platform)
if (showFilters) {
LibraryFilterPanel(
state = state,
platform = platform,
onStateChange = onStateChange
)
} else if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) {
@ -1017,7 +1280,7 @@ private fun LibraryContent(
NonReaderLibraryTab.UNREAD,
NonReaderLibraryTab.IN_PROGRESS,
NonReaderLibraryTab.COMPLETED -> {
val books = state.booksForNonReaderLibraryTab(selectedTab, platform)
val books = visibleBooks
if (books.isEmpty()) {
if (state.rawLibraryBooks.isEmpty() && useImportEmptyStateWhenLibraryEmpty) {
LibraryImportEmptyState(
@ -1052,13 +1315,13 @@ private fun LibraryContent(
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onTogglePinned = onTogglePinned,
onAddToShelf = addToShelfFromBookAction?.let { addToShelf -> { book -> addToShelf(setOf(book.id)) } },
modifier = Modifier.weight(1f)
)
}
}
NonReaderLibraryTab.SHELVES -> {
val tagShelves = state.shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 }
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
BrowseByTagRow(
tagShelves = tagShelves,
@ -1077,7 +1340,7 @@ private fun LibraryContent(
}
)
ShelfCollection(
shelves = state.shelves.filter { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART },
shelves = browseShelves,
selectedBookIds = state.selectedBookIds,
pinnedBookIds = state.pinnedLibraryBookIds,
onOpenBook = onOpenBook,
@ -1085,18 +1348,22 @@ private fun LibraryContent(
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onTogglePinned = onTogglePinned,
onAddBooksToShelf = addToShelfFromBookAction,
onManageShelfBooks = manageShelfBooksAction,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
onRemoveFolder = onRemoveFolder,
onCreateShelf = if (showNewShelfPrimaryAction) onCreateShelf else null,
emptyTitle = readerString("desktop_no_shelves_yet", "No shelves yet"),
emptyBody = readerString("desktop_no_shelves_desc", "Manual shelves and series collections will appear here."),
emptyActionLabel = if (showNewShelfPrimaryAction) readerString("fab_new_shelf", "New shelf") else null,
modifier = Modifier.weight(1f)
)
}
}
NonReaderLibraryTab.SMART_SHELVES -> ShelfCollection(
shelves = state.shelves.filter { it.type == ShelfType.SMART },
shelves = smartShelves,
selectedBookIds = state.selectedBookIds,
pinnedBookIds = state.pinnedLibraryBookIds,
onOpenBook = onOpenBook,
@ -1104,6 +1371,7 @@ private fun LibraryContent(
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onTogglePinned = onTogglePinned,
onAddBooksToShelf = addToShelfFromBookAction,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
emptyTitle = readerString("desktop_no_smart_shelves_yet", "No smart shelves yet"),
@ -1112,7 +1380,7 @@ private fun LibraryContent(
)
NonReaderLibraryTab.TAGS -> ShelfCollection(
shelves = state.shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 },
shelves = tagShelves,
selectedBookIds = state.selectedBookIds,
pinnedBookIds = state.pinnedLibraryBookIds,
onOpenBook = onOpenBook,
@ -1120,6 +1388,7 @@ private fun LibraryContent(
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onTogglePinned = onTogglePinned,
onAddBooksToShelf = addToShelfFromBookAction,
emptyTitle = readerString("desktop_no_tags_yet", "No tags yet"),
emptyBody = readerString("desktop_no_tags_desc", "Tags added to books will appear here."),
modifier = Modifier.weight(1f)
@ -1127,12 +1396,12 @@ private fun LibraryContent(
NonReaderLibraryTab.FOLDERS -> {
val currentFolder = state.viewingShelfId
?.let { id -> state.shelves.firstOrNull { it.id == id && it.type == ShelfType.FOLDER } }
?.let { id -> shelvesById[id]?.takeIf { it.type == ShelfType.FOLDER } }
if (currentFolder != null) {
FolderShelfDetail(
shelf = currentFolder,
childShelves = currentFolder.childShelfIds.mapNotNull { childId ->
state.shelves.firstOrNull { it.id == childId }
shelvesById[childId]
},
selectedBookIds = state.selectedBookIds,
pinnedBookIds = state.pinnedLibraryBookIds,
@ -1141,6 +1410,7 @@ private fun LibraryContent(
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onTogglePinned = onTogglePinned,
onAddBooksToShelf = addToShelfFromBookAction,
onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) },
onBack = { onStateChange(state.copy(viewingShelfId = currentFolder.parentShelfId)) },
modifier = Modifier.weight(1f)
@ -1154,7 +1424,7 @@ private fun LibraryContent(
)
}
ShelfCollection(
shelves = state.shelves.filter { it.type == ShelfType.FOLDER && it.parentShelfId == null },
shelves = rootFolderShelves,
selectedBookIds = state.selectedBookIds,
pinnedBookIds = state.pinnedLibraryBookIds,
onOpenBook = onOpenBook,
@ -1162,6 +1432,7 @@ private fun LibraryContent(
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onTogglePinned = onTogglePinned,
onAddBooksToShelf = addToShelfFromBookAction,
onRemoveFolder = onRemoveFolder,
onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) },
emptyTitle = readerString("desktop_no_folders_yet", "No folders yet"),
@ -1257,9 +1528,9 @@ private fun LibraryFilterSummary(
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun LibraryFilterPanel(
state: SharedReaderScreenState,
platform: ReaderPlatform,
onStateChange: (SharedReaderScreenState) -> Unit
) {
Surface(
@ -1267,7 +1538,7 @@ private fun LibraryFilterPanel(
color = MaterialTheme.colorScheme.surfaceContainerLow,
border = sharedSubtleBorder()
) {
Column(Modifier.fillMaxWidth().padding(SharedUiTokens.panelPadding), verticalArrangement = Arrangement.spacedBy(SharedUiTokens.contentGap)) {
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(readerString("filter_library", "Filters"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) {
@ -1278,30 +1549,20 @@ private fun LibraryFilterPanel(
}
LibraryFilterSection(title = readerString("filter_file_type", "File type")) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
nonReaderLibraryFileTypeGroups().forEach { group ->
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
readerString(group.titleKey, group.titleFallback),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
group.fileTypes.forEach { type ->
FilterChip(
selected = type in state.libraryFilters.fileTypes,
onClick = {
val updated = state.libraryFilters.fileTypes.toggle(type)
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated))))
},
label = { Text(SharedFileCapabilities.displayNameFor(type)) }
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
nonReaderLibraryFileTypeGroups(platform).flatMap { it.fileTypes }.forEach { type ->
FilterChip(
selected = type in state.libraryFilters.fileTypes,
onClick = {
val updated = state.libraryFilters.fileTypes.toggle(type)
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated))))
},
label = { Text(SharedFileCapabilities.displayNameFor(type)) }
)
}
}
}
@ -1328,7 +1589,14 @@ private fun LibraryFilterPanel(
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(sourceFolders = updated))))
},
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(16.dp)) },
label = { Text(folder.name) }
label = {
Text(
folder.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 180.dp)
)
}
)
}
}
@ -1360,9 +1628,10 @@ private fun LibraryFilterPanel(
if (state.allTags.isNotEmpty()) {
LibraryFilterSection(title = readerString("section_tags", "Tags")) {
FlowRow(
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalAlignment = Alignment.CenterVertically
) {
state.allTags.forEach { tag ->
FilterChip(
@ -1372,7 +1641,14 @@ private fun LibraryFilterPanel(
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(tagIds = updated))))
},
leadingIcon = { Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) },
label = { Text(tag.name) }
label = {
Text(
tag.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 160.dp)
)
}
)
}
}
@ -1387,11 +1663,11 @@ private fun LibraryFilterSection(
title: String,
content: @Composable () -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Text(
title,
style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
content()
@ -1414,6 +1690,7 @@ private fun BookGrid(
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onTogglePinned: (BookItem) -> Unit,
onAddToShelf: ((BookItem) -> Unit)? = null,
modifier: Modifier = Modifier
) {
if (viewMode == BookViewMode.LIST) {
@ -1432,7 +1709,8 @@ private fun BookGrid(
onToggleSelection = { onToggleSelection(book.id) },
onShowInfo = { onShowBookInfo(book) },
onEdit = { onEditBook(book) },
onTogglePinned = { onTogglePinned(book) }
onTogglePinned = { onTogglePinned(book) },
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
)
}
}
@ -1454,7 +1732,8 @@ private fun BookGrid(
onToggleSelection = { onToggleSelection(book.id) },
onShowInfo = { onShowBookInfo(book) },
onEdit = { onEditBook(book) },
onTogglePinned = { onTogglePinned(book) }
onTogglePinned = { onTogglePinned(book) },
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
)
}
}
@ -1473,6 +1752,7 @@ private fun BookTile(
onShowInfo: () -> Unit,
onEdit: () -> Unit,
onTogglePinned: () -> Unit,
onAddToShelf: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
var menuExpanded by remember { mutableStateOf(false) }
@ -1522,7 +1802,8 @@ private fun BookTile(
onTogglePinned = onTogglePinned,
onShowInfo = onShowInfo,
onEdit = onEdit,
onToggleSelection = onToggleSelection
onToggleSelection = onToggleSelection,
onAddToShelf = onAddToShelf
)
}
TypeBadge(book.type, modifier = Modifier.align(Alignment.BottomEnd).padding(6.dp))
@ -1557,7 +1838,8 @@ private fun BookListItem(
onToggleSelection: () -> Unit,
onShowInfo: () -> Unit,
onEdit: () -> Unit,
onTogglePinned: () -> Unit
onTogglePinned: () -> Unit,
onAddToShelf: (() -> Unit)? = null
) {
var menuExpanded by remember { mutableStateOf(false) }
Surface(
@ -1598,7 +1880,8 @@ private fun BookListItem(
onTogglePinned = onTogglePinned,
onShowInfo = onShowInfo,
onEdit = onEdit,
onToggleSelection = onToggleSelection
onToggleSelection = onToggleSelection,
onAddToShelf = onAddToShelf
)
}
}
@ -1614,7 +1897,8 @@ private fun BookActionMenu(
onTogglePinned: () -> Unit,
onShowInfo: () -> Unit,
onEdit: () -> Unit,
onToggleSelection: () -> Unit
onToggleSelection: () -> Unit,
onAddToShelf: (() -> Unit)? = null
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
DropdownMenuItem(
@ -1641,6 +1925,16 @@ private fun BookActionMenu(
onEdit()
}
)
if (onAddToShelf != null) {
DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) },
text = { Text(readerString("desktop_add_to_shelf", "Add to shelf")) },
onClick = {
onDismiss()
onAddToShelf()
}
)
}
DropdownMenuItem(
leadingIcon = { Icon(if (selected) Icons.Default.Check else Icons.AutoMirrored.Filled.List, contentDescription = null) },
text = { Text(if (selected) readerString("clear_selection", "Clear selection") else readerString("action_select", "Select")) },
@ -1818,12 +2112,16 @@ private fun ShelfCollection(
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onTogglePinned: (BookItem) -> Unit,
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
onManageShelfBooks: ((Shelf) -> Unit)? = null,
onRenameShelf: (Shelf) -> Unit = {},
onDeleteShelf: (Shelf) -> Unit = {},
onRemoveFolder: (Shelf) -> Unit = {},
onOpenShelf: ((Shelf) -> Unit)? = null,
onCreateShelf: (() -> Unit)? = null,
emptyTitle: String,
emptyBody: String,
emptyActionLabel: String? = null,
modifier: Modifier = Modifier
) {
if (shelves.isEmpty()) {
@ -1831,6 +2129,8 @@ private fun ShelfCollection(
icon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(56.dp)) },
title = emptyTitle,
body = emptyBody,
actionLabel = emptyActionLabel,
onAction = onCreateShelf,
modifier = modifier
)
return
@ -1851,6 +2151,8 @@ private fun ShelfCollection(
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onTogglePinned = onTogglePinned,
onAddBooksToShelf = onAddBooksToShelf,
onManageShelfBooks = onManageShelfBooks,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
onRemoveFolder = onRemoveFolder,
@ -1870,6 +2172,8 @@ private fun ShelfSection(
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onTogglePinned: (BookItem) -> Unit,
onAddBooksToShelf: ((Set<String>) -> Unit)?,
onManageShelfBooks: ((Shelf) -> Unit)?,
onRenameShelf: (Shelf) -> Unit,
onDeleteShelf: (Shelf) -> Unit,
onRemoveFolder: (Shelf) -> Unit,
@ -1912,6 +2216,23 @@ private fun ShelfSection(
}
}
if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") {
if (onManageShelfBooks != null) {
OutlinedButton(onClick = { onManageShelfBooks(shelf) }) {
Icon(
if (shelf.bookCount == 0) Icons.Default.Add else Icons.Default.FormatListNumbered,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text(
if (shelf.bookCount == 0) {
readerString("fab_add_books", "Add books")
} else {
readerString("desktop_manage_books", "Manage books")
}
)
}
}
IconButton(onClick = { onRenameShelf(shelf) }, modifier = Modifier.size(34.dp)) {
Icon(Icons.Default.Edit, contentDescription = readerString("menu_rename_shelf", "Rename shelf"), modifier = Modifier.size(18.dp))
}
@ -1937,6 +2258,7 @@ private fun ShelfSection(
onShowInfo = { onShowBookInfo(book) },
onEdit = { onEditBook(book) },
onTogglePinned = { onTogglePinned(book) },
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } },
modifier = Modifier.width(148.dp)
)
}
@ -1957,6 +2279,7 @@ private fun FolderShelfDetail(
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onTogglePinned: (BookItem) -> Unit,
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
onOpenShelf: (Shelf) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier
@ -2021,7 +2344,8 @@ private fun FolderShelfDetail(
onToggleSelection = { onToggleSelection(book.id) },
onShowInfo = { onShowBookInfo(book) },
onEdit = { onEditBook(book) },
onTogglePinned = { onTogglePinned(book) }
onTogglePinned = { onTogglePinned(book) },
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } }
)
}
}
@ -2431,7 +2755,7 @@ private fun fileTypeColor(type: FileType): Color {
FileType.PDF -> Color(0xFF9C4146)
FileType.EPUB, FileType.MOBI -> Color(0xFF006C4C)
FileType.DOCX, FileType.ODT, FileType.FODT, FileType.PPTX -> Color(0xFF0F52BA)
FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(0xFF705D49)
FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT -> Color(0xFF705D49)
else -> Color(0xFF5D6B82)
}
}

View file

@ -1,7 +1,6 @@
package com.aryan.reader.shared.ui
import androidx.compose.ui.graphics.Color
import com.aryan.reader.shared.ReaderAutoScrollState
import com.aryan.reader.shared.ReaderHighlightPalette
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.UserHighlight
@ -14,7 +13,6 @@ data class ReaderContentNavigationTarget(
val locator: ReaderLocator?,
val requestId: Long,
val readingMode: ReaderReadingMode,
val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(),
val ttsLocator: ReaderLocator? = null,
val ttsRequestId: Long = 0L
)
@ -28,6 +26,7 @@ sealed interface ReaderContentRenderPlan {
data class WebDocument(
val html: String,
val appearanceScript: String,
val highlightPaletteScript: String,
override val background: Color,
override val foreground: Color,
override val navigationTarget: ReaderContentNavigationTarget,

View file

@ -10,6 +10,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -17,6 +18,7 @@ import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.dp
@ -32,9 +34,12 @@ fun ReaderMinimalSlider(
onValueChangeFinished: (() -> Unit)? = null,
activeColor: Color? = null,
inactiveColor: Color? = null,
thumbColor: Color? = null
thumbColor: Color? = null,
markerValue: Float? = null,
markerColor: Color? = null
) {
var widthPx by remember { mutableFloatStateOf(0f) }
var dragValue by remember { mutableStateOf<Float?>(null) }
val rangeStart = valueRange.start
val rangeEnd = valueRange.endInclusive
@ -45,22 +50,36 @@ fun ReaderMinimalSlider(
}
val inputModifier = if (enabled) {
Modifier.pointerInput(rangeStart, rangeEnd, widthPx) {
Modifier.pointerInput(rangeStart, rangeEnd) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
onValueChangeStarted?.invoke()
onValueChange(valueForOffset(down.position.x))
down.consume()
var gestureStarted = false
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
onValueChange(valueForOffset(change.position.x))
change.consume()
fun updateValue(offsetX: Float) {
val nextValue = valueForOffset(offsetX)
dragValue = nextValue
onValueChange(nextValue)
}
onValueChangeFinished?.invoke()
try {
onValueChangeStarted?.invoke()
gestureStarted = true
updateValue(down.position.x)
down.consume()
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
updateValue(change.position.x)
change.consume()
}
} finally {
if (gestureStarted) {
onValueChangeFinished?.invoke()
}
dragValue = null
}
}
}
} else {
@ -70,6 +89,8 @@ fun ReaderMinimalSlider(
val effectiveActiveColor = activeColor ?: MaterialTheme.colorScheme.primary
val effectiveInactiveColor = inactiveColor ?: MaterialTheme.colorScheme.surfaceVariant
val effectiveThumbColor = thumbColor ?: MaterialTheme.colorScheme.primary
val effectiveMarkerColor = markerColor ?: effectiveActiveColor
val markerFraction = readerMinimalSliderMarkerFraction(markerValue, valueRange)
val disabledAlpha = if (enabled) 1f else 0.38f
Box(
@ -80,8 +101,9 @@ fun ReaderMinimalSlider(
) {
Canvas(Modifier.fillMaxSize()) {
val range = rangeEnd - rangeStart
val displayValue = dragValue ?: value
val fraction = if (range > 0f) {
((value.coerceIn(rangeStart, rangeEnd) - rangeStart) / range).coerceIn(0f, 1f)
((displayValue.coerceIn(rangeStart, rangeEnd) - rangeStart) / range).coerceIn(0f, 1f)
} else {
0f
}
@ -104,6 +126,22 @@ fun ReaderMinimalSlider(
cornerRadius = cornerRadius
)
markerFraction?.let { fraction ->
val markerWidth = 2.dp.toPx()
val markerHeight = 12.dp.toPx()
val markerX = if (size.width <= markerWidth) {
size.width / 2f
} else {
(size.width * fraction).coerceIn(markerWidth / 2f, size.width - markerWidth / 2f)
}
drawRoundRect(
color = effectiveMarkerColor.copy(alpha = effectiveMarkerColor.alpha * disabledAlpha),
topLeft = Offset(markerX - markerWidth / 2f, centerY - markerHeight / 2f),
size = Size(markerWidth, markerHeight),
cornerRadius = CornerRadius(markerWidth / 2f, markerWidth / 2f)
)
}
val thumbCenterX = if (size.width <= thumbRadius * 2f) {
size.width / 2f
} else {
@ -117,3 +155,16 @@ fun ReaderMinimalSlider(
}
}
}
internal fun readerMinimalSliderMarkerFraction(
markerValue: Float?,
valueRange: ClosedFloatingPointRange<Float>
): Float? {
val marker = markerValue ?: return null
val rangeStart = valueRange.start
val rangeEnd = valueRange.endInclusive
if (rangeEnd <= rangeStart) return null
return ((marker.coerceIn(rangeStart, rangeEnd) - rangeStart) / (rangeEnd - rangeStart))
.coerceIn(0f, 1f)
}

View file

@ -0,0 +1,130 @@
package com.aryan.reader.shared.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import kotlinx.coroutines.delay
@Composable
fun ReaderTooltipIconButton(
tooltip: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable () -> Unit
) {
var hovered by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) }
val density = LocalDensity.current
val marginPx = with(density) { 8.dp.roundToPx() }
val offsetPx = with(density) { 8.dp.roundToPx() }
LaunchedEffect(hovered, tooltip) {
if (hovered && tooltip.isNotBlank()) {
delay(450L)
visible = hovered
} else {
visible = false
}
}
Box(
modifier = Modifier.pointerInput(tooltip) {
awaitPointerEventScope {
var isHovered = false
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
when (event.type) {
PointerEventType.Enter,
PointerEventType.Move -> if (!isHovered) {
isHovered = true
hovered = true
}
PointerEventType.Exit,
PointerEventType.Press -> if (isHovered) {
isHovered = false
hovered = false
}
}
}
}
}
) {
IconButton(onClick = onClick, modifier = modifier, enabled = enabled) {
content.invoke()
}
if (visible) {
Popup(
popupPositionProvider = ReaderTooltipPositionProvider(
marginPx = marginPx,
offsetPx = offsetPx
)
) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
tonalElevation = 0.dp,
shadowElevation = 4.dp
) {
Text(
text = tooltip,
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.widthIn(max = 260.dp)
.padding(horizontal = 8.dp, vertical = 6.dp)
)
}
}
}
}
}
private data class ReaderTooltipPositionProvider(
private val marginPx: Int,
private val offsetPx: Int
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val centeredX = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2
val maxX = (windowSize.width - popupContentSize.width - marginPx).coerceAtLeast(marginPx)
val x = centeredX.coerceIn(marginPx, maxX)
val belowY = anchorBounds.bottom + offsetPx
val aboveY = anchorBounds.top - popupContentSize.height - offsetPx
val y = if (belowY + popupContentSize.height <= windowSize.height - marginPx) {
belowY
} else {
aboveY.coerceAtLeast(marginPx)
}
return IntOffset(x, y)
}
}

View file

@ -39,7 +39,6 @@ enum class ReaderWorkspaceTopAction {
APPEARANCE,
READ_ALOUD,
AI,
AUTO_SCROLL,
TOOLS
}
@ -52,9 +51,47 @@ enum class ReaderWorkspaceBottomAction {
data class ReaderWorkspaceChromeModel(
val preferAutoHide: Boolean,
val forceVisible: Boolean,
val forceVisibleReasons: Set<String> = emptySet()
val forceVisibleReasons: Set<String> = emptySet(),
val revealVisibleReasons: Set<String> = emptySet()
)
internal fun readerWorkspaceChromeVisible(
requestedVisible: Boolean,
lockedVisible: Boolean,
forcedVisible: Boolean
): Boolean {
return lockedVisible || forcedVisible || requestedVisible
}
internal fun readerWorkspaceChromeVisibleAfterReaderTap(
requestedVisible: Boolean,
lockedVisible: Boolean,
forcedVisible: Boolean,
rightPanelClosedByTap: Boolean = false
): Boolean {
return if (lockedVisible || forcedVisible || rightPanelClosedByTap) {
true
} else {
!requestedVisible
}
}
internal fun readerWorkspaceShouldCloseRightPanelAfterReaderTap(
rightPanelOpen: Boolean,
hasInspectorSections: Boolean,
closeRightPanelOnReaderTap: Boolean
): Boolean {
return closeRightPanelOnReaderTap && rightPanelOpen && hasInspectorSections
}
internal fun readerWorkspaceLeftPanelVisible(
toggledOpen: Boolean,
chromeVisible: Boolean,
hasNavigationSections: Boolean
): Boolean {
return toggledOpen && chromeVisible && hasNavigationSections
}
data class ReaderWorkspaceFileActionState(
val canShare: Boolean = false,
val canSaveCopy: Boolean = false,
@ -94,7 +131,8 @@ fun epubReaderWorkspaceModel(
extrasState: ReaderExtrasState,
aiAvailable: Boolean,
cloudTtsAvailable: Boolean = true,
externalLookupAvailable: Boolean = true
externalLookupAvailable: Boolean = true,
appThemeControlsAvailable: Boolean = false
): ReaderWorkspaceModel {
val preferences = toolbarPreferences.sanitized()
val leftSections = listOf(
@ -104,16 +142,21 @@ fun epubReaderWorkspaceModel(
ReaderWorkspaceLeftSection.IMAGES
)
val inspectorSections = buildList {
if (preferences.isVisible(ReaderTool.THEME) || preferences.isVisible(ReaderTool.FORMAT)) {
if (
appThemeControlsAvailable ||
preferences.isVisible(ReaderTool.THEME) ||
preferences.isVisible(ReaderTool.FORMAT) ||
preferences.isVisible(ReaderTool.VISUAL_OPTIONS) ||
preferences.isVisible(ReaderTool.READING_MODE)
) {
add(ReaderWorkspaceInspectorSection.APPEARANCE)
}
if (preferences.isVisible(ReaderTool.READING_MODE)) {
add(ReaderWorkspaceInspectorSection.TOOLS)
}
if (
(aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) ||
(cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) ||
preferences.isVisible(ReaderTool.AUTO_SCROLL)
(cloudTtsAvailable && (
preferences.isVisible(ReaderTool.TTS_CONTROLS) ||
preferences.isVisible(ReaderTool.TTS_SETTINGS)
)) ||
preferences.isVisible(ReaderTool.TTS_REPLACEMENTS)
) {
add(ReaderWorkspaceInspectorSection.AI_TTS)
}
@ -126,7 +169,6 @@ fun epubReaderWorkspaceModel(
if (ReaderWorkspaceInspectorSection.APPEARANCE in inspectorSections) add(ReaderWorkspaceTopAction.APPEARANCE)
if (cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD)
if (aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) add(ReaderWorkspaceTopAction.AI)
if (preferences.isVisible(ReaderTool.AUTO_SCROLL)) add(ReaderWorkspaceTopAction.AUTO_SCROLL)
if (inspectorSections.isNotEmpty()) add(ReaderWorkspaceTopAction.TOOLS)
}.distinct()
val bottomActions = buildList {
@ -149,7 +191,7 @@ fun epubReaderWorkspaceModel(
richTextEditing = false,
loading = false,
errorMessage = null,
autoScroll = extrasState.autoScroll,
autoScroll = ReaderAutoScrollState(),
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
)
)
@ -211,7 +253,6 @@ fun pdfReaderWorkspaceModel(
add(ReaderWorkspaceTopAction.APPEARANCE)
if (cloudTtsAvailable) add(ReaderWorkspaceTopAction.READ_ALOUD)
if (aiAvailable) add(ReaderWorkspaceTopAction.AI)
add(ReaderWorkspaceTopAction.AUTO_SCROLL)
add(ReaderWorkspaceTopAction.TOOLS)
}
return ReaderWorkspaceModel(
@ -234,7 +275,7 @@ fun pdfReaderWorkspaceModel(
richTextEditing = richTextEditing,
loading = loading,
errorMessage = errorMessage,
autoScroll = extrasState.autoScroll,
autoScroll = ReaderAutoScrollState(),
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
)
)
@ -252,7 +293,7 @@ fun readerWorkspaceChromeModel(
autoScroll: ReaderAutoScrollState,
ttsBusy: Boolean
): ReaderWorkspaceChromeModel {
val reasons = buildSet {
val forceReasons = buildSet {
if (searchActive) add("search")
if (leftPanelOpen) add("left-panel")
if (inspectorOpen) add("inspector")
@ -261,11 +302,14 @@ fun readerWorkspaceChromeModel(
if (loading) add("loading")
if (!errorMessage.isNullOrBlank()) add("error")
if (autoScroll.sanitized().enabled) add("auto-scroll")
}
val revealReasons = buildSet {
if (ttsBusy) add("tts")
}
return ReaderWorkspaceChromeModel(
preferAutoHide = preferAutoHide,
forceVisible = reasons.isNotEmpty(),
forceVisibleReasons = reasons
forceVisible = forceReasons.isNotEmpty(),
forceVisibleReasons = forceReasons,
revealVisibleReasons = revealReasons
)
}

View file

@ -1,59 +1,54 @@
package com.aryan.reader.shared.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.BorderStroke
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.CircleShape
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.AccountCircle
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Feedback
import androidx.compose.material.icons.filled.Folder
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.Lock
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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
@ -70,9 +65,9 @@ import com.aryan.reader.shared.AppContrastOption
import com.aryan.reader.shared.AppThemeMode
import com.aryan.reader.shared.CustomAppTheme
import com.aryan.reader.shared.SharedFeaturePolicy
import com.aryan.reader.shared.UserData
enum class SharedAppTab {
HOME,
LIBRARY,
SHELVES,
CATALOGS,
@ -97,6 +92,15 @@ fun SharedAppShell(
customAppThemes: List<CustomAppTheme> = emptyList(),
isTabsEnabled: Boolean = true,
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard,
currentUser: UserData? = null,
accountAvailable: Boolean = featurePolicy.aiAndCloud,
isOssBuild: Boolean = false,
isProUser: Boolean = false,
isSyncEnabled: Boolean = false,
syncAvailable: Boolean = featurePolicy.aiAndCloud,
onSignInRequested: (() -> Unit)? = null,
accountAvatar: (@Composable (UserData, Modifier) -> Unit)? = null,
onSyncEnabledChange: (Boolean) -> Unit = {},
onTabSelected: (SharedAppTab) -> Unit,
onImportFiles: () -> Unit,
onImportFolder: () -> Unit = {},
@ -121,7 +125,23 @@ fun SharedAppShell(
featurePolicy = featurePolicy
)
}
var showToolsPanel by remember { mutableStateOf(false) }
val sidebarSyncToggleModel = remember(
currentUser != null,
accountAvailable,
syncAvailable,
isProUser,
isSyncEnabled,
featurePolicy
) {
sharedSidebarSyncToggleModel(
isSignedIn = currentUser != null,
accountAvailable = accountAvailable,
syncAvailable = syncAvailable,
isProUser = isProUser,
isSyncEnabled = isSyncEnabled,
featurePolicy = featurePolicy
)
}
var showAppThemeSettings by remember { mutableStateOf(false) }
Scaffold(
@ -141,15 +161,65 @@ fun SharedAppShell(
SharedAppSidebar(
selectedTab = shellModel.selectedPrimaryTab,
primaryTabs = shellModel.primaryTabs,
primaryActions = shellModel.primaryActions,
currentUser = currentUser,
accountAvailable = accountAvailable,
isOssBuild = isOssBuild,
syncToggleModel = sidebarSyncToggleModel,
onAccountClick = { onTabSelected(SharedAppTab.PRO) },
onSignInRequested = onSignInRequested,
accountAvatar = accountAvatar,
onSyncEnabledChange = onSyncEnabledChange,
onTabSelected = onTabSelected,
onToolsClick = { showToolsPanel = true }
onPrimaryAction = { action ->
when (action) {
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested?.invoke()
else -> Unit
}
},
moreMenu = {
SharedMoreMenuButton(
compact = false,
moreSections = shellModel.moreSections,
isTabsEnabled = isTabsEnabled,
onImportFiles = onImportFiles,
onImportFolder = onImportFolder,
onSyncRequested = onSyncRequested,
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested,
onAppThemeRequested = { showAppThemeSettings = true },
onAiSettingsRequested = { onAiSettingsRequested?.invoke() },
onOpenTab = onTabSelected,
onTabsEnabledChange = onTabsEnabledChange
)
}
)
} else {
SharedAppCompactRail(
selectedTab = shellModel.selectedPrimaryTab,
primaryTabs = shellModel.primaryTabs,
primaryActions = shellModel.primaryActions,
onTabSelected = onTabSelected,
onToolsClick = { showToolsPanel = true }
onPrimaryAction = { action ->
when (action) {
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested?.invoke()
else -> Unit
}
},
moreMenu = {
SharedMoreMenuButton(
compact = true,
moreSections = shellModel.moreSections,
isTabsEnabled = isTabsEnabled,
onImportFiles = onImportFiles,
onImportFolder = onImportFolder,
onSyncRequested = onSyncRequested,
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested,
onAppThemeRequested = { showAppThemeSettings = true },
onAiSettingsRequested = { onAiSettingsRequested?.invoke() },
onOpenTab = onTabSelected,
onTabsEnabledChange = onTabsEnabledChange
)
}
)
}
}
@ -163,55 +233,6 @@ fun SharedAppShell(
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,
toolActions = shellModel.toolActions,
onClose = { showToolsPanel = false },
onImportFiles = {
showToolsPanel = false
onImportFiles()
},
onImportFolder = {
showToolsPanel = false
onImportFolder()
},
onSyncRequested = {
showToolsPanel = false
onSyncRequested()
},
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested?.let { syncMetadata ->
{
showToolsPanel = false
syncMetadata()
}
},
onAppThemeRequested = {
showToolsPanel = false
showAppThemeSettings = true
},
onAiSettingsRequested = {
showToolsPanel = false
onAiSettingsRequested?.invoke()
},
onOpenTab = { tab ->
showToolsPanel = false
onTabSelected(tab)
},
onTabsEnabledChange = onTabsEnabledChange
)
}
}
}
@ -239,8 +260,18 @@ fun SharedAppShell(
private fun SharedAppSidebar(
selectedTab: SharedAppTab,
primaryTabs: List<SharedAppTab>,
primaryActions: List<SharedAppToolAction>,
currentUser: UserData?,
accountAvailable: Boolean,
isOssBuild: Boolean,
syncToggleModel: SharedSidebarSyncToggleModel,
onAccountClick: () -> Unit,
onSignInRequested: (() -> Unit)?,
accountAvatar: (@Composable (UserData, Modifier) -> Unit)?,
onSyncEnabledChange: (Boolean) -> Unit,
onTabSelected: (SharedAppTab) -> Unit,
onToolsClick: () -> Unit
onPrimaryAction: (SharedAppToolAction) -> Unit,
moreMenu: @Composable () -> Unit
) {
Surface(
modifier = Modifier
@ -255,24 +286,48 @@ private fun SharedAppSidebar(
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(SharedUiTokens.compactGap)
) {
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
Text(readerString("app_name", "Episteme"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(readerString("desktop_library_and_reader", "Library and reader"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
SharedSidebarHeader(
currentUser = currentUser,
accountAvailable = accountAvailable,
isOssBuild = isOssBuild,
onAccountClick = onAccountClick,
onSignInRequested = onSignInRequested,
accountAvatar = accountAvatar
)
primaryTabs.forEach { tab ->
if (tab == SharedAppTab.PRO) {
primaryActions.forEach { action ->
SharedSidebarButton(
label = action.labelForPrimaryNavigation(),
icon = action.iconForPrimaryNavigation(),
onClick = { onPrimaryAction(action) }
)
}
}
SharedSidebarNavItem(
tab = tab,
selected = selectedTab == tab,
onClick = { onTabSelected(tab) }
)
if (tab == SharedAppTab.PRO && syncToggleModel.visible) {
SharedSidebarSyncToggle(
model = syncToggleModel,
onSyncEnabledChange = onSyncEnabledChange
)
}
}
if (SharedAppTab.PRO !in primaryTabs) {
primaryActions.forEach { action ->
SharedSidebarButton(
label = action.labelForPrimaryNavigation(),
icon = action.iconForPrimaryNavigation(),
onClick = { onPrimaryAction(action) }
)
}
}
Spacer(Modifier.weight(1f))
HorizontalDivider()
SharedSidebarButton(
label = readerString("desktop_tools", "Tools"),
icon = Icons.Default.Settings,
onClick = onToolsClick
)
moreMenu()
}
}
}
@ -281,11 +336,23 @@ private fun SharedAppSidebar(
private fun SharedAppCompactRail(
selectedTab: SharedAppTab,
primaryTabs: List<SharedAppTab>,
primaryActions: List<SharedAppToolAction>,
onTabSelected: (SharedAppTab) -> Unit,
onToolsClick: () -> Unit
onPrimaryAction: (SharedAppToolAction) -> Unit,
moreMenu: @Composable () -> Unit
) {
NavigationRail(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) {
primaryTabs.forEach { tab ->
if (tab == SharedAppTab.PRO) {
primaryActions.forEach { action ->
NavigationRailItem(
selected = false,
onClick = { onPrimaryAction(action) },
icon = { Icon(action.iconForPrimaryNavigation(), contentDescription = null) },
label = { Text(action.labelForPrimaryNavigation()) }
)
}
}
NavigationRailItem(
selected = selectedTab == tab,
onClick = { onTabSelected(tab) },
@ -293,13 +360,167 @@ private fun SharedAppCompactRail(
label = { Text(tab.localizedLabel()) }
)
}
if (SharedAppTab.PRO !in primaryTabs) {
primaryActions.forEach { action ->
NavigationRailItem(
selected = false,
onClick = { onPrimaryAction(action) },
icon = { Icon(action.iconForPrimaryNavigation(), contentDescription = null) },
label = { Text(action.labelForPrimaryNavigation()) }
)
}
}
Spacer(Modifier.weight(1f))
IconButton(onClick = onToolsClick) {
Icon(Icons.Default.Settings, contentDescription = readerString("desktop_tools", "Tools"))
moreMenu()
}
}
@Composable
private fun SharedSidebarHeader(
currentUser: UserData?,
accountAvailable: Boolean,
isOssBuild: Boolean,
onAccountClick: () -> Unit,
onSignInRequested: (() -> Unit)?,
accountAvatar: (@Composable (UserData, Modifier) -> Unit)?
) {
val signInRequested = onSignInRequested
val avatarContent = accountAvatar
when {
isOssBuild -> {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
SharedInitialAvatar(initial = "E", modifier = Modifier.size(42.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
readerString("desktop_app_name_oss", "Episteme oss"),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
readerString("desktop_offline_oss_reader", "Offline desktop reader"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
currentUser != null -> {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)),
onClick = onAccountClick
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (avatarContent != null) {
avatarContent(currentUser, Modifier.size(42.dp))
} else {
SharedInitialAvatar(initial = currentUser.initial(), modifier = Modifier.size(42.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
currentUser.displayName ?: currentUser.email ?: readerString("desktop_signed_in", "Signed in"),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
currentUser.email ?: readerString("desktop_account_and_credits", "Account & credits"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
accountAvailable && signInRequested != null -> {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)),
onClick = signInRequested
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
Icons.Default.AccountCircle,
contentDescription = null,
modifier = Modifier.size(42.dp),
tint = MaterialTheme.colorScheme.primary
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
readerString("drawer_sign_in", "Sign in with Google"),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
readerString("desktop_sign_in_account_header_desc", "Sync account, Pro, and credits"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
else -> {
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
Text(readerString("app_name", "Episteme"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(readerString("desktop_library_and_reader", "Library and reader"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun SharedInitialAvatar(
initial: String,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Text(initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
}
}
private fun UserData.initial(): String {
return (displayName ?: email ?: "E")
.trim()
.firstOrNull()
?.uppercase()
?: "E"
}
@Composable
private fun SharedSidebarNavItem(
tab: SharedAppTab,
@ -334,6 +555,68 @@ private fun SharedSidebarNavItem(
}
}
@Composable
private fun SharedSidebarSyncToggle(
model: SharedSidebarSyncToggleModel,
onSyncEnabledChange: (Boolean) -> Unit
) {
val title = readerString("desktop_cloud_sync", "Cloud sync")
val summary = when {
!model.enabled -> readerString("desktop_pro_required", "Pro required")
model.checked -> readerString("content_desc_enabled", "Enabled")
else -> readerString("desktop_disabled", "Disabled")
}
val contentColor = if (model.enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.62f)
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
contentColor = contentColor,
onClick = {
if (model.enabled) {
onSyncEnabledChange(!model.checked)
}
}
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(
title,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
summary,
style = MaterialTheme.typography.labelSmall,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Switch(
checked = model.checked,
enabled = model.enabled,
onCheckedChange = { checked ->
if (model.enabled) {
onSyncEnabledChange(checked)
}
}
)
}
}
}
@Composable
private fun SharedSidebarButton(
label: String,
@ -359,11 +642,10 @@ private fun SharedSidebarButton(
}
@Composable
private fun SharedToolsPanel(
modifier: Modifier,
private fun SharedMoreMenuButton(
compact: Boolean,
moreSections: List<SharedAppMoreSection>,
isTabsEnabled: Boolean,
toolActions: List<SharedAppToolAction>,
onClose: () -> Unit,
onImportFiles: () -> Unit,
onImportFolder: () -> Unit,
onSyncRequested: () -> Unit,
@ -373,190 +655,178 @@ private fun SharedToolsPanel(
onOpenTab: (SharedAppTab) -> Unit,
onTabsEnabledChange: (Boolean) -> Unit
) {
val hasWorkspaceActions = SharedAppToolAction.SETTINGS in toolActions ||
SharedAppToolAction.APP_THEME in toolActions ||
SharedAppToolAction.TABS_TOGGLE in toolActions
val hasLibraryActions = SharedAppToolAction.IMPORT_FILES in toolActions ||
SharedAppToolAction.IMPORT_FOLDER in toolActions ||
SharedAppToolAction.SYNC in toolActions
val hasSettingsActions = SharedAppToolAction.PRO in toolActions ||
SharedAppToolAction.AI_SETTINGS in toolActions ||
SharedAppToolAction.CUSTOM_FONTS in toolActions
val hasProjectActions = SharedAppToolAction.HELP_FEEDBACK in toolActions ||
SharedAppToolAction.SUPPORT in toolActions ||
SharedAppToolAction.ABOUT in toolActions
var expanded by remember { mutableStateOf(false) }
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(readerString("desktop_tools", "Tools"), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(readerString("desktop_tools_desc", "Import, sync, and app settings"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = readerString("desktop_close_tools", "Close tools"))
}
fun runAction(action: SharedAppToolAction) {
expanded = false
when (action) {
SharedAppToolAction.SETTINGS -> onOpenTab(SharedAppTab.SETTINGS)
SharedAppToolAction.IMPORT_FILES -> onImportFiles()
SharedAppToolAction.IMPORT_FOLDER -> onImportFolder()
SharedAppToolAction.SYNC -> onSyncRequested()
SharedAppToolAction.APP_THEME -> onAppThemeRequested()
SharedAppToolAction.PRO -> onOpenTab(SharedAppTab.PRO)
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested()
SharedAppToolAction.CUSTOM_FONTS -> onOpenTab(SharedAppTab.CUSTOM_FONTS)
SharedAppToolAction.HELP_FEEDBACK -> onOpenTab(SharedAppTab.FEEDBACK)
SharedAppToolAction.SUPPORT -> onOpenTab(SharedAppTab.SUPPORT)
SharedAppToolAction.ABOUT -> onOpenTab(SharedAppTab.ABOUT)
SharedAppToolAction.TABS_TOGGLE -> onTabsEnabledChange(!isTabsEnabled)
}
}
Box(modifier = if (compact) Modifier else Modifier.fillMaxWidth()) {
if (compact) {
IconButton(onClick = { expanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = readerString("desktop_more_menu", "More"))
}
if (hasWorkspaceActions) {
SharedToolsSection(readerString("desktop_workspace", "Workspace")) {
if (SharedAppToolAction.SETTINGS in toolActions) {
SharedToolRow(Icons.Default.Settings, readerString("desktop_settings_hub", "Settings hub")) { onOpenTab(SharedAppTab.SETTINGS) }
}
if (SharedAppToolAction.APP_THEME in toolActions) {
SharedToolRow(
icon = Icons.Default.Palette,
title = readerString("app_theme_title", "App theme"),
onClick = onAppThemeRequested
} else {
SharedSidebarButton(
label = readerString("desktop_more_menu", "More"),
icon = Icons.Default.MoreVert,
onClick = { expanded = true }
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.width(300.dp)
) {
moreSections.forEachIndexed { sectionIndex, section ->
SharedMoreMenuSectionLabel(section.group)
section.actions.forEach { action ->
if (action == SharedAppToolAction.SYNC && onFolderMetadataSyncRequested != null) {
SharedMoreMenuItem(
icon = Icons.Default.Sync,
title = readerString("desktop_sync_metadata", "Sync metadata"),
onClick = {
expanded = false
onFolderMetadataSyncRequested()
}
)
SharedMoreMenuItem(
icon = Icons.Default.Search,
title = readerString("desktop_full_scan", "Full scan"),
onClick = { runAction(action) }
)
} else {
SharedMoreMenuItem(
icon = action.iconForMoreMenu(),
title = action.labelForMoreMenu(isTabsEnabled),
checked = if (action == SharedAppToolAction.TABS_TOGGLE) isTabsEnabled else null,
onClick = { runAction(action) }
)
}
if (SharedAppToolAction.TABS_TOGGLE in toolActions) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(readerString("desktop_open_readers", "Open readers"), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
Text(
if (isTabsEnabled) readerString("content_desc_enabled", "Enabled") else readerString("desktop_disabled", "Disabled"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = isTabsEnabled,
onCheckedChange = onTabsEnabledChange
)
}
}
}
if (sectionIndex != moreSections.lastIndex) {
HorizontalDivider()
}
}
if (hasLibraryActions) {
SharedToolsSection(readerString("library_title", "Library")) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
if (SharedAppToolAction.IMPORT_FILES in toolActions) {
Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) {
Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("desktop_import_files", "Import files"))
}
}
if (SharedAppToolAction.IMPORT_FOLDER in toolActions) {
OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) {
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("fab_add_folder", "Add folder"))
}
}
}
if (SharedAppToolAction.SYNC in toolActions) {
if (onFolderMetadataSyncRequested == null) {
FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("desktop_sync_folders", "Sync folders"))
}
} else {
SharedToolRow(Icons.Default.Sync, readerString("desktop_sync_metadata", "Sync metadata"), onFolderMetadataSyncRequested)
SharedToolRow(Icons.Default.Search, readerString("desktop_full_scan", "Full scan")) {
onSyncRequested()
}
}
}
}
}
if (hasSettingsActions) {
SharedToolsSection(readerString("settings", "Settings")) {
if (SharedAppToolAction.PRO in toolActions) {
SharedToolRow(Icons.Default.Star, readerString("desktop_pro_and_credits", "Pro and credits")) { onOpenTab(SharedAppTab.PRO) }
}
if (SharedAppToolAction.AI_SETTINGS in toolActions) {
SharedToolRow(Icons.Default.Settings, readerString("ai_settings_title", "AI keys and models"), onAiSettingsRequested)
}
if (SharedAppToolAction.CUSTOM_FONTS in toolActions) {
SharedToolRow(Icons.Default.TextFields, readerString("custom_fonts", "Custom fonts")) { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
}
}
}
if (hasProjectActions) {
SharedToolsSection(readerString("desktop_project", "Project")) {
if (SharedAppToolAction.HELP_FEEDBACK in toolActions) {
SharedToolRow(Icons.Default.Feedback, readerString("drawer_help_feedback", "Help & feedback")) { onOpenTab(SharedAppTab.FEEDBACK) }
}
if (SharedAppToolAction.SUPPORT in toolActions) {
SharedToolRow(Icons.Default.Favorite, readerString("drawer_support_project", "Support project")) { onOpenTab(SharedAppTab.SUPPORT) }
}
if (SharedAppToolAction.ABOUT in toolActions) {
SharedToolRow(Icons.Default.Info, readerString("about_title", "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()
}
private fun SharedMoreMenuSectionLabel(group: SharedAppMoreGroup) {
Text(
group.labelForMoreMenu(),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
}
@Composable
private fun SharedToolRow(
private fun SharedMoreMenuItem(
icon: ImageVector,
title: String,
checked: Boolean? = null,
onClick: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
DropdownMenuItem(
text = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
leadingIcon = { Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp)) },
trailingIcon = checked?.let { isChecked ->
{ Switch(checked = isChecked, onCheckedChange = null) }
},
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)
)
}
@Composable
private fun SharedAppToolAction.labelForPrimaryNavigation(): String {
return when (this) {
SharedAppToolAction.AI_SETTINGS -> readerString("desktop_ai_keys", "AI keys")
else -> labelForMoreMenu(isTabsEnabled = false)
}
}
private fun SharedAppToolAction.iconForPrimaryNavigation(): ImageVector {
return when (this) {
SharedAppToolAction.AI_SETTINGS -> Icons.Default.Lock
else -> iconForMoreMenu()
}
}
@Composable
private fun SharedAppMoreGroup.labelForMoreMenu(): String {
return when (this) {
SharedAppMoreGroup.LIBRARY -> readerString("desktop_more_library_actions", "Library actions")
SharedAppMoreGroup.ACCOUNT -> readerString("desktop_account_and_credits", "Account & credits")
SharedAppMoreGroup.PREFERENCES -> readerString("desktop_preferences", "Preferences")
SharedAppMoreGroup.HELP -> readerString("desktop_help", "Help")
}
}
@Composable
private fun SharedAppToolAction.labelForMoreMenu(isTabsEnabled: Boolean): String {
return when (this) {
SharedAppToolAction.SETTINGS -> readerString("settings", "Settings")
SharedAppToolAction.IMPORT_FILES -> readerString("desktop_import_files", "Import files")
SharedAppToolAction.IMPORT_FOLDER -> readerString("fab_add_folder", "Add folder")
SharedAppToolAction.SYNC -> readerString("desktop_sync_folders", "Sync folders")
SharedAppToolAction.APP_THEME -> readerString("app_theme_title", "App theme")
SharedAppToolAction.PRO -> readerString("desktop_account_and_credits", "Account & credits")
SharedAppToolAction.AI_SETTINGS -> readerString("ai_settings_title", "AI keys and models")
SharedAppToolAction.CUSTOM_FONTS -> readerString("custom_fonts", "Custom fonts")
SharedAppToolAction.HELP_FEEDBACK -> readerString("drawer_help_feedback", "Help & feedback")
SharedAppToolAction.SUPPORT -> readerString("drawer_support_project", "Support project")
SharedAppToolAction.ABOUT -> readerString("about_title", "About Episteme")
SharedAppToolAction.TABS_TOGGLE -> if (isTabsEnabled) {
readerString("desktop_reader_tabs_on", "Reader tabs on")
} else {
readerString("desktop_reader_tabs_off", "Reader tabs off")
}
}
}
private fun SharedAppToolAction.iconForMoreMenu(): ImageVector {
return when (this) {
SharedAppToolAction.SETTINGS -> Icons.Default.Settings
SharedAppToolAction.IMPORT_FILES -> Icons.Default.ImportExport
SharedAppToolAction.IMPORT_FOLDER -> Icons.Default.CreateNewFolder
SharedAppToolAction.SYNC -> Icons.Default.Sync
SharedAppToolAction.APP_THEME -> Icons.Default.Palette
SharedAppToolAction.PRO -> Icons.Default.Star
SharedAppToolAction.AI_SETTINGS -> Icons.Default.Settings
SharedAppToolAction.CUSTOM_FONTS -> Icons.Default.TextFields
SharedAppToolAction.HELP_FEEDBACK -> Icons.Default.Feedback
SharedAppToolAction.SUPPORT -> Icons.Default.Favorite
SharedAppToolAction.ABOUT -> Icons.Default.Info
SharedAppToolAction.TABS_TOGGLE -> Icons.AutoMirrored.Filled.MenuBook
}
}
@Composable
private fun SharedAppTab.localizedLabel(): String {
return when (this) {
SharedAppTab.HOME -> readerString("nav_home", "Home")
SharedAppTab.LIBRARY -> readerString("library_title", "Library")
SharedAppTab.SHELVES -> readerString("tab_shelves", "Shelves")
SharedAppTab.CATALOGS -> readerString("opds_stream", "OPDS")
SharedAppTab.READER -> readerString("desktop_reader", "Reader")
SharedAppTab.SETTINGS -> readerString("settings", "Settings")
SharedAppTab.PRO -> readerString("desktop_pro", "Pro")
SharedAppTab.PRO -> readerString("desktop_account_and_credits", "Account & credits")
SharedAppTab.CUSTOM_FONTS -> readerString("custom_fonts", "Custom fonts")
SharedAppTab.SUPPORT -> readerString("desktop_support", "Support")
SharedAppTab.FEEDBACK -> readerString("desktop_feedback", "Feedback")
@ -566,7 +836,6 @@ private fun SharedAppTab.localizedLabel(): String {
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

View file

@ -6,7 +6,6 @@ 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
@ -49,6 +48,7 @@ import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
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
@ -64,6 +64,9 @@ 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.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.TextRange
@ -267,112 +270,29 @@ fun SharedAppThemeSettingsDialog(
onCustomThemeDeleted: (String) -> Unit,
onDismiss: () -> Unit
) {
var showCreateDialog by remember { mutableStateOf(false) }
val defaultCustomThemeName = readerString("desktop_custom_theme_default", "Custom")
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(readerString("app_theme_title", "App theme"), fontWeight = FontWeight.Bold) },
text = {
Column(
SharedAppThemeControls(
appThemeMode = appThemeMode,
appContrastOption = appContrastOption,
appTextDimFactorLight = appTextDimFactorLight,
appTextDimFactorDark = appTextDimFactorDark,
appSeedColor = appSeedColor,
customAppThemes = customAppThemes,
onThemeModeChanged = onThemeModeChanged,
onContrastOptionChanged = onContrastOptionChanged,
onTextDimFactorLightChanged = onTextDimFactorLightChanged,
onTextDimFactorDarkChanged = onTextDimFactorDarkChanged,
onSeedColorChanged = onSeedColorChanged,
onCustomThemeAdded = onCustomThemeAdded,
onCustomThemeDeleted = onCustomThemeDeleted,
modifier = Modifier
.widthIn(max = 620.dp)
.heightIn(max = 620.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
SettingsLabel(readerString("app_theme_appearance", "Appearance"))
SegmentedControl(
values = AppThemeMode.entries,
selectedValue = appThemeMode,
label = { it.localizedLabel() },
onValueSelected = onThemeModeChanged
)
SettingsLabel(readerString("app_theme_contrast", "Contrast"))
SegmentedControl(
values = AppContrastOption.entries,
selectedValue = appContrastOption,
label = { it.localizedLabel() },
onValueSelected = onContrastOptionChanged
)
if (appThemeMode == AppThemeMode.SYSTEM) {
TextBrightnessSlider(
label = readerString("app_theme_text_brightness_light", "Text brightness (Light)"),
value = appTextDimFactorLight,
onValueChange = onTextDimFactorLightChanged
)
TextBrightnessSlider(
label = readerString("app_theme_text_brightness_dark", "Text brightness (Dark)"),
value = appTextDimFactorDark,
onValueChange = onTextDimFactorDarkChanged
)
} else {
TextBrightnessSlider(
label = readerString("app_theme_text_brightness", "Text brightness"),
value = if (appThemeMode == AppThemeMode.DARK) appTextDimFactorDark else appTextDimFactorLight,
onValueChange = if (appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged
)
}
SettingsLabel(readerString("app_theme_color_scheme", "Color scheme"))
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ThemeSwatch(
color = MaterialTheme.colorScheme.primary,
selected = appSeedColor == null,
label = readerString("app_theme_dynamic", "Dynamic"),
onClick = { onSeedColorChanged(null) }
)
AppThemePresets.forEach { preset ->
ThemeSwatch(
color = preset.color,
selected = appSeedColor == preset.color,
label = readerString(preset.nameKey, preset.nameFallback),
onClick = { onSeedColorChanged(preset.color) }
)
}
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
SettingsLabel(readerString("theme_my_themes", "My themes"))
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Add, contentDescription = readerString("content_desc_add_custom_theme", "Add custom theme"))
}
}
if (customAppThemes.isEmpty()) {
Text(
readerString("desktop_no_custom_themes_yet", "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) }
)
}
}
}
}
.verticalScroll(rememberScrollState())
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
@ -380,6 +300,124 @@ fun SharedAppThemeSettingsDialog(
}
}
)
}
@Composable
fun SharedAppThemeControls(
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,
modifier: Modifier = Modifier
) {
var showCreateDialog by remember { mutableStateOf(false) }
val defaultCustomThemeName = readerString("desktop_custom_theme_default", "Custom")
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
SettingsLabel(readerString("app_theme_appearance", "Appearance"))
SegmentedControl(
values = AppThemeMode.entries,
selectedValue = appThemeMode,
label = { it.localizedLabel() },
onValueSelected = onThemeModeChanged
)
SettingsLabel(readerString("app_theme_contrast", "Contrast"))
SegmentedControl(
values = AppContrastOption.entries,
selectedValue = appContrastOption,
label = { it.localizedLabel() },
onValueSelected = onContrastOptionChanged
)
if (appThemeMode == AppThemeMode.SYSTEM) {
TextBrightnessSlider(
label = readerString("app_theme_text_brightness_light", "Text brightness (Light)"),
value = appTextDimFactorLight,
onValueChange = onTextDimFactorLightChanged
)
TextBrightnessSlider(
label = readerString("app_theme_text_brightness_dark", "Text brightness (Dark)"),
value = appTextDimFactorDark,
onValueChange = onTextDimFactorDarkChanged
)
} else {
TextBrightnessSlider(
label = readerString("app_theme_text_brightness", "Text brightness"),
value = if (appThemeMode == AppThemeMode.DARK) appTextDimFactorDark else appTextDimFactorLight,
onValueChange = if (appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged
)
}
SettingsLabel(readerString("app_theme_color_scheme", "Color scheme"))
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ThemeSwatch(
color = MaterialTheme.colorScheme.primary,
selected = appSeedColor == null,
label = readerString("app_theme_dynamic", "Dynamic"),
onClick = { onSeedColorChanged(null) }
)
AppThemePresets.forEach { preset ->
ThemeSwatch(
color = preset.color,
selected = appSeedColor == preset.color,
label = readerString(preset.nameKey, preset.nameFallback),
onClick = { onSeedColorChanged(preset.color) }
)
}
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
SettingsLabel(readerString("theme_my_themes", "My themes"))
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Add, contentDescription = readerString("content_desc_add_custom_theme", "Add custom theme"))
}
}
if (customAppThemes.isEmpty()) {
Text(
readerString("desktop_no_custom_themes_yet", "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) }
)
}
}
}
}
if (showCreateDialog) {
SharedCreateAppThemeDialog(
@ -656,11 +694,19 @@ fun SharedHsvColorPickerDialog(
onDismiss: () -> Unit,
onSave: (Color) -> Unit,
modifier: Modifier = Modifier,
resetColor: Color? = null,
stateKey: Any? = null,
onLiveColorChange: (Color) -> Unit = {},
preview: @Composable (Color) -> Unit = {}
) {
var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) }
val effectiveStateKey = stateKey ?: initialColor
var hsv by remember(effectiveStateKey) { mutableStateOf(initialColor.toSharedHsvColor()) }
val color = hsv.toComposeColor()
LaunchedEffect(effectiveStateKey, color) {
onLiveColorChange(color)
}
fun updateFromColor(nextColor: Color) {
hsv = nextColor.toSharedHsvColor()
}
@ -709,7 +755,8 @@ fun SharedHsvColorPickerDialog(
onHueSatChanged = { hue, saturation ->
hsv = hsv.copy(hue = hue, saturation = saturation)
},
modifier = Modifier.size(240.dp)
modifier = Modifier.size(240.dp),
gestureKey = effectiveStateKey
)
SharedBrightnessSlider(
@ -717,7 +764,8 @@ fun SharedHsvColorPickerDialog(
saturation = hsv.saturation,
value = hsv.value,
onValueChanged = { hsv = hsv.copy(value = it) },
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp))
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp)),
gestureKey = effectiveStateKey
)
Row(
@ -767,9 +815,14 @@ fun SharedHsvColorPickerDialog(
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (resetColor != null) {
TextButton(onClick = { updateFromColor(resetColor) }) {
Text(readerString("action_reset", "Reset"), color = MaterialTheme.colorScheme.error)
}
}
Spacer(Modifier.weight(1f))
TextButton(onClick = onDismiss) {
Text(readerString("action_cancel", "Cancel"))
}
@ -795,32 +848,23 @@ fun SharedHsvWheel(
saturation: Float,
currentColor: Color,
onHueSatChanged: (Float, Float) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
gestureKey: Any? = Unit
) {
val touchPadding = 12.dp
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val paddingPx = touchPadding.toPx()
fun update(offset: Offset) {
val selection = sharedHsvWheelSelection(
offsetX = offset.x,
offsetY = offset.y,
width = size.width.toFloat(),
height = size.height.toFloat(),
paddingPx = paddingPx
)
onHueSatChanged(selection.hue, selection.saturation)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
modifier = modifier.pointerInput(gestureKey) {
val paddingPx = touchPadding.toPx()
awaitSharedColorPickerDrag { offset ->
val selection = sharedHsvWheelSelection(
offsetX = offset.x,
offsetY = offset.y,
width = size.width.toFloat(),
height = size.height.toFloat(),
paddingPx = paddingPx
)
onHueSatChanged(selection.hue, selection.saturation)
}
}
) {
@ -896,7 +940,8 @@ fun SharedSpectrumBox(
saturation: Float,
currentColor: Color,
onHueSatChanged: (Float, Float) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
gestureKey: Any? = Unit
) {
val rainbowColors = listOf(
Color.Red,
@ -910,26 +955,16 @@ fun SharedSpectrumBox(
val touchPadding = 12.dp
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val paddingPx = touchPadding.toPx()
modifier = modifier.pointerInput(gestureKey) {
val paddingPx = touchPadding.toPx()
awaitSharedColorPickerDrag { offset ->
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)
}
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)
}
}
) {
@ -982,27 +1017,18 @@ fun SharedBrightnessSlider(
saturation: Float,
value: Float,
onValueChanged: (Float) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
gestureKey: Any? = Unit
) {
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)
}
modifier = modifier.pointerInput(gestureKey) {
awaitSharedColorPickerDrag { offset ->
val nextValue = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
onValueChanged(nextValue)
}
}
) {
@ -1021,6 +1047,27 @@ fun SharedBrightnessSlider(
}
}
private suspend fun PointerInputScope.awaitSharedColorPickerDrag(
onPosition: (Offset) -> Unit
) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
down.consume()
onPosition(down.position)
val pointerId = down.id
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == pointerId } ?: return@awaitEachGesture
onPosition(change.position)
change.consume()
if (change.changedToUp() || !change.pressed) {
return@awaitEachGesture
}
}
}
}
@Composable
fun SharedRgbInputColumn(
label: String,

View file

@ -30,9 +30,11 @@ import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -62,6 +64,7 @@ import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.Shelf
import com.aryan.reader.shared.Tag
import com.aryan.reader.shared.cardAuthor
import com.aryan.reader.shared.cardTitle
import com.aryan.reader.shared.formatFileSize
import com.aryan.reader.shared.parseTagList
@ -131,32 +134,51 @@ fun SharedAddToShelfDialog(
shelves: List<Shelf>,
onDismiss: () -> Unit,
onCreateShelf: () -> Unit,
onShelfSelected: (Shelf) -> Unit
onShelvesSelected: (Set<String>) -> Unit
) {
var selectedShelfIds by remember(shelves) { mutableStateOf(emptySet<String>()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(readerString("desktop_add_to_shelf", "Add to shelf")) },
text = {
if (shelves.isEmpty()) {
Text(readerString("desktop_create_shelf_first", "Create a shelf first, then add selected books to it."))
Text(readerString("desktop_create_shelf_for_books", "Create a shelf and the chosen books will be added 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)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
readerString("desktop_select_shelves_to_add", "Choose one or more shelves."),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.heightIn(max = 360.dp)) {
items(shelves, key = { it.id }) { shelf ->
val selected = shelf.id in selectedShelfIds
Surface(
shape = RoundedCornerShape(8.dp),
color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth().clickable {
selectedShelfIds = selectedShelfIds.toggle(shelf.id)
}
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = selected,
onCheckedChange = {
selectedShelfIds = selectedShelfIds.toggle(shelf.id)
}
)
Spacer(Modifier.width(4.dp))
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)
}
}
}
}
@ -164,8 +186,22 @@ fun SharedAddToShelfDialog(
}
},
confirmButton = {
TextButton(onClick = onCreateShelf) {
Text(readerString("fab_new_shelf", "New shelf"))
if (shelves.isEmpty()) {
Button(onClick = onCreateShelf) {
Text(readerString("fab_new_shelf", "New shelf"))
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onCreateShelf) {
Text(readerString("fab_new_shelf", "New shelf"))
}
Button(
onClick = { onShelvesSelected(selectedShelfIds) },
enabled = selectedShelfIds.isNotEmpty()
) {
Text(readerString("action_add", "Add"))
}
}
}
},
dismissButton = {
@ -176,6 +212,112 @@ fun SharedAddToShelfDialog(
)
}
@Composable
fun SharedManageShelfBooksDialog(
shelf: Shelf,
books: List<BookItem>,
onDismiss: () -> Unit,
onSave: (Set<String>) -> Unit
) {
var query by remember(shelf.id) { mutableStateOf("") }
var selectedBookIds by remember(shelf.id, shelf.books) {
mutableStateOf(shelf.books.mapTo(linkedSetOf<String>()) { it.id }.toSet())
}
val normalizedQuery = query.trim()
val visibleBooks = remember(books, normalizedQuery) {
if (normalizedQuery.isBlank()) {
books
} else {
books.filter { book ->
book.cardTitle().contains(normalizedQuery, ignoreCase = true) ||
book.cardAuthor().contains(normalizedQuery, ignoreCase = true) ||
book.displayName.contains(normalizedQuery, ignoreCase = true) ||
book.tags.any { tag -> tag.name.contains(normalizedQuery, ignoreCase = true) }
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(readerString("desktop_manage_shelf_books_title", "Manage %1\$s", shelf.name)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SharedStableOutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text(readerString("library_search_placeholder", "Search books, authors, or tags")) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
readerString("desktop_shelf_selected_book_count", "%1\$d selected", selectedBookIds.size),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (books.isEmpty()) {
Text(readerString("your_library_empty", "Your library is empty"))
} else if (visibleBooks.isEmpty()) {
Text(readerString("desktop_no_books_match", "No books match."))
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
items(visibleBooks, key = { it.id }) { book ->
val selected = book.id in selectedBookIds
Surface(
shape = RoundedCornerShape(8.dp),
color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth().clickable {
selectedBookIds = selectedBookIds.toggle(book.id)
}
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Checkbox(
checked = selected,
onCheckedChange = {
selectedBookIds = selectedBookIds.toggle(book.id)
}
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(book.cardTitle(), maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
book.cardAuthor(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
}
},
confirmButton = {
Button(onClick = { onSave(selectedBookIds) }) {
Text(readerString("action_save", "Save"))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(readerString("action_cancel", "Cancel"))
}
}
)
}
private fun Set<String>.toggle(value: String): Set<String> {
return if (value in this) this - value else this + value
}
@Composable
fun SharedBookInfoDialog(
book: BookItem,

View file

@ -66,6 +66,7 @@ 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.SharedOpdsLocalBookMatcher
import com.aryan.reader.shared.opds.SharedOpdsDownloadState
import com.aryan.reader.shared.opds.SharedOpdsScreenState
import com.aryan.reader.shared.opds.SharedOpdsText
@ -866,9 +867,7 @@ private fun SharedOpdsCatalogDialog(
}
private fun OpdsEntry.findLocalBook(localLibraryBooks: List<BookItem>): BookItem? {
return localLibraryBooks.firstOrNull {
it.title.equals(title, ignoreCase = true) || it.displayName.equals(title, ignoreCase = true)
}
return SharedOpdsLocalBookMatcher.findBook(this, localLibraryBooks)
}
@Composable

View file

@ -39,6 +39,7 @@ 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.sharedPdfRichTextSelectionBounds
import com.aryan.reader.shared.pdf.withoutTrailingSharedPdfPageBreak
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
@ -202,10 +203,12 @@ fun SharedPdfRichTextLayer(
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) {
sharedPdfRichTextSelectionBounds(
selectionStart = selection.start,
selectionEnd = selection.end,
textLength = textToRender.length
)?.let { (localStart, localEnd) ->
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
Canvas(modifier = Modifier.fillMaxSize()) {
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
@ -213,6 +216,7 @@ fun SharedPdfRichTextLayer(
}
if (selection.collapsed && controller.isCursorVisible) {
val localStart = selection.start.coerceIn(0, textToRender.length)
val alpha = if (isScrolling) {
1f
} else {

View file

@ -13,6 +13,7 @@ internal data class SharedReaderModalAnchorBounds(
)
internal val LocalSharedReaderModalAnchorBounds = compositionLocalOf<SharedReaderModalAnchorBounds?> { null }
internal val LocalSharedReaderModalFocusableOverride = compositionLocalOf<Boolean?> { null }
internal enum class SharedReaderModalLevel {
Panel,

View file

@ -33,6 +33,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
@ -157,6 +158,11 @@ fun SharedReaderVerticalScrollbar(
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
label = "sharedReaderScrollbarHeight"
)
val currentScrollbarTrackHeight by rememberUpdatedState(scrollbarTrackHeight)
val currentScrollbarContentHeight by rememberUpdatedState(state.contentHeightPx)
val currentScrollbarViewportHeight by rememberUpdatedState(state.viewportHeightPx)
val currentScrollbarProgress by rememberUpdatedState(state.progress)
val currentScrollbarActiveThumbHeight by rememberUpdatedState(with(density) { scrollbarActiveHeight.toPx() })
Box(
modifier = modifier
@ -166,6 +172,68 @@ fun SharedReaderVerticalScrollbar(
.onGloballyPositioned { coordinates ->
scrollbarTrackHeight = coordinates.size.height.toFloat()
}
.pointerInput(listState) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
try {
isDraggingScrollbar = true
scrollbarVisible = true
scrollInteractionTick += 1
down.consume()
val initialContentHeight = currentScrollbarContentHeight
val initialViewportHeight = currentScrollbarViewportHeight
val initialMaxScroll = (initialContentHeight - initialViewportHeight).coerceAtLeast(0f)
val initialTrackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
?: initialViewportHeight
val initialThumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
val initialTrackSpace = (initialTrackHeight - initialThumbHeight).coerceAtLeast(1f)
val initialThumbTop = (initialTrackSpace * currentScrollbarProgress)
.coerceIn(0f, initialTrackSpace)
val downY = down.position.y.coerceIn(0f, initialTrackHeight)
val thumbDragOffset = if (downY in initialThumbTop..(initialThumbTop + initialThumbHeight)) {
downY - initialThumbTop
} else {
initialThumbHeight / 2f
}
var lastScrollPx = currentScrollbarProgress * initialMaxScroll
fun scrollToPointer(pointerY: Float) {
val contentHeight = currentScrollbarContentHeight
val viewportHeight = currentScrollbarViewportHeight
val maxScroll = (contentHeight - viewportHeight).coerceAtLeast(0f)
if (maxScroll <= 0f) return
val trackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
?: viewportHeight
val thumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
val trackSpace = (trackHeight - thumbHeight).coerceAtLeast(1f)
val targetThumbTop = (pointerY - thumbDragOffset).coerceIn(0f, trackSpace)
val targetScrollPx = (targetThumbTop / trackSpace) * maxScroll
val scrollDelta = targetScrollPx - lastScrollPx
if (abs(scrollDelta) > 0.01f) {
listState.dispatchRawDelta(scrollDelta)
scrollInteractionTick += 1
}
lastScrollPx = targetScrollPx
}
scrollToPointer(down.position.y)
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
if (change.position.y != change.previousPosition.y) {
change.consume()
scrollToPointer(change.position.y)
}
}
} finally {
isDraggingScrollbar = false
scrollInteractionTick += 1
}
}
}
) {
val thumbHeightPx = with(density) { barHeight.toPx() }
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
@ -184,39 +252,6 @@ fun SharedReaderVerticalScrollbar(
modifier = Modifier
.height(barHeight)
.width(36.dp)
.pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
try {
isDraggingScrollbar = true
scrollbarVisible = true
scrollInteractionTick += 1
down.consume()
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
val deltaY = change.position.y - change.previousPosition.y
if (deltaY != 0f) {
change.consume()
val trackHeight = scrollbarTrackHeight.takeIf { it > 0f }
?: state.viewportHeightPx
val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() })
.coerceAtLeast(1f)
val scrollDelta = (deltaY / trackSpace) *
(state.contentHeightPx - state.viewportHeightPx)
listState.dispatchRawDelta(scrollDelta)
scrollInteractionTick += 1
}
}
} finally {
isDraggingScrollbar = false
scrollInteractionTick += 1
}
}
}
) {
Box(
modifier = Modifier
@ -314,6 +349,11 @@ fun SharedPdfVerticalScrollbar(
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
label = "sharedPdfScrollbarHeight"
)
val currentScrollbarTrackHeight by rememberUpdatedState(scrollbarTrackHeight)
val currentScrollbarContentHeight by rememberUpdatedState(state.contentHeightPx)
val currentScrollbarViewportHeight by rememberUpdatedState(state.viewportHeightPx)
val currentScrollbarProgress by rememberUpdatedState(state.progress)
val currentScrollbarActiveThumbHeight by rememberUpdatedState(with(density) { scrollbarActiveHeight.toPx() })
val safeCurrentPage = if (pageCount > 0) currentPage.coerceIn(0, pageCount - 1) else 0
Box(
@ -324,6 +364,68 @@ fun SharedPdfVerticalScrollbar(
.onGloballyPositioned { coordinates ->
scrollbarTrackHeight = coordinates.size.height.toFloat()
}
.pointerInput(listState) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
try {
isDraggingScrollbar = true
scrollbarVisible = true
scrollInteractionTick += 1
down.consume()
val initialContentHeight = currentScrollbarContentHeight
val initialViewportHeight = currentScrollbarViewportHeight
val initialMaxScroll = (initialContentHeight - initialViewportHeight).coerceAtLeast(0f)
val initialTrackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
?: initialViewportHeight
val initialThumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
val initialTrackSpace = (initialTrackHeight - initialThumbHeight).coerceAtLeast(1f)
val initialThumbTop = (initialTrackSpace * currentScrollbarProgress)
.coerceIn(0f, initialTrackSpace)
val downY = down.position.y.coerceIn(0f, initialTrackHeight)
val thumbDragOffset = if (downY in initialThumbTop..(initialThumbTop + initialThumbHeight)) {
downY - initialThumbTop
} else {
initialThumbHeight / 2f
}
var lastScrollPx = currentScrollbarProgress * initialMaxScroll
fun scrollToPointer(pointerY: Float) {
val contentHeight = currentScrollbarContentHeight
val viewportHeight = currentScrollbarViewportHeight
val maxScroll = (contentHeight - viewportHeight).coerceAtLeast(0f)
if (maxScroll <= 0f) return
val trackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
?: viewportHeight
val thumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
val trackSpace = (trackHeight - thumbHeight).coerceAtLeast(1f)
val targetThumbTop = (pointerY - thumbDragOffset).coerceIn(0f, trackSpace)
val targetScrollPx = (targetThumbTop / trackSpace) * maxScroll
val scrollDelta = targetScrollPx - lastScrollPx
if (abs(scrollDelta) > 0.01f) {
listState.dispatchRawDelta(scrollDelta)
scrollInteractionTick += 1
}
lastScrollPx = targetScrollPx
}
scrollToPointer(down.position.y)
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
if (change.position.y != change.previousPosition.y) {
change.consume()
scrollToPointer(change.position.y)
}
}
} finally {
isDraggingScrollbar = false
scrollInteractionTick += 1
}
}
}
) {
val thumbHeightPx = with(density) { barHeight.toPx() }
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
@ -367,39 +469,6 @@ fun SharedPdfVerticalScrollbar(
modifier = Modifier
.height(barHeight)
.width(48.dp)
.pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
try {
isDraggingScrollbar = true
scrollbarVisible = true
scrollInteractionTick += 1
down.consume()
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
val deltaY = change.position.y - change.previousPosition.y
if (deltaY != 0f) {
change.consume()
val trackHeight = scrollbarTrackHeight.takeIf { it > 0f }
?: state.viewportHeightPx
val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() })
.coerceAtLeast(1f)
val scrollDelta = (deltaY / trackSpace) *
(state.contentHeightPx - state.viewportHeightPx)
listState.dispatchRawDelta(scrollDelta)
scrollInteractionTick += 1
}
}
} finally {
isDraggingScrollbar = false
scrollInteractionTick += 1
}
}
}
) {
Box(
modifier = Modifier

View file

@ -0,0 +1,379 @@
package com.aryan.reader.shared.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderCloudTtsState
import com.aryan.reader.shared.ReaderTtsReadScope
import com.aryan.reader.shared.readerCloudTtsControlsModel
import com.aryan.reader.shared.readerCloudTtsVoiceById
@Composable
fun SharedReaderTtsOverlayControls(
settings: ReaderAiByokSettings,
cloudTts: ReaderCloudTtsState,
credits: Int,
showCredits: Boolean,
isCollapsed: Boolean,
onCollapseChange: (Boolean) -> Unit,
onPauseResume: () -> Unit,
onSkipPrevious: () -> Unit,
onSkipNext: () -> Unit,
onLocateCurrentChunk: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
val sanitized = settings.sanitized()
val controls = readerCloudTtsControlsModel(cloudTts)
if (!controls.isVisible) return
val voice = remember(sanitized.ttsSpeakerId) { readerCloudTtsVoiceById(sanitized.ttsSpeakerId) }
val progress = cloudTts.progress
val chunk = progress.currentChunk
val chunkLabel = if (progress.currentChunkIndex >= 0 && progress.chunks.isNotEmpty()) {
readerString(
"desktop_tts_chunk_count",
"Part %1\$d/%2\$d",
progress.currentChunkIndex + 1,
progress.chunks.size
)
} else {
null
}
val progressFraction = if (progress.currentChunkIndex >= 0 && progress.chunks.isNotEmpty()) {
((progress.currentChunkIndex + 1).toFloat() / progress.chunks.size).coerceIn(0f, 1f)
} else {
null
}
val title = when {
cloudTts.isLoading -> readerString("desktop_preparing_audio", "Preparing audio")
cloudTts.isPaused -> readerString("desktop_paused", "Paused")
cloudTts.isPlaying -> readerString("label_reading", "Reading")
else -> readerString("action_read_aloud", "Read aloud")
}
val chapterLabel = chunk?.chapterTitle
?.lineSequence()
?.map { it.trim() }
?.filter { it.isNotBlank() }
?.joinToString(" - ")
?.takeIf { it.isNotBlank() }
val statusLine = cloudTts.errorMessage
?: progress.currentPositionLabel
?: cloudTts.statusMessage
?: chapterLabel
?: voice?.let { "${it.name}: ${it.description}" }
?: ""
val scopeLabel = readerTtsScopeLabel(progress.scope)
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
modifier = modifier.widthIn(max = 560.dp).animateContentSize()
) {
AnimatedContent(
targetState = isCollapsed,
transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
label = "SharedReaderTtsOverlay"
) { collapsed ->
if (collapsed) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
IconButton(
onClick = { onCollapseChange(false) },
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Default.KeyboardArrowLeft,
contentDescription = readerString("content_desc_expand", "Expand"),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Column(
modifier = Modifier.widthIn(max = 220.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
Text(
title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
chunkLabel ?: scopeLabel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
SharedReaderTtsPlayPauseButton(
isPlaying = cloudTts.isPlaying,
isLoading = cloudTts.isLoading,
enabled = controls.canPauseResume,
size = 36,
iconSize = 20,
onClick = onPauseResume
)
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Default.Close,
contentDescription = readerString("content_desc_stop_tts", "Stop read aloud"),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
} else {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (statusLine.isNotBlank()) {
Text(
statusLine,
style = MaterialTheme.typography.labelMedium,
color = if (cloudTts.errorMessage != null) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
IconButton(
enabled = controls.canLocateCurrentChunk,
onClick = onLocateCurrentChunk,
modifier = Modifier.size(34.dp)
) {
Icon(
Icons.Default.MyLocation,
contentDescription = readerString("desktop_locate_current_tts", "Locate current reading"),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(34.dp)) {
Icon(
Icons.Default.KeyboardArrowRight,
contentDescription = readerString("content_desc_collapse", "Collapse"),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onClose, modifier = Modifier.size(34.dp)) {
Icon(
Icons.Default.Close,
contentDescription = readerString("content_desc_stop_tts", "Stop read aloud"),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
SharedReaderTtsPill(readerString("tts_mode_cloud_ai", "Cloud AI"))
voice?.let { SharedReaderTtsPill(it.name) }
if (showCredits) {
SharedReaderTtsPill(readerString("credits_count", "%1\$d credits", credits))
}
SharedReaderTtsPill(chunkLabel ?: scopeLabel)
}
progressFraction?.let { fraction ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.18f))
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.primary)
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
IconButton(
enabled = controls.canSkipPrevious,
onClick = onSkipPrevious,
modifier = Modifier.size(44.dp)
) {
Icon(
Icons.Default.SkipPrevious,
contentDescription = readerString("content_desc_tts_previous_chunk", "Previous chunk"),
modifier = Modifier.size(24.dp)
)
}
Spacer(Modifier.width(10.dp))
SharedReaderTtsPlayPauseButton(
isPlaying = cloudTts.isPlaying,
isLoading = cloudTts.isLoading,
enabled = controls.canPauseResume,
size = 56,
iconSize = 28,
onClick = onPauseResume
)
Spacer(Modifier.width(10.dp))
IconButton(
enabled = controls.canSkipNext,
onClick = onSkipNext,
modifier = Modifier.size(44.dp)
) {
Icon(
Icons.Default.SkipNext,
contentDescription = readerString("content_desc_tts_next_chunk", "Next chunk"),
modifier = Modifier.size(24.dp)
)
}
}
}
}
}
}
}
@Composable
private fun SharedReaderTtsPill(text: String) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
shape = RoundedCornerShape(8.dp)
) {
Text(
text,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp)
)
}
}
@Composable
private fun SharedReaderTtsPlayPauseButton(
isPlaying: Boolean,
isLoading: Boolean,
enabled: Boolean,
size: Int,
iconSize: Int,
onClick: () -> Unit
) {
Box(modifier = Modifier.size(size.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
enabled = enabled,
onClick = onClick,
modifier = Modifier.size(size.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = readerString("content_desc_play_pause", "Play or pause"),
modifier = Modifier.size(iconSize.dp)
)
}
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(size.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
strokeWidth = if (size >= 56) 3.dp else 2.dp
)
}
}
}
@Composable
private fun readerTtsScopeLabel(scope: ReaderTtsReadScope): String {
return when (scope) {
ReaderTtsReadScope.PAGE -> readerString("desktop_page", "Page")
ReaderTtsReadScope.CHAPTER -> readerString("chapter", "Chapter")
ReaderTtsReadScope.BOOK -> readerString("desktop_from_here", "From here")
}
}

View file

@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.BuiltInPdfReaderThemes
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.ReaderAction
import com.aryan.reader.shared.ReaderTheme
import com.aryan.reader.shared.ReaderToolbarPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.SharedSettingsAction
@ -78,6 +79,8 @@ fun SharedSettingsHub(
onReaderToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit = {},
customFonts: List<CustomFontItem> = emptyList(),
onPickCustomFont: (() -> String?)? = null,
customReaderThemes: List<ReaderTheme> = emptyList(),
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit = {},
readerCustomTextureIds: List<String> = emptyList(),
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)? = null,
showTopBar: Boolean = true,
@ -167,6 +170,8 @@ fun SharedSettingsHub(
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange,
customFonts = customFonts,
onPickCustomFont = onPickCustomFont,
customReaderThemes = customReaderThemes,
onCustomReaderThemesChange = onCustomReaderThemesChange,
readerCustomTextureIds = readerCustomTextureIds,
onImportReaderTexture = onImportReaderTexture,
modifier = Modifier.weight(1f)
@ -490,6 +495,8 @@ private fun SharedSettingsDetailPage(
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
customFonts: List<CustomFontItem>,
onPickCustomFont: (() -> String?)?,
customReaderThemes: List<ReaderTheme>,
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit,
readerCustomTextureIds: List<String>,
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?,
modifier: Modifier
@ -510,7 +517,6 @@ private fun SharedSettingsDetailPage(
SharedSettingsDestination.EPUB_FORMAT -> {
SharedReaderFormatControls(
settings = settings,
toolbarPreferences = ReaderToolbarPreferences(),
onPickCustomFont = onPickCustomFont,
customFonts = customFonts,
onReaderAction = { action ->
@ -521,6 +527,8 @@ private fun SharedSettingsDetailPage(
SharedSettingsDestination.EPUB_THEME_TEXTURE -> {
SharedReaderThemeControls(
settings = settings,
customThemes = customReaderThemes,
onCustomThemesChange = onCustomReaderThemesChange,
customTextureIds = readerCustomTextureIds,
onImportTexture = onImportReaderTexture,
onSettingsChange = onSettingsChange
@ -547,15 +555,25 @@ private fun SharedSettingsDetailPage(
SharedReaderThemeControls(
settings = pdfSettings,
builtInThemes = BuiltInPdfReaderThemes,
customThemes = customReaderThemes,
onCustomThemesChange = onCustomReaderThemesChange,
customTextureIds = readerCustomTextureIds,
onImportTexture = onImportReaderTexture,
onSettingsChange = onPdfSettingsChange
)
HorizontalDivider()
Text(readerString("visual_options_title", "Visual options"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
SharedPdfVisualOptionDefaultsSwitch(
title = readerString("menu_right_to_left_pagination", "Paginated (right-to-left)"),
summary = readerString("visual_options_right_to_left_pagination_desc", "Uses right-to-left page order when PDF pagination mode is active."),
checked = pdfSettings.rightToLeftPagination,
onCheckedChange = { enabled ->
onPdfSettingsChange(pdfSettings.copy(rightToLeftPagination = enabled))
}
)
SharedPdfVisualOptionDefaultsSwitch(
title = readerString("visual_options_remove_page_gap", "Remove gap between pages"),
summary = readerString("desktop_remove_gap_between_pages_desc", "Applies to vertical reading mode."),
summary = readerString("desktop_remove_gap_between_pages_desc", "Applies to vertical reading and two-page spreads."),
checked = !pdfSettings.pdfVerticalPageGapVisible,
onCheckedChange = { removeGap ->
onPdfSettingsChange(pdfSettings.copy(pdfVerticalPageGapVisible = !removeGap))

View file

@ -32,8 +32,11 @@ 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.FileOpen
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Policy
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material3.AlertDialog
@ -494,6 +497,9 @@ fun SharedAboutScreen(
buildLabel: String,
onOpenSource: (() -> Unit)? = null,
onOpenIssues: (() -> Unit)? = null,
onOpenPrivacyPolicy: (() -> Unit)? = null,
onOpenTerms: (() -> Unit)? = null,
onOpenLicenses: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
SharedScreenScaffold(
@ -544,6 +550,30 @@ fun SharedAboutScreen(
onClick = onOpenIssues
)
}
if (onOpenPrivacyPolicy != null) {
SharedUtilityOptionCard(
title = readerString("legal_privacy_policy", "Privacy Policy"),
body = readerString("about_privacy_desc", "How Episteme handles data for this edition."),
icon = { Icon(Icons.Default.Policy, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onOpenPrivacyPolicy
)
}
if (onOpenTerms != null) {
SharedUtilityOptionCard(
title = readerString("legal_terms_of_service", "Terms of Service"),
body = readerString("about_terms_desc", "Usage terms and conditions."),
icon = { Icon(Icons.Default.Gavel, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onOpenTerms
)
}
if (onOpenLicenses != null) {
SharedUtilityOptionCard(
title = readerString("legal_licenses", "Licenses"),
body = readerString("about_licenses_desc", "Open source libraries used."),
icon = { Icon(Icons.Default.FileOpen, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onOpenLicenses
)
}
}
}

View file

@ -0,0 +1,122 @@
package com.aryan.reader.shared
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CloudSyncDecisionsTest {
@Test
fun `newer remote metadata applies over local metadata`() {
assertEquals(
SharedCloudBookMetadataWinner.REMOTE,
sharedCloudBookMetadataWinner(
localModifiedTimestamp = 100L,
remoteModifiedTimestamp = 200L
)
)
assertTrue(
shouldApplyRemoteCloudBookUpdate(
localModifiedTimestamp = 100L,
remoteModifiedTimestamp = 200L
)
)
}
@Test
fun `newer local sidecar wins even when book metadata is older`() {
assertEquals(
SharedCloudBookMetadataWinner.LOCAL,
sharedCloudBookMetadataWinner(
localModifiedTimestamp = 100L,
remoteModifiedTimestamp = 200L,
localSidecarModifiedTimestamp = 300L
)
)
assertEquals(
SharedCloudBookMetadataWinner.REMOTE,
sharedCloudBookReadingMetadataWinner(
localModifiedTimestamp = 100L,
remoteModifiedTimestamp = 200L
)
)
assertTrue(
shouldUploadLocalCloudBookUpdate(
localModifiedTimestamp = 100L,
remoteModifiedTimestamp = 200L,
localSidecarModifiedTimestamp = 300L
)
)
assertTrue(
shouldApplyRemoteCloudBookMetadataUpdate(
localModifiedTimestamp = 100L,
remoteModifiedTimestamp = 200L
)
)
}
@Test
fun `stale remote metadata is ignored`() {
assertFalse(
shouldApplyRemoteCloudBookUpdate(
localModifiedTimestamp = 300L,
remoteModifiedTimestamp = 200L
)
)
assertTrue(
shouldUploadLocalCloudBookUpdate(
localModifiedTimestamp = 300L,
remoteModifiedTimestamp = 200L
)
)
}
@Test
fun `content sync only moves changed file payloads`() {
assertTrue(
shouldDownloadRemoteCloudBookContent(
localFileAvailable = true,
localContentModifiedTimestamp = 100L,
remoteContentModifiedTimestamp = 200L
)
)
assertTrue(
shouldDownloadRemoteCloudBookContent(
localFileAvailable = false,
localContentModifiedTimestamp = 0L,
remoteContentModifiedTimestamp = 200L
)
)
assertFalse(
shouldDownloadRemoteCloudBookContent(
localFileAvailable = true,
localContentModifiedTimestamp = 300L,
remoteContentModifiedTimestamp = 200L
)
)
assertFalse(
shouldDownloadRemoteCloudBookContent(
localFileAvailable = false,
localContentModifiedTimestamp = 0L,
remoteContentModifiedTimestamp = 200L,
remoteDeleted = true
)
)
assertTrue(
shouldUploadLocalCloudBookContent(
localFileAvailable = true,
localContentModifiedTimestamp = 300L,
remoteContentModifiedTimestamp = 200L
)
)
}
@Test
fun `cloud book content file name uses shared primary extension`() {
assertEquals("book-1.epub", sharedCloudBookContentFileName("book-1", FileType.EPUB))
assertEquals("book-1.md", sharedCloudBookContentFileName("book-1", FileType.MD))
assertEquals("book-1.mobi", sharedCloudBookContentFileName("book-1", FileType.MOBI))
assertEquals(null, sharedCloudBookContentFileName("book-1", FileType.UNKNOWN))
}
}

View file

@ -165,6 +165,8 @@ class EpubAnnotationSerializerTest {
val oldDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:7:123456:abc")
val timestampFallbackLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:7:1780000000000")
val rangedDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:40:55")
val scrollWrappedDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop-scroll:5238:5238:desktop:2:40:55")
val androidLocator = ReaderLocator.fromLegacy(cfi = "android-locator:3:42:128")
assertEquals(2, oldDesktopLocator.chapterIndex)
assertEquals(7, oldDesktopLocator.pageIndex)
@ -174,5 +176,26 @@ class EpubAnnotationSerializerTest {
assertEquals(2, rangedDesktopLocator.chapterIndex)
assertEquals(40, rangedDesktopLocator.startOffset)
assertEquals(55, rangedDesktopLocator.endOffset)
assertEquals(2, scrollWrappedDesktopLocator.chapterIndex)
assertEquals(40, scrollWrappedDesktopLocator.startOffset)
assertEquals(55, scrollWrappedDesktopLocator.endOffset)
assertEquals("desktop:2:40:55", scrollWrappedDesktopLocator.cfi)
assertEquals(3, androidLocator.chapterIndex)
assertEquals(42, androidLocator.blockIndex)
assertEquals(128, androidLocator.charOffset)
}
@Test
fun `android locator with quote hydrates absolute text range for synced highlights`() {
val locator = ReaderLocator.fromLegacy(
cfi = "android-locator:3:42:128",
textQuote = "marked"
)
assertEquals(3, locator.chapterIndex)
assertEquals(42, locator.blockIndex)
assertEquals(128, locator.charOffset)
assertEquals(128, locator.startOffset)
assertEquals(134, locator.endOffset)
}
}

View file

@ -34,6 +34,7 @@ class FileCapabilitiesTest {
SharedFileCapabilities.mimeTypeFor(FileType.PPTX)
)
assertTrue("application/pdf" in SharedFileCapabilities.androidFilePickerMimeTypes)
assertTrue("application/x-tar" in SharedFileCapabilities.androidFilePickerMimeTypes)
assertTrue("text/x-kotlin" in SharedFileCapabilities.androidFilePickerMimeTypes)
assertFalse("*/*" in SharedFileCapabilities.androidFilePickerMimeTypes)
assertEquals(
@ -48,6 +49,7 @@ class FileCapabilitiesTest {
FileType.CBZ,
FileType.CBR,
FileType.CB7,
FileType.CBT,
FileType.DOCX,
FileType.PPTX,
FileType.ODT,
@ -85,14 +87,16 @@ class FileCapabilitiesTest {
)
assertEquals(
ReaderFeatureSurface.PDF_VIEWER,
SharedFileCapabilities.surfaceFor(FileType.CBR, ReaderPlatform.DESKTOP)
SharedFileCapabilities.surfaceFor(FileType.CBT, ReaderPlatform.DESKTOP)
)
assertEquals(
ReaderFeatureSurface.EPUB_READER,
SharedFileCapabilities.surfaceFor(FileType.MD, ReaderPlatform.ANDROID)
)
assertTrue(SharedFileCapabilities.canOpen(FileType.CBZ, ReaderPlatform.ANDROID))
assertTrue(SharedFileCapabilities.canOpen(FileType.CBZ, ReaderPlatform.DESKTOP))
assertTrue(SharedFileCapabilities.canOpen(FileType.CBT, ReaderPlatform.ANDROID))
assertTrue(SharedFileCapabilities.canOpen(FileType.CBT, ReaderPlatform.DESKTOP))
assertTrue(SharedFileCapabilities.isComicArchive(FileType.CBT))
}
@Test
@ -102,6 +106,7 @@ class FileCapabilitiesTest {
assertEquals(FileType.HTML, "chapter.xhtml".toFileType())
assertEquals(FileType.MOBI, SharedFileCapabilities.fileTypeForName("book.azw3"))
assertEquals(FileType.FB2, SharedFileCapabilities.fileTypeForName("book.fb2.zip"))
assertEquals(FileType.CBT, SharedFileCapabilities.fileTypeForName("comic.cbt"))
assertEquals(FileType.PPTX, SharedFileCapabilities.fileTypeForName("slides.pptx"))
assertEquals(FileType.HTML, SharedFileCapabilities.fileTypeForName("payload.json.txt"))
assertEquals(FileType.EPUB, SharedFileCapabilities.fileTypeForName("book.epub.txt"))
@ -124,8 +129,10 @@ class FileCapabilitiesTest {
)
assertEquals(FileType.HTML, SharedFileCapabilities.resolveFileTypeForMetadata("payload", "application/json"))
assertEquals(FileType.CBZ, SharedFileCapabilities.resolveFileTypeForMetadata("comic.cbz", "application/zip"))
assertEquals(FileType.CBT, SharedFileCapabilities.resolveFileTypeForMetadata("comic.cbt", "application/x-tar"))
assertEquals(FileType.FB2, SharedFileCapabilities.resolveFileTypeForMetadata("book.fb2.zip", "application/zip"))
assertNull(SharedFileCapabilities.resolveFileTypeForMetadata("archive.zip", "application/zip"))
assertNull(SharedFileCapabilities.resolveFileTypeForMetadata("archive.tar", "application/x-tar"))
}
@Test

View file

@ -270,6 +270,36 @@ class LocalFolderSyncEngineTest {
assertFalse(FileType.UNKNOWN in SyncedFolder("C:/Library", "Library", lastScanTime = 0L).allowedFileTypes)
}
@Test
fun `disabled synced folder does not import files or metadata`() {
val existing = book(
id = "local_Book.pdf",
timestamp = 100L,
progress = 10f
)
val result = LocalFolderSyncEngine.syncFolder(
state = SharedReaderScreenState(
rawLibraryBooks = listOf(existing),
syncedFolders = listOf(syncedFolder().copy(localSyncEnabled = false))
),
folder = syncedFolder().copy(localSyncEnabled = false),
files = listOf(scannedFile("New.pdf", "New.pdf")),
remoteMetadata = mapOf(
"local_Book.pdf" to metadata(
id = "local_Book.pdf",
progress = 80f,
modified = 500L
)
),
nowMillis = 1_000L
)
assertEquals(listOf(existing), result.state.rawLibraryBooks)
assertEquals(0, result.stats.newBooks)
assertEquals(0, result.stats.remoteMetadataUpdates)
assertTrue(result.removedBookIds.isEmpty())
}
@Test
fun `metadata sidecar is skipped for clean unread folder books`() {
assertNull(book(id = "local_Book.pdf", isRecent = false, progress = null).toSharedFolderBookMetadata())
@ -305,6 +335,36 @@ class LocalFolderSyncEngineTest {
assertEquals(locator, restored.readerPosition)
}
@Test
fun `metadata sidecar preserves android block reader position`() {
val locator = ReaderLocator(
chapterIndex = 1,
pageIndex = 5,
blockIndex = 44,
charOffset = 120,
cfi = "android-locator:1:44:120"
)
val metadata = book(
id = "local_Book.epub",
type = FileType.EPUB,
progress = 37f,
readerPosition = locator
).toSharedFolderBookMetadata() ?: error("Expected sidecar")
val restored = metadata.toBookItem(
file = scannedFile("Book.epub", "Book.epub"),
existing = null,
nowMillis = 2_000L
)
assertEquals(1, metadata.lastChapterIndex)
assertEquals(5, metadata.lastPage)
assertEquals("android-locator:1:44:120", metadata.lastPositionCfi)
assertEquals(44, metadata.locatorBlockIndex)
assertEquals(120, metadata.locatorCharOffset)
assertEquals(locator, restored.readerPosition)
}
@Test
fun `metadata sidecar ignores legacy editable metadata`() {
val local = book(id = "local_Book.pdf")
@ -451,6 +511,7 @@ class LocalFolderSyncEngineTest {
sourceFolder: String = "C:/Library",
timestamp: Long = 100L,
title: String = "Book",
type: FileType = FileType.PDF,
progress: Float? = null,
isRecent: Boolean = false,
fileSize: Long = 0L,
@ -461,7 +522,7 @@ class LocalFolderSyncEngineTest {
return BookItem(
id = id,
path = path,
type = FileType.PDF,
type = type,
displayName = displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,

View file

@ -1,6 +1,15 @@
package com.aryan.reader.shared
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.SharedReaderTextAlign
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@ -31,6 +40,74 @@ class ReaderAppearanceModelsTest {
)
}
@Test
fun `pdf and epub built in themes share the standard reader palette`() {
data class ThemeToken(
val name: String,
val backgroundArgb: Int,
val textArgb: Int,
val isDark: Boolean,
val textureId: String?
)
val epubPalette = BuiltInReaderThemes
.drop(1)
.map { theme ->
ThemeToken(
name = theme.name,
backgroundArgb = theme.backgroundColor.toArgb(),
textArgb = theme.textColor.toArgb(),
isDark = theme.isDark,
textureId = theme.textureId
)
}
val pdfPalette = BuiltInPdfReaderThemes
.drop(2)
.map { theme ->
ThemeToken(
name = theme.name,
backgroundArgb = theme.backgroundColor.toArgb(),
textArgb = theme.textColor.toArgb(),
isDark = theme.isDark,
textureId = theme.textureId
)
}
assertEquals(epubPalette, pdfPalette)
}
@Test
fun `pdf highlighter defaults follow android pdf highlight slots`() {
val expectedPdfColors = SharedPdfAndroidHighlightColors.palette.take(4)
assertEquals(4, SharedPdfHighlighterPalette.MaxColors)
assertEquals(expectedPdfColors, SharedPdfHighlighterPalette.defaultColors)
assertEquals(expectedPdfColors[0], SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER).colorArgb)
assertEquals(expectedPdfColors[1], SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER_ROUND).colorArgb)
val custom = SharedPdfHighlighterPalette(
colors = expectedPdfColors + listOf(0xFFFF00FF.toInt())
).sanitized()
assertEquals(4, custom.colors.size)
assertEquals(expectedPdfColors, custom.colors)
}
@Test
fun `custom reader themes keep android persisted shape`() {
val first = ReaderTheme(
id = "custom",
name = "Custom",
backgroundColor = Color(0xFFF5F5F5),
textColor = Color(0xFF111111),
isDark = false,
isCustom = true
)
val replacement = first.copy(name = "Replacement")
val builtIn = BuiltInReaderThemes.first().copy(isCustom = false)
assertEquals(listOf(replacement), listOf(first, builtIn, replacement).sanitizeCustomReaderThemes())
}
@Test
fun `reader textures expose shared desktop resource paths`() {
assertTrue(ReaderTexture.entries.all { it.assetPath.startsWith("textures/") })
@ -65,4 +142,82 @@ class ReaderAppearanceModelsTest {
assertEquals(theme.backgroundColor.toArgb().toLong(), settings.backgroundColorArgb)
assertEquals(theme.textColor.toArgb().toLong(), settings.textColorArgb)
}
@Test
fun `reset reader format settings keeps reader mode and appearance choices`() {
val settings = ReaderSettings(
fontSize = 26,
lineSpacing = 2.0f,
margin = 96,
horizontalMargin = 128,
verticalMargin = 72,
textAlign = SharedReaderTextAlign.RIGHT,
pageWidth = 1040,
fontFamily = "Imported",
customFontPath = "C:\\fonts\\Imported.ttf",
paragraphSpacing = 2.2f,
imageScale = 1.8f,
readingMode = ReaderReadingMode.PAGINATED,
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
themeId = "sepia",
textureId = ReaderTexture.PAPER.id,
textureAlpha = 0.35f,
darkMode = true,
backgroundColorArgb = 0xFF101010,
textColorArgb = 0xFFEAEAEA
)
val reset = settings.resetReaderFormatSettings()
val defaults = ReaderSettings()
assertEquals(defaults.fontSize, reset.fontSize)
assertEquals(defaults.lineSpacing, reset.lineSpacing)
assertEquals(defaults.margin, reset.margin)
assertEquals(defaults.horizontalMargin, reset.horizontalMargin)
assertEquals(defaults.verticalMargin, reset.verticalMargin)
assertEquals(defaults.textAlign, reset.textAlign)
assertEquals(defaults.pageWidth, reset.pageWidth)
assertEquals(defaults.fontFamily, reset.fontFamily)
assertEquals(defaults.customFontPath, reset.customFontPath)
assertEquals(defaults.paragraphSpacing, reset.paragraphSpacing)
assertEquals(defaults.imageScale, reset.imageScale)
assertEquals(ReaderReadingMode.PAGINATED, reset.readingMode)
assertEquals(ReaderPageSpreadMode.TWO_PAGE, reset.pageSpreadMode)
assertEquals("sepia", reset.themeId)
assertEquals(ReaderTexture.PAPER.id, reset.textureId)
assertEquals(0.35f, reset.textureAlpha)
assertEquals(true, reset.darkMode)
assertEquals(0xFF101010, reset.backgroundColorArgb)
assertEquals(0xFFEAEAEA, reset.textColorArgb)
}
@Test
fun `axis margin updates keep the opposite axis fixed`() {
val defaultSettings = ReaderSettings()
val horizontalOnly = defaultSettings.withHorizontalReaderMargin(96)
assertEquals(96, horizontalOnly.resolvedHorizontalMargin)
assertEquals(defaultSettings.resolvedVerticalMargin, horizontalOnly.resolvedVerticalMargin)
assertEquals(96, horizontalOnly.margin)
assertEquals(defaultSettings.resolvedVerticalMargin, horizontalOnly.verticalMargin)
val verticalOnly = horizontalOnly.withVerticalReaderMargin(24)
assertEquals(96, verticalOnly.resolvedHorizontalMargin)
assertEquals(24, verticalOnly.resolvedVerticalMargin)
assertEquals(96, verticalOnly.margin)
assertEquals(96, verticalOnly.horizontalMargin)
}
@Test
fun `page width format control is only shown for paginated mode`() {
assertEquals(
false,
ReaderSettings(readingMode = ReaderReadingMode.VERTICAL).shouldShowPageWidthFormatControl()
)
assertEquals(
true,
ReaderSettings(readingMode = ReaderReadingMode.PAGINATED).shouldShowPageWidthFormatControl()
)
}
}

View file

@ -0,0 +1,103 @@
package com.aryan.reader.shared
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ReaderBookReplacementEngineTest {
@Test
fun `book replacements apply only to matching file id`() {
val preferences = ReaderBookReplacementPreferences(
fileRules = mapOf(
"book-a" to listOf(rule(from = "Alice", to = "Alicia")),
"book-b" to listOf(rule(from = "Alice", to = "Alix")),
),
)
assertEquals(
"Alicia looked around.",
ReaderBookReplacementEngine.apply("Alice looked around.", preferences, "book-a").text,
)
assertEquals(
"Alix looked around.",
ReaderBookReplacementEngine.apply("Alice looked around.", preferences, "book-b").text,
)
assertEquals(
"Alice looked around.",
ReaderBookReplacementEngine.apply("Alice looked around.", preferences, "missing").text,
)
}
@Test
fun `book replacements have no global fallback`() {
val preferences = ReaderBookReplacementPreferences(
fileRules = mapOf("" to listOf(rule(from = "global", to = "local"))),
)
assertEquals(
"global rule",
ReaderBookReplacementEngine.apply("global rule", preferences, "book").text,
)
}
@Test
fun `book replacements serialize and preserve per file rules`() {
val preferences = ReaderBookReplacementPreferences(
fileRules = mapOf(
"book" to listOf(
rule(
id = "regex",
from = """A(\w+)""",
to = "B\$1",
isRegex = true,
wholeWord = false,
),
),
),
)
val decoded = ReaderBookReplacementPreferencesJson.decodeOrEmpty(
ReaderBookReplacementPreferencesJson.encode(preferences),
)
assertEquals(preferences, decoded)
}
@Test
fun `signature only reflects active rules for file`() {
val preferences = ReaderBookReplacementPreferences(
fileRules = mapOf(
"book" to listOf(
rule(id = "on", from = "old", to = "new"),
rule(id = "off", from = "draft", to = "unused", enabled = false),
),
),
)
val signature = preferences.signatureForFile("book")
assertTrue("old" in signature)
assertTrue("draft" !in signature)
assertEquals("", preferences.signatureForFile("missing"))
}
private fun rule(
id: String = "rule",
from: String,
to: String,
enabled: Boolean = true,
isRegex: Boolean = false,
matchCase: Boolean = false,
wholeWord: Boolean = true,
): ReaderWordReplacementRule {
return ReaderWordReplacementRule(
id = id,
from = from,
to = to,
enabled = enabled,
isRegex = isRegex,
matchCase = matchCase,
wholeWord = wholeWord,
)
}
}

View file

@ -55,7 +55,8 @@ class ReaderDefaultSettingsStateTest {
fontFamily = "Serif",
themeId = "dark",
textureId = "paper",
textureAlpha = 0.25f
textureAlpha = 0.25f,
rightToLeftPagination = true
)
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
@ -72,7 +73,8 @@ class ReaderDefaultSettingsStateTest {
val epubDefaults = ReaderSettings(themeId = "sepia")
val pdfDefaults = ReaderSettings(
themeId = "reverse",
pdfFirstPageStandaloneInSpread = true
pdfFirstPageStandaloneInSpread = true,
rightToLeftPagination = true
)
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(

View file

@ -48,6 +48,35 @@ class ReaderExtrasModelsTest {
assertIs<ReaderByokTextRequestResult.Ready>(ready)
}
@Test
fun `reader ai one model setting matches Android model selection logic`() {
val oneModel = ReaderByokTextRequests.build(
settings = ReaderAiByokSettings(
geminiKey = "gemini_test",
groqKey = "gsk_test",
useOneModel = true,
modelForAll = "groq:qwen/qwen3-32b",
defineModel = "gemini:gemini-flash-lite-latest"
),
feature = ReaderAiFeature.DEFINE,
text = "epistemic"
)
val perFeature = ReaderByokTextRequests.build(
settings = ReaderAiByokSettings(
geminiKey = "gemini_test",
groqKey = "gsk_test",
useOneModel = false,
modelForAll = "groq:qwen/qwen3-32b",
defineModel = "gemini:gemini-flash-lite-latest"
),
feature = ReaderAiFeature.DEFINE,
text = "epistemic"
)
assertEquals("groq:qwen/qwen3-32b", assertIs<ReaderByokTextRequestResult.Ready>(oneModel).request.model.id)
assertEquals("gemini:gemini-flash-lite-latest", assertIs<ReaderByokTextRequestResult.Ready>(perFeature).request.model.id)
}
@Test
fun `BYOK cloud tts is available only with gemini key and cloud tts model`() {
assertFalse(ReaderAiByokSettings(geminiKey = "key").isCloudTtsAvailable)
@ -112,6 +141,56 @@ class ReaderExtrasModelsTest {
assertTrue(populated.hasCurrentVoiceCachedAudio)
}
@Test
fun `cloud tts overlay is visible only for active reader playback`() {
assertFalse(readerCloudTtsControlsModel(ReaderCloudTtsState(isAvailable = true)).isVisible)
assertTrue(readerCloudTtsControlsModel(ReaderCloudTtsState(isLoading = true)).isVisible)
assertTrue(readerCloudTtsControlsModel(ReaderCloudTtsState(isPlaying = true)).isVisible)
assertTrue(readerCloudTtsControlsModel(ReaderCloudTtsState(isPaused = true)).isVisible)
}
@Test
fun `cloud tts overlay exposes chunk navigation only when a chunk can be skipped`() {
val chunks = List(3) { index ->
ReaderTtsChunk(
index = index,
pageIndex = index,
chapterIndex = 0,
chapterTitle = "Chapter",
text = "Part ${index + 1}.",
startOffset = index * 10,
endOffset = index * 10 + 7
)
}
val first = readerCloudTtsControlsModel(
ReaderCloudTtsState(
isPlaying = true,
progress = ReaderTtsProgress(chunks = chunks, currentChunkIndex = 0)
)
)
val middle = readerCloudTtsControlsModel(
ReaderCloudTtsState(
isPlaying = true,
progress = ReaderTtsProgress(chunks = chunks, currentChunkIndex = 1)
)
)
val loading = readerCloudTtsControlsModel(
ReaderCloudTtsState(
isLoading = true,
progress = ReaderTtsProgress(chunks = chunks, currentChunkIndex = 1)
)
)
assertFalse(first.canSkipPrevious)
assertTrue(first.canSkipNext)
assertTrue(first.canLocateCurrentChunk)
assertTrue(middle.canSkipPrevious)
assertTrue(middle.canSkipNext)
assertFalse(loading.canSkipPrevious)
assertFalse(loading.canSkipNext)
}
@Test
fun `hidden reader ai follows android availability logic`() {
val visible = ReaderAiByokSettings(
@ -234,6 +313,142 @@ class ReaderExtrasModelsTest {
assertFalse(chunks.any { it.text.startsWith("First hidden") })
}
@Test
fun `tts planner keeps synthetic desktop locators at android style chunk boundary`() {
val visibleLine = "Gilberte's either noticing or suffering by his peculations. Tears came to my eyes."
val source = "Hidden before this visual line. $visibleLine Later visible sentence."
val visibleStart = source.indexOf(visibleLine)
val book = SharedEpubBook(
id = "tts-desktop-line",
fileName = "tts-desktop-line.epub",
title = "TTS desktop line",
chapters = listOf(SharedEpubChapter("one", "One", source))
)
val page = ReaderPage(
pageIndex = 0,
chapterIndex = 0,
chapterTitle = "One",
text = source,
startOffset = 0,
endOffset = source.length
)
val session = ReaderSessionState(
reader = PaginatedReaderState(
book = book,
pages = listOf(page),
currentPageIndex = 0
),
navigationLocator = ReaderLocator(
chapterIndex = 0,
pageIndex = 0,
startOffset = visibleStart,
endOffset = visibleStart,
textQuote = visibleLine,
cfi = "desktop:0:$visibleStart:$visibleStart"
)
)
val first = ReaderTtsPlanner.chunksFromCurrentLocation(session).first()
assertEquals(visibleStart, first.startOffset)
assertTrue(first.text.startsWith(visibleLine))
assertFalse(first.text.startsWith("Hidden before"))
}
@Test
fun `tts planner trims onward chunks with source offsets after sentence gaps`() {
val source = "First hidden sentence.\n\nSecond visible sentence starts on the top line."
val visibleOffset = source.indexOf("Second")
val book = SharedEpubBook(
id = "tts-visible-gap",
fileName = "tts-visible-gap.epub",
title = "TTS visible gap",
chapters = listOf(SharedEpubChapter("one", "One", source))
)
val session = ReaderEngine().createSession(book).copy(
navigationLocator = ReaderLocator(
chapterIndex = 0,
pageIndex = 0,
startOffset = visibleOffset,
endOffset = visibleOffset,
textQuote = "Second visible sentence starts on the top line."
)
)
val first = ReaderTtsPlanner.chunksFromCurrentLocation(session).first()
assertEquals(visibleOffset, first.startOffset)
assertTrue(first.text.startsWith("Second visible sentence starts"))
assertFalse(first.text.startsWith("cond visible"))
}
@Test
fun `tts planner matches android source cfi before slicing initial chunk`() {
val hidden = "Hidden block text that should never be trimmed into."
val visible = "Visible line starts here and should be spoken."
val visibleOffset = 20
val hiddenBlock = SemanticParagraph(
text = hidden,
spans = emptyList(),
style = CssStyle(),
elementId = null,
cfi = "/4/2",
startCharOffsetInSource = 0,
blockIndex = 0
)
val visibleBlock = SemanticParagraph(
text = visible,
spans = emptyList(),
style = CssStyle(),
elementId = null,
cfi = "/4/4",
startCharOffsetInSource = visibleOffset,
blockIndex = 1
)
val book = SharedEpubBook(
id = "tts-cfi-match",
fileName = "tts-cfi-match.epub",
title = "TTS CFI match",
chapters = listOf(
SharedEpubChapter(
id = "one",
title = "One",
plainText = "$hidden\n$visible",
semanticBlocks = listOf(hiddenBlock, visibleBlock)
)
)
)
val page = ReaderPage(
pageIndex = 0,
chapterIndex = 0,
chapterTitle = "One",
text = "$hidden\n$visible",
startOffset = 0,
endOffset = hidden.length + visible.length + visibleOffset
)
val session = ReaderSessionState(
reader = PaginatedReaderState(
book = book,
pages = listOf(page),
currentPageIndex = 0
),
navigationLocator = ReaderLocator(
chapterIndex = 0,
pageIndex = 0,
startOffset = visibleOffset,
endOffset = visibleOffset,
textQuote = visible,
cfi = "/4/4:0"
)
)
val first = ReaderTtsPlanner.chunksFromCurrentLocation(session).first()
assertEquals("/4/4", first.sourceCfi)
assertTrue(first.text.startsWith("Visible line starts here"))
assertFalse(first.text.contains("Hidden block"))
}
@Test
fun `tts planner maps trimmed page text back to source offsets`() {
val source = "Intro.\n\n Leading words continue."

View file

@ -36,7 +36,7 @@ class ReaderToolbarPreferencesTest {
}
@Test
fun `highlight palette reducer sanitizes colors`() {
fun `highlight palette reducer follows android four slot palette`() {
val state = SharedReaderScreenState()
.reduce(
AppAction.ReaderHighlightPaletteChanged(
@ -46,6 +46,19 @@ class ReaderToolbarPreferencesTest {
)
)
assertEquals(listOf(HighlightColor.CYAN, HighlightColor.YELLOW), state.readerHighlightPalette.colors)
assertEquals(ReaderHighlightPalette.defaultColors, state.readerHighlightPalette.colors)
val customized = state.reduce(
AppAction.ReaderHighlightPaletteChanged(
ReaderHighlightPalette(
colors = listOf(HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.PINK, HighlightColor.WHITE)
)
)
)
assertEquals(
listOf(HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.PINK, HighlightColor.WHITE),
customized.readerHighlightPalette.colors
)
}
}

View file

@ -86,6 +86,27 @@ class SettingsHubModelsTest {
assertTrue(SharedSettingsAction.FOLDER_SYNC in actions)
}
@Test
fun `desktop can hide account auth rows while preserving sync controls`() {
val model = sharedSettingsHubModel(
SharedSettingsHubInput(
platform = SharedSettingsPlatform.DESKTOP,
includeAccountAuthActions = false,
accountAvailable = true,
syncAvailable = true,
folderSyncAvailable = true,
isSignedIn = true,
isProUser = true
)
)
val actions = model.visibleNestedActions()
assertFalse(SharedSettingsAction.SIGN_IN in actions)
assertFalse(SharedSettingsAction.SIGN_OUT in actions)
assertTrue(SharedSettingsAction.CLOUD_SYNC in actions)
assertTrue(SharedSettingsAction.FOLDER_SYNC in actions)
}
@Test
fun `reader tabs setting can be omitted for platforms without visible tabs`() {
val model = sharedSettingsHubModel(

View file

@ -0,0 +1,18 @@
package com.aryan.reader.shared
import kotlin.test.Test
import kotlin.test.assertEquals
class SharedLegalLinksTest {
@Test
fun `standard and oss legal profiles use separate policy pages`() {
val standard = sharedLegalLinksForProfile(SharedLegalProfile.STANDARD)
val oss = sharedLegalLinksForProfile(SharedLegalProfile.OSS)
assertEquals("$EPISTEME_POLICY_BASE_URL/privacy-policy.html", standard.privacyPolicyUrl)
assertEquals("$EPISTEME_POLICY_BASE_URL/terms-and-conditions.html", standard.termsUrl)
assertEquals("$EPISTEME_POLICY_BASE_URL/oss-privacy-policy.html", oss.privacyPolicyUrl)
assertEquals("$EPISTEME_POLICY_BASE_URL/oss-terms-of-service.html", oss.termsUrl)
assertEquals(standard.licensesUrl, oss.licensesUrl)
}
}

View file

@ -77,6 +77,113 @@ class SharedLibraryEditorTest {
assertEquals("banner_books_added_to_shelf", result.state.bannerMessage?.text?.name)
}
@Test
fun `addBooksToShelves adds missing refs to multiple shelves and can keep selection`() {
val state = SharedReaderScreenState(selectedBookIds = setOf("selected"))
val refs = listOf(BookShelfRef(bookId = "one", shelfId = "manual_a", addedAt = 1L))
val result = SharedLibraryEditor.addBooksToShelves(
state = state,
shelfRecords = listOf(ShelfRecord("manual_a", "A"), ShelfRecord("manual_b", "B")),
shelfRefs = refs,
bookIds = listOf(" one ", "two", "one"),
shelfIds = listOf("manual_a", "manual_b", "unshelved", " "),
clearSelection = false,
nowMillis = 9L
)
requireNotNull(result)
assertEquals(setOf("selected"), result.state.selectedBookIds)
assertEquals(
listOf(
BookShelfRef(bookId = "one", shelfId = "manual_a", addedAt = 1L),
BookShelfRef(bookId = "two", shelfId = "manual_a", addedAt = 9L),
BookShelfRef(bookId = "one", shelfId = "manual_b", addedAt = 9L),
BookShelfRef(bookId = "two", shelfId = "manual_b", addedAt = 9L)
),
result.shelfRefs
)
assertEquals("3 shelf entries added.", result.state.bannerMessage?.message)
}
@Test
fun `addBooksToShelves clears selection when requested`() {
val state = SharedReaderScreenState(selectedBookIds = setOf("one"))
val result = SharedLibraryEditor.addBooksToShelves(
state = state,
shelfRecords = listOf(ShelfRecord("manual", "Manual")),
shelfRefs = emptyList(),
bookIds = listOf("one"),
shelfIds = listOf("manual"),
clearSelection = true,
nowMillis = 10L
)
requireNotNull(result)
assertTrue(result.state.selectedBookIds.isEmpty())
assertEquals(
listOf(BookShelfRef(bookId = "one", shelfId = "manual", addedAt = 10L)),
result.shelfRefs
)
}
@Test
fun `replaceShelfBooks only rewrites target shelf refs`() {
val state = SharedReaderScreenState(
shelves = listOf(Shelf("manual", "Manual", ShelfType.MANUAL, emptyList()))
)
val refs = listOf(
BookShelfRef(bookId = "old", shelfId = "manual", addedAt = 1L),
BookShelfRef(bookId = "keep", shelfId = "other", addedAt = 2L)
)
val result = SharedLibraryEditor.replaceShelfBooks(
state = state,
shelfRecords = listOf(ShelfRecord("manual", "Manual")),
shelfRefs = refs,
shelfId = "manual",
bookIds = listOf("new", "new", " "),
nowMillis = 7L
)
requireNotNull(result)
assertEquals(
listOf(
BookShelfRef(bookId = "keep", shelfId = "other", addedAt = 2L),
BookShelfRef(bookId = "new", shelfId = "manual", addedAt = 7L)
),
result.shelfRefs
)
assertEquals("Updated \"Manual\" with 1 book.", result.state.bannerMessage?.message)
}
@Test
fun `createShelfWithBooks creates shelf refs and clears selection`() {
val state = SharedReaderScreenState(selectedBookIds = setOf("one", "two"))
val result = SharedLibraryEditor.createShelfWithBooks(
state = state,
shelfRecords = emptyList(),
shelfRefs = emptyList(),
name = " Favorites ",
bookIds = listOf("one", "two", "one"),
nowMillis = 12L
)
requireNotNull(result)
assertTrue(result.state.selectedBookIds.isEmpty())
assertEquals(listOf(ShelfRecord("shelf_12", "Favorites")), result.shelfRecords)
assertEquals(
listOf(
BookShelfRef(bookId = "one", shelfId = "shelf_12", addedAt = 12L),
BookShelfRef(bookId = "two", shelfId = "shelf_12", addedAt = 12L)
),
result.shelfRefs
)
assertEquals("Created shelf \"Favorites\" with 2 books.", result.state.bannerMessage?.message)
}
@Test
fun `createSmartShelf stores trimmed shared rules and rejects blank definitions`() {
val definition = SmartCollectionDefinition(

View file

@ -163,6 +163,11 @@ class SharedLibraryProjectorTest {
assertEquals(setOf("one"), opened.pinnedHomeBookIds)
assertEquals(setOf("two"), opened.pinnedLibraryBookIds)
val reactivated = opened.reduce(AppAction.BookTabOpened("one"))
assertEquals(listOf("one", "two"), reactivated.openTabIds)
assertEquals("one", reactivated.activeTabBookId)
val closedActive = opened.reduce(AppAction.BookTabClosed("two"))
assertEquals(listOf("one"), closedActive.openTabIds)
@ -375,6 +380,7 @@ class SharedLibraryProjectorTest {
assertEquals(FileType.PDF, "REPORT.PDF".toFileType())
assertEquals(FileType.HTML, "page.htm".toFileType())
assertEquals(FileType.CBZ, "comic.cbz".toFileType())
assertEquals(FileType.CBT, "comic.cbt".toFileType())
assertEquals(FileType.UNKNOWN, "archive.zip".toFileType())
}

View file

@ -74,6 +74,7 @@ class SharedLibrarySnapshotJsonTest {
pageInfoMode = PageInfoMode.SYNC,
pageInfoPosition = PageInfoPosition.TOP,
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
rightToLeftPagination = true,
pdfVerticalPageGapVisible = false,
pdfPageNumberOverlayVisible = false,
pdfFirstPageStandaloneInSpread = true,
@ -121,7 +122,8 @@ class SharedLibrarySnapshotJsonTest {
paginatedVerticalScrollOffset = 140,
verticalFirstPageIndex = 3,
verticalFirstPageScrollOffset = 44
)
),
readingPositionModifiedTimestamp = 9_000L
)
),
shelfRecords = listOf(ShelfRecord(id = "shelf", name = "Shelf", isSmart = true, smartRulesJson = "{}")),
@ -154,6 +156,25 @@ class SharedLibrarySnapshotJsonTest {
customAppThemes = listOf(
CustomAppTheme(id = "forest", name = "Forest", seedColor = Color(0xFF006C4C))
),
customReaderThemes = listOf(
ReaderTheme(
id = "my_solid",
name = "My Solid",
backgroundColor = Color(0xFFF5F5F5),
textColor = Color(0xFF111111),
isDark = false,
isCustom = true
),
ReaderTheme(
id = "my_texture",
name = "My Texture",
backgroundColor = Color(0xFF222222),
textColor = Color(0xFFEFEFEF),
isDark = true,
textureId = ReaderTexture.CANVAS.id,
isCustom = true
)
),
readerDefaultSettings = ReaderSettings(themeId = "sepia"),
pdfReaderDefaultSettings = ReaderSettings(themeId = "reverse"),
readerToolbarPreferences = ReaderToolbarPreferences(
@ -162,7 +183,7 @@ class SharedLibrarySnapshotJsonTest {
bottomToolIds = setOf(ReaderTool.BOOKMARK.id)
).sanitized(),
readerHighlightPalette = ReaderHighlightPalette(
colors = listOf(HighlightColor.YELLOW, HighlightColor.CYAN)
colors = listOf(HighlightColor.YELLOW, HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.WHITE)
),
readerTtsReplacementPreferences = ReaderTtsReplacementPreferences(
globalRules = listOf(
@ -250,6 +271,7 @@ class SharedLibrarySnapshotJsonTest {
assertTrue(settings.pdfVerticalPageGapVisible)
assertTrue(settings.pdfPageNumberOverlayVisible)
assertFalse(settings.rightToLeftPagination)
}
@Test
@ -295,7 +317,8 @@ class SharedLibrarySnapshotJsonTest {
"uriString": "C:/Books",
"name": "Books",
"lastScanTime": 12,
"allowedFileTypes": ["PDF", "UNKNOWN", "EPUB"]
"allowedFileTypes": ["PDF", "UNKNOWN", "EPUB"],
"localSyncEnabled": false
}
]
}
@ -304,6 +327,7 @@ class SharedLibrarySnapshotJsonTest {
val folder = decoded.syncedFolders.single()
assertEquals(setOf(FileType.PDF, FileType.EPUB), folder.allowedFileTypes)
assertFalse(folder.localSyncEnabled)
assertFalse(FileType.UNKNOWN in folder.allowedFileTypes)
val encoded = SharedLibrarySnapshotJson.encode(

View file

@ -1,5 +1,7 @@
package com.aryan.reader.shared.opds
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
@ -67,6 +69,13 @@ class SharedOpdsCatalogsTest {
"https://example.org/search?q=ada%20lovelace",
SharedOpdsSearch.expandSearchTemplate("https://example.org/search?q={searchTerms}", "ada lovelace")
)
assertEquals(
"https://example.org/search?q=ada%20lovelace&per-page=12&page=1",
SharedOpdsSearch.expandSearchTemplate(
"https://example.org/search?q={searchTerms}&per-page={count}&page={startPage}",
"ada lovelace"
)
)
assertEquals(
"https://example.org/search?existing=1&query=ada%20lovelace",
SharedOpdsSearch.expandSearchTemplate("https://example.org/search?existing=1", "ada lovelace")
@ -114,5 +123,50 @@ class SharedOpdsCatalogsTest {
urlPathSegment = null
)
)
assertEquals(
".cbt",
SharedOpdsDownloadNamer.resolveExtension(
acquisition = OpdsAcquisition("https://example.org/download", "application/vnd.comicbook+tar"),
contentDisposition = null,
urlPathSegment = null
)
)
}
@Test
fun `local book matcher recognizes opds download temp names and acquisition filenames`() {
val entry = OpdsEntry(
id = "entry",
title = "A Catalog Book",
summary = null,
coverUrl = null,
acquisitions = listOf(
OpdsAcquisition("https://example.org/files/alternate-title.epub", "application/epub+zip")
),
navigationUrl = null
)
val books = listOf(
BookItem(
id = "book",
path = "file:///library/opds_dl_A_Catalog_Book.epub",
type = FileType.EPUB,
displayName = "opds_dl_A_Catalog_Book.epub",
timestamp = 1L,
title = "Embedded Title"
)
)
assertEquals(books.single(), SharedOpdsLocalBookMatcher.findBook(entry, books))
val acquisitionNamedBook = books.single().copy(
path = "file:///library/alternate-title.epub",
displayName = "alternate-title.epub",
title = "Different Embedded Title"
)
assertEquals(
acquisitionNamedBook,
SharedOpdsLocalBookMatcher.findBook(entry, listOf(acquisitionNamedBook))
)
}
}

View file

@ -160,6 +160,37 @@ class PdfReaderSessionTest {
assertEquals(config.strokeWidth, state.strokeWidth)
}
@Test
fun `tool color and thickness changes persist per active tool`() {
val penColor = 0xFF123456.toInt()
val highlighterColor = 0x8CABCDEF.toInt()
val penConfigured = SharedPdfReaderState.initial(pageCount = 1)
.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.PEN))
.reduce(SharedPdfReaderAction.ColorSelected(penColor))
.reduce(SharedPdfReaderAction.StrokeWidthChanged(0.012f))
val highlighterConfigured = penConfigured
.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.HIGHLIGHTER))
.reduce(SharedPdfReaderAction.ColorSelected(highlighterColor))
val penAgain = highlighterConfigured.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.PEN))
val highlighterAgain = penAgain.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.HIGHLIGHTER))
assertEquals(penColor, penAgain.selectedColorArgb)
assertEquals(0.012f, penAgain.strokeWidth)
assertEquals(highlighterColor, highlighterAgain.selectedColorArgb)
}
@Test
fun `pen palette changes follow android fixed slot behavior`() {
val customColor = 0xFF010203.toInt()
val state = SharedPdfReaderState.initial(pageCount = 1)
.reduce(SharedPdfReaderAction.PenPaletteChanged(listOf(0, customColor, 0xFF040506.toInt())))
assertEquals(SharedPdfAnnotationDefaults.penPalette.size, state.penPalette.size)
assertEquals(customColor, state.penPalette.first())
}
@Test
fun `text selection markup tools and neutral mode are exclusive`() {
val selectingText = SharedPdfReaderState.initial(pageCount = 1)
@ -192,6 +223,29 @@ class PdfReaderSessionTest {
assertEquals(listOf(first), state.annotations)
}
@Test
fun `annotation undo and redo follow add remove history`() {
val first = annotation("first", pageIndex = 0)
val second = annotation("second", pageIndex = 0)
val added = SharedPdfReaderState.initial(pageCount = 1)
.reduce(SharedPdfReaderAction.AnnotationAdded(first))
.reduce(SharedPdfReaderAction.AnnotationAdded(second))
val undoneAdd = added.reduce(SharedPdfReaderAction.UndoAnnotationEdit)
val redoneAdd = undoneAdd.reduce(SharedPdfReaderAction.RedoAnnotationEdit)
val removed = redoneAdd.reduce(SharedPdfReaderAction.ClearPageAnnotations(0))
val undoneRemove = removed.reduce(SharedPdfReaderAction.UndoAnnotationEdit)
val redoneRemove = undoneRemove.reduce(SharedPdfReaderAction.RedoAnnotationEdit)
assertEquals(listOf(first), undoneAdd.annotations)
assertEquals(true, undoneAdd.canRedoAnnotationEdit)
assertEquals(listOf(first, second), redoneAdd.annotations)
assertEquals(emptyList(), removed.annotations)
assertEquals(listOf(first, second), undoneRemove.annotations)
assertEquals(emptyList(), redoneRemove.annotations)
}
@Test
fun `bookmark actions toggle and normalize pages`() {
val state = SharedPdfReaderState.initial(pageCount = 4)

View file

@ -64,4 +64,20 @@ class PdfSelectionGeometryTest {
assertEquals(2, merged.size)
}
@Test
fun `line fallback collapses overlapping glyph bands on the same visual line`() {
val bounds = PdfSelectionGeometry.lineBoundsForChars(
listOf(
PdfTextCharBounds(index = 1, left = 0.10f, top = 0.100f, right = 0.13f, bottom = 0.130f),
PdfTextCharBounds(index = 2, left = 0.14f, top = 0.116f, right = 0.17f, bottom = 0.146f),
PdfTextCharBounds(index = 3, left = 0.18f, top = 0.101f, right = 0.21f, bottom = 0.131f)
)
)
assertEquals(
listOf(PdfPageBounds(left = 0.10f, top = 0.100f, right = 0.21f, bottom = 0.146f)),
bounds
)
}
}

View file

@ -30,6 +30,19 @@ class PdfSpreadLayoutTest {
assertEquals(listOf(0, 2, 4), PdfSpreadLayout.spreadStartPageIndices(pageCount = 5, settings = settings))
}
@Test
fun `right to left pagination reverses only the displayed pdf spread order`() {
val settings = ReaderSettings(
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
rightToLeftPagination = true
)
assertEquals(listOf(2, 3), PdfSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
assertEquals(listOf(3, 2), PdfSpreadLayout.visiblePageIndicesForDisplay(3, pageCount = 10, settings = settings))
assertEquals(4, PdfSpreadLayout.nextPageIndex(2, pageCount = 10, settings = settings))
assertEquals(0, PdfSpreadLayout.previousPageIndex(2, pageCount = 10, settings = settings))
}
@Test
fun `two page mode can keep the first page alone`() {
val settings = ReaderSettings(

View file

@ -0,0 +1,43 @@
package com.aryan.reader.shared.pdf
import kotlin.test.Test
import kotlin.test.assertEquals
class SharedPdfAnnotationCommentsTest {
@Test
fun `visible comments filter blanks and promote orphan replies`() {
val comments = listOf(
SharedPdfAnnotationComment(id = "root", contents = "Root"),
SharedPdfAnnotationComment(id = "reply", parentId = "root", contents = "Reply"),
SharedPdfAnnotationComment(id = "blank-parent", contents = ""),
SharedPdfAnnotationComment(id = "orphan", parentId = "blank-parent", contents = "Orphan")
)
val visible = comments.visiblePdfAnnotationComments()
assertEquals(listOf("root", "reply", "orphan"), visible.map { it.id })
assertEquals("root", visible.single { it.id == "reply" }.parentId)
assertEquals(null, visible.single { it.id == "orphan" }.parentId)
}
@Test
fun `comment helpers preserve nested thread behavior`() {
val comments = listOf(
SharedPdfAnnotationComment(id = "undated", contents = "Undated"),
SharedPdfAnnotationComment(id = "newer", contents = "Newer", createdAt = 30L),
SharedPdfAnnotationComment(id = "older", contents = "Older", createdAt = 10L),
SharedPdfAnnotationComment(id = "child", parentId = "older", contents = "Child"),
SharedPdfAnnotationComment(id = "grandchild", parentId = "child", contents = "Grandchild"),
SharedPdfAnnotationComment(id = "sibling", contents = "Sibling", createdAt = 20L)
)
assertEquals(
listOf("older", "sibling", "newer", "undated"),
comments.pdfCommentChildren(parentId = null).map { it.id }
)
assertEquals(
listOf("undated", "newer", "older", "sibling"),
comments.withoutPdfCommentThread("child").map { it.id }
)
}
}

View file

@ -254,6 +254,106 @@ class SharedPdfAnnotationSerializerTest {
assertEquals(0, legacy.getValue("highlights").jsonArray.size)
}
@Test
fun `sidecar codec merges local and remote annotation additions`() {
val local = SharedPdfAnnotation(
id = "local-ink",
pageIndex = 0,
kind = PdfAnnotationKind.INK,
points = listOf(PdfPagePoint(0.1f, 0.2f, 10L)),
colorArgb = 0xFF000000.toInt(),
createdAt = 10L
)
val remote = SharedPdfAnnotation(
id = "remote-ink",
pageIndex = 0,
kind = PdfAnnotationKind.INK,
points = listOf(PdfPagePoint(0.3f, 0.4f, 20L)),
colorArgb = 0xFFFF0000.toInt(),
createdAt = 20L
)
fun payload(annotation: SharedPdfAnnotation): String {
return testJson.encodeToString(
JsonElement.serializer(),
JsonObject(
mapOf(
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(listOf(annotation))
)
)
)
}
val merged = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson(
localDataJson = payload(local),
remoteDataJson = payload(remote),
preferRemoteOnConflict = false
)
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(
testJson.parseToJsonElement(merged).jsonObject
)
assertEquals(listOf("local-ink", "remote-ink"), annotations.map { it.id })
assertEquals(2, SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(merged))
}
@Test
fun `sidecar codec deletion tombstones remove stale remote annotations`() {
val deletedRemote = SharedPdfAnnotation(
id = "deleted-remote-ink",
pageIndex = 0,
kind = PdfAnnotationKind.INK,
points = listOf(PdfPagePoint(0.1f, 0.2f, 10L)),
colorArgb = 0xFF000000.toInt(),
createdAt = 10L
)
val local = SharedPdfAnnotation(
id = "local-ink",
pageIndex = 0,
kind = PdfAnnotationKind.INK,
points = listOf(PdfPagePoint(0.3f, 0.4f, 20L)),
colorArgb = 0xFFFF0000.toInt(),
createdAt = 20L
)
fun payload(
annotations: List<SharedPdfAnnotation>,
deletions: Map<String, Long> = emptyMap()
): String {
return testJson.encodeToString(
JsonElement.serializer(),
JsonObject(
buildMap {
put(
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
)
if (deletions.isNotEmpty()) {
put(
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS,
SharedPdfAnnotationSidecarCodec.encodeAnnotationDeletionsElement(deletions)
)
}
}
)
)
}
val merged = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson(
localDataJson = payload(
annotations = listOf(local),
deletions = mapOf(deletedRemote.id to 100L)
),
remoteDataJson = payload(listOf(deletedRemote)),
preferRemoteOnConflict = false
)
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(
testJson.parseToJsonElement(merged).jsonObject
)
assertEquals(listOf("local-ink"), annotations.map { it.id })
assertEquals(mapOf(deletedRemote.id to 100L), SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(merged))
}
@Test
fun `embedded annotation threads link replies and nearby orphan comments`() {
val root = embeddedAnnotation(

View file

@ -178,6 +178,13 @@ class SharedPdfRichTextTest {
)
}
@Test
fun `selection bounds normalize reversed and clamped rich text selections`() {
assertEquals(44 to 45, sharedPdfRichTextSelectionBounds(45, 44, textLength = 45))
assertEquals(0 to 5, sharedPdfRichTextSelectionBounds(-3, 99, textLength = 5))
assertEquals(null, sharedPdfRichTextSelectionBounds(3, 3, textLength = 5))
}
@Test
fun `trailing page break creates editable blank page layout`() {
val globalText = AnnotatedString("$SHARED_PDF_PAGE_BREAK_CHAR")

View file

@ -38,6 +38,40 @@ class ReaderEngineTest {
assertSame(first.reader.pages, second.reader.pages)
}
@Test
fun `visible locator and bookmarks prefer android style cfi when semantic blocks provide it`() {
val engine = ReaderEngine()
val session = engine.createSession(
SharedEpubBook(
id = "semantic",
fileName = "semantic.epub",
title = "Semantic",
chapters = listOf(
SharedEpubChapter(
id = "one",
title = "One",
plainText = "Alpha beta",
semanticBlocks = listOf(
SemanticParagraph(
text = "Alpha beta",
spans = emptyList(),
style = CssStyle(),
elementId = null,
cfi = "/4/2/2",
startCharOffsetInSource = 0
)
)
)
)
)
)
val bookmarked = engine.toggleBookmark(session)
assertEquals("/4/2/2:0", session.navigationLocator?.cfi)
assertEquals("/4/2/2:0", bookmarked.bookmarks.single().locator.cfi)
}
@Test
fun `visual settings update does not repaginate or move current page`() {
val engine = ReaderEngine()
@ -62,6 +96,43 @@ class ReaderEngineTest {
assertEquals("night", updated.reader.settings.themeId)
}
@Test
fun `vertical page navigation uses scroll page locator for webview slider sync`() {
val engine = ReaderEngine()
val session = engine.createSession(
book = longBook(),
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
)
val moved = engine.goToPage(session, 1)
assertEquals(1, moved.reader.currentPageIndex)
assertEquals("desktop-scroll-page:1", moved.navigationLocator?.cfi)
assertEquals(1, moved.navigationLocator?.pageIndex)
}
@Test
fun `visible page sync stores stable vertical locator cfi instead of scroll metrics`() {
val engine = ReaderEngine()
val session = engine.createSession(
book = longBook(),
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
)
val page = session.reader.pages[1]
val locator = ReaderLocator(
chapterIndex = page.chapterIndex,
pageIndex = page.pageIndex,
startOffset = page.startOffset + 24,
endOffset = page.startOffset + 24,
cfi = "desktop-scroll:120:800:desktop:${page.chapterIndex}:${page.startOffset + 24}:${page.startOffset + 24}"
)
val synced = engine.syncVisiblePage(session, page.pageIndex, locator)
assertEquals("desktop:${page.chapterIndex}:${page.startOffset + 24}:${page.startOffset + 24}", synced.navigationLocator?.cfi)
assertEquals(locator.startOffset, synced.navigationLocator?.startOffset)
}
@Test
fun `createSession restores precise locator ahead of fallback page index`() {
val engine = ReaderEngine()
@ -358,6 +429,81 @@ class ReaderEngineTest {
assertEquals(160, replaced.navigationLocator?.startOffset)
}
@Test
fun `replacePages resolves page start anchors to the page after a touching boundary`() {
val engine = ReaderEngine()
val book = manualRangeBook()
val pages = listOf(
ReaderPage(0, 0, "One", "first", 0, 100),
ReaderPage(1, 0, "One", "second", 100, 200),
ReaderPage(2, 0, "One", "third", 200, 300)
)
val session = engine.createSession(book).copy(
reader = PaginatedReaderState(book, pages, currentPageIndex = 1),
navigationLocator = ReaderLocator(chapterIndex = 0, pageIndex = 1, startOffset = 100, endOffset = 100),
navigationRequestId = 8L
)
val replaced = engine.replacePages(
state = session,
pages = pages,
reflowAnchor = session.navigationLocator,
navigationRequestIdAtReflowStart = 8L
)
assertEquals(1, replaced.reader.currentPageIndex)
assertEquals(1, replaced.navigationLocator?.pageIndex)
assertEquals(100, replaced.navigationLocator?.startOffset)
}
@Test
fun `replacePages resolves android block locator after measured pagination`() {
val engine = ReaderEngine()
val book = manualRangeBook()
val initialSession = engine.createSession(
book = book,
initialLocator = ReaderLocator(
chapterIndex = 0,
pageIndex = 0,
blockIndex = 42,
charOffset = 160,
cfi = "android-locator:0:42:160"
)
)
val measuredPages = listOf(
ReaderPage(
pageIndex = 0,
chapterIndex = 0,
chapterTitle = "One",
text = "first",
startOffset = 0,
endOffset = 100,
semanticBlocks = listOf(SemanticParagraph("first", emptyList(), CssStyle(), null, null, 0, 7))
),
ReaderPage(
pageIndex = 1,
chapterIndex = 0,
chapterTitle = "One",
text = "target",
startOffset = 150,
endOffset = 210,
semanticBlocks = listOf(SemanticParagraph("target", emptyList(), CssStyle(), null, null, 150, 42))
)
)
val replaced = engine.replacePages(
state = initialSession,
pages = measuredPages,
reflowAnchor = initialSession.navigationLocator,
navigationRequestIdAtReflowStart = initialSession.navigationRequestId
)
assertEquals(1, replaced.reader.currentPageIndex)
assertEquals(1, replaced.navigationLocator?.pageIndex)
assertEquals(42, replaced.navigationLocator?.blockIndex)
assertEquals(160, replaced.navigationLocator?.charOffset)
}
@Test
fun `replacePages lets newer explicit navigation override reflow anchor`() {
val engine = ReaderEngine()

View file

@ -18,6 +18,7 @@ import com.aryan.reader.paginatedreader.SemanticTable
import com.aryan.reader.paginatedreader.SemanticTableCell
import com.aryan.reader.shared.HighlightColor
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.ReaderHighlightPalette
import com.aryan.reader.shared.ReaderTexture
import com.aryan.reader.shared.UserHighlight
import kotlin.test.Test
@ -126,6 +127,10 @@ class ReaderHtmlDocumentBuilderTest {
assertTrue(html.contains("startOffset >= pageEnd || endOffset <= pageStart"))
assertTrue(html.contains("normalizedRangeForText(searchRoot, expectedNormalized, false)"))
assertTrue(html.contains("locator.textQuote || highlight.text"))
assertTrue(html.contains("var sourceCfiBases = readerCfiBases(sourceCfi);"))
assertTrue(html.contains("return readerHostMatchesCfi(host, sourceCfiBases);"))
assertTrue(html.contains("if (cfiOffsets) {"))
assertTrue(html.contains("if (hasPreciseOffsets) return;"))
}
@Test
@ -142,6 +147,8 @@ class ReaderHtmlDocumentBuilderTest {
assertTrue(html.contains("highlight_bridge_error attempt="))
assertTrue(html.contains("var marker = document.createElement('span');"))
assertTrue(html.contains("range.intersectsNode(node)"))
assertTrue(html.contains("function readerHighlightCfiForRange(startSegment, endSegment, chapterIndex, startOffset, endOffset)"))
assertTrue(html.contains("function readerOffsetsForSourceCfi(chapterIndex, sourceCfi, expectedText)"))
assertFalse(html.contains("paintUserHighlightRange(payload"))
assertTrue(localWrapIndex >= 0)
assertTrue(bridgeSendIndex > localWrapIndex)
@ -224,6 +231,109 @@ class ReaderHtmlDocumentBuilderTest {
assertTrue(html.contains("EpistemeEpubPagination"))
}
@Test
fun `page document scopes android locator highlights to one page in a spread`() {
val pageText = "prefix target suffix"
val book = semanticHighlightBook(
leftText = pageText,
rightText = pageText
)
val left = ReaderPage(
pageIndex = 0,
chapterIndex = 0,
chapterTitle = "One",
text = pageText,
startOffset = 100,
endOffset = 100 + pageText.length,
semanticBlocks = listOf(
SemanticParagraph(pageText, emptyList(), CssStyle(), null, "/4/2", startCharOffsetInSource = 100, blockIndex = 42)
)
)
val right = ReaderPage(
pageIndex = 1,
chapterIndex = 0,
chapterTitle = "One",
text = pageText,
startOffset = 200,
endOffset = 200 + pageText.length,
semanticBlocks = listOf(
SemanticParagraph(pageText, emptyList(), CssStyle(), null, "/4/4", startCharOffsetInSource = 200, blockIndex = 43)
)
)
val highlight = UserHighlight(
id = "highlight-android",
cfi = "android-locator:0:42:108",
text = "target",
color = HighlightColor.YELLOW,
chapterIndex = 0,
locator = ReaderLocator.fromLegacy(
chapterIndex = 0,
cfi = "android-locator:0:42:108",
textQuote = "target"
)
)
val html = ReaderHtmlDocumentBuilder.pageDocument(
book = book,
page = left,
visiblePages = listOf(left, right),
settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE),
highlights = listOf(highlight)
)
assertEquals(1, Regex("data-reader-highlight-id=\"highlight-android\"").findAll(html).count())
assertTrue(html.contains("""data-reader-page-index="0""""))
assertTrue(html.contains("""data-reader-page-index="1""""))
}
@Test
fun `page document scopes source cfi text fallback highlights to cfi page`() {
val pageText = "prefix target suffix"
val book = semanticHighlightBook(
leftText = pageText,
rightText = pageText
)
val left = ReaderPage(
pageIndex = 0,
chapterIndex = 0,
chapterTitle = "One",
text = pageText,
startOffset = 100,
endOffset = 100 + pageText.length,
semanticBlocks = listOf(
SemanticParagraph(pageText, emptyList(), CssStyle(), null, "/4/2", startCharOffsetInSource = 100, blockIndex = 42)
)
)
val right = ReaderPage(
pageIndex = 1,
chapterIndex = 0,
chapterTitle = "One",
text = pageText,
startOffset = 200,
endOffset = 200 + pageText.length,
semanticBlocks = listOf(
SemanticParagraph(pageText, emptyList(), CssStyle(), null, "/4/4", startCharOffsetInSource = 200, blockIndex = 43)
)
)
val highlight = UserHighlight(
id = "highlight-cfi",
cfi = "/4/2:8|/4/2:14",
text = "target",
color = HighlightColor.YELLOW,
chapterIndex = 0
)
val html = ReaderHtmlDocumentBuilder.pageDocument(
book = book,
page = left,
visiblePages = listOf(left, right),
settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE),
highlights = listOf(highlight)
)
assertEquals(1, Regex("data-reader-highlight-id=\"highlight-cfi\"").findAll(html).count())
}
@Test
fun `vertical document carries active locator for shared scroll navigation`() {
val html = ReaderHtmlDocumentBuilder.verticalDocument(
@ -248,10 +358,96 @@ class ReaderHtmlDocumentBuilderTest {
assertTrue(html.contains("data-reader-active-chapter-index=\"1\""))
assertTrue(html.contains("data-reader-active-start-offset=\"7\""))
assertTrue(html.contains("scrollToActiveLocator"))
assertTrue(html.contains("readerDesktopPositionTraceLog"))
assertTrue(html.contains("bestVisibleReaderHost"))
assertTrue(html.contains("function prepareVerticalScrollMeasurement(targetChapter)"))
assertTrue(html.contains("pendingRestoreLocator = locator"))
assertTrue(html.contains("positionCfi = stableReaderCfi(positionCfi) || stableDesktopCfi(chapterIndex, offset, offset)"))
assertFalse(html.contains("positionCfi = 'desktop-scroll:' + metrics.scrollY"))
}
@Test
fun `vertical document styles native scrollbar from reader theme variables`() {
fun `vertical document centers followed tts locator without changing active locator scroll`() {
val html = ReaderHtmlDocumentBuilder.verticalDocument(
book = repeatedWordBook("alpha beta gamma"),
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
)
val activeScrollStart = html.indexOf("function scrollToActiveLocator()")
val activeCallStart = html.indexOf("scrollToLocator({", activeScrollStart)
val activeCallEnd = html.indexOf("});", activeCallStart)
assertTrue(activeScrollStart >= 0)
assertTrue(activeCallStart > activeScrollStart)
assertTrue(activeCallEnd > activeCallStart)
val activeScrollCall = html.substring(activeCallStart, activeCallEnd)
assertTrue(html.contains("function scrollToLocator(locator, options)"))
assertTrue(html.contains("function shouldCenterScrollTarget(options)"))
assertTrue(html.contains("function shouldTrackScrollRestore(options)"))
assertTrue(html.contains("return documentTop - Math.round((viewportHeight - rectHeight) / 2);"))
assertTrue(html.contains("if (follow && locator) scrollToLocator(locator, { align: 'center', trackRestore: false });"))
assertFalse(activeScrollCall.contains("align: 'center'"))
assertFalse(activeScrollCall.contains("trackRestore: false"))
}
@Test
fun `vertical document reports visible locator from top reader edge`() {
val html = ReaderHtmlDocumentBuilder.verticalDocument(
book = repeatedWordBook("alpha beta gamma"),
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
)
assertTrue(html.contains("return Math.max(1, Math.min(height - 1, 8));"))
assertFalse(html.contains("Math.round(height * 0.12)"))
assertTrue(html.contains("function firstVisibleLineRect()"))
assertTrue(html.contains("function firstVisibleLineStart(targetLineRect)"))
assertTrue(html.contains("var topLineStart = topLineRect ? firstVisibleLineStart(topLineRect) : null;"))
assertTrue(html.contains("event=web_line_start_choice"))
assertTrue(html.contains("return visibleResult(node, i, offset + i);"))
assertFalse(html.contains("charRect.top >= viewportTop - 0.5"))
assertTrue(html.contains("var sourceOffset = boundaryOffsetWithinContent(content, node, localOffset);"))
assertTrue(html.contains("return positionFromReaderHost(chapter, requestedOffset, preferredY, 'restore_locator_visible');"))
assertTrue(html.contains("var position = pendingRestoreVisiblePosition() || currentVisiblePosition();"))
assertTrue(html.contains("return fallback || { offset: contentStart, textNode: null };"))
assertTrue(html.contains("EpistemeDesktopTtsStartTrace"))
assertTrue(html.contains("readerTtsStartTraceLog('event=web_position_report_send"))
}
@Test
fun `vertical document can render a chapter window while retaining page anchors`() {
val html = ReaderHtmlDocumentBuilder.verticalDocument(
book = SharedEpubBook(
id = "book",
fileName = "book.epub",
title = "Book",
chapters = listOf(
SharedEpubChapter("one", "One", "First chapter body."),
SharedEpubChapter("two", "Two", "Second chapter body."),
SharedEpubChapter("three", "Three", "Third chapter body.")
)
),
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL),
pages = listOf(
ReaderPage(0, 0, "One", "First chapter body.", 0, 19),
ReaderPage(1, 1, "Two", "Second chapter body.", 0, 20),
ReaderPage(2, 2, "Three", "Third chapter body.", 0, 19)
),
renderedChapterRange = 1..1
)
assertFalse(html.contains("First chapter body."))
assertTrue(html.contains("Second chapter body."))
assertFalse(html.contains("Third chapter body."))
assertFalse(html.contains("data-reader-chapter-index=\"0\""))
assertTrue(html.contains("data-reader-chapter-index=\"1\""))
assertFalse(html.contains("data-reader-chapter-index=\"2\""))
assertTrue(html.contains("\"chapterIndex\":0"))
assertTrue(html.contains("\"chapterIndex\":1"))
assertTrue(html.contains("\"chapterIndex\":2"))
assertTrue(html.contains("function renderedVerticalPageAnchors()"))
}
@Test
fun `vertical document keeps native webview scrollbar at edge`() {
val html = ReaderHtmlDocumentBuilder.verticalDocument(
book = repeatedWordBook("alpha beta"),
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
@ -259,9 +455,187 @@ class ReaderHtmlDocumentBuilderTest {
assertTrue(html.contains("--reader-scrollbar-track: color-mix(in srgb, var(--reader-bg)"))
assertTrue(html.contains("--reader-scrollbar-thumb: color-mix(in srgb, var(--reader-fg)"))
assertTrue(html.contains("scrollbar-color: var(--reader-scrollbar-thumb) var(--reader-scrollbar-track)"))
assertTrue(html.contains("body.reader-vertical::-webkit-scrollbar-thumb"))
assertTrue(html.contains("body.reader-vertical::-webkit-scrollbar-thumb:hover"))
assertTrue(html.contains("""<html class="reader-vertical-root">"""))
assertTrue(Regex("html\\.reader-vertical-root \\{\\s*width: 100%;\\s*min-width: 0;\\s*overflow-y: scroll;\\s*scrollbar-width: thin;").containsMatchIn(html))
assertTrue(html.contains("html.reader-vertical-root::-webkit-scrollbar"))
assertFalse(html.contains("html.reader-vertical-root::-webkit-scrollbar,\n body.reader-vertical::-webkit-scrollbar {\n width: 0;"))
assertTrue(html.contains("scrollbar-gutter: stable;"))
}
@Test
fun `vertical document lays out continuous full width content`() {
val html = ReaderHtmlDocumentBuilder.verticalDocument(
book = repeatedWordBook("alpha beta"),
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL, pageWidth = 520)
)
val verticalExpansionCss = Regex(
"body\\.reader-vertical > \\.chapter,\\s*" +
"body\\.reader-vertical > :not\\(\\.chapter\\):not\\(#reader-selection-menu\\):not\\(\\.reader-selection-handle\\):not\\(script\\):not\\(style\\),\\s*" +
"body\\.reader-vertical > \\.chapter > :not\\(\\.reader-content\\),\\s*" +
"body\\.reader-vertical > \\.chapter > \\.chapter-title,\\s*" +
"body\\.reader-vertical > \\.chapter > \\.reader-content \\{\\s*" +
"box-sizing: border-box !important;\\s*" +
"min-width: 0 !important;"
)
val verticalMarginCss = Regex(
"body\\.reader-vertical > \\.chapter \\{\\s*" +
"width: 100% !important;\\s*" +
"max-width: none !important;\\s*" +
"margin: 0 !important;"
)
val verticalContentCss = Regex(
"body\\.reader-vertical > :not\\(\\.chapter\\):not\\(#reader-selection-menu\\):not\\(\\.reader-selection-handle\\):not\\(script\\):not\\(style\\),\\s*" +
"body\\.reader-vertical > \\.chapter > :not\\(\\.reader-content\\),\\s*" +
"body\\.reader-vertical > \\.chapter > \\.chapter-title,\\s*" +
"body\\.reader-vertical > \\.chapter > \\.reader-content \\{\\s*" +
"width: var\\(--reader-vertical-page-width\\) !important;\\s*" +
"max-width: none !important;\\s*" +
"margin-left: auto !important;\\s*" +
"margin-right: auto !important;"
)
val verticalChapterSiblingResetCss = Regex(
"body\\.reader-vertical > \\.chapter > :not\\(\\.reader-content\\) \\{\\s*" +
"position: static !important;\\s*" +
"left: auto !important;\\s*" +
"right: auto !important;"
)
val verticalContentClampCss = Regex(
"body\\.reader-vertical \\.reader-content p,\\s*" +
"body\\.reader-vertical \\.reader-content div,\\s*" +
"body\\.reader-vertical \\.reader-content h1,"
)
val verticalTextAlignCss = Regex(
"body\\.reader-vertical \\.reader-content,\\s*" +
"body\\.reader-vertical \\.reader-content p,\\s*" +
"body\\.reader-vertical \\.reader-content li,"
)
val verticalPositionResetCss = Regex(
"position: static !important;\\s*" +
"left: auto !important;\\s*" +
"right: auto !important;\\s*" +
"top: auto !important;\\s*" +
"bottom: auto !important;\\s*" +
"transform: none !important;\\s*" +
"float: none !important;\\s*" +
"clear: none !important;"
)
val verticalNestedWrapperResetCss = Regex(
"body\\.reader-vertical \\.reader-content div,\\s*" +
"body\\.reader-vertical \\.reader-content section,\\s*" +
"body\\.reader-vertical \\.reader-content article,"
)
val verticalTitleClampCss = Regex(
"body\\.reader-vertical \\.reader-content :where\\(h1, h2, h3, h4, h5, h6, hgroup, center,"
)
val verticalMarginResetCss = Regex(
"body\\.reader-vertical \\.reader-content > p,\\s*" +
"body\\.reader-vertical \\.reader-content > div,\\s*" +
"body\\.reader-vertical \\.reader-content > h1,"
)
val paginatedWidthCss = Regex(
"\\.chapter, \\.page \\{\\s*" +
"max-width: var\\(--reader-page-width\\);"
)
val verticalBodyCss = Regex(
"body\\.reader-vertical \\{\\s*" +
"width: 100%;\\s*" +
"max-width: 100%;\\s*" +
"min-height: 100vh;\\s*" +
"min-height: 100dvh;\\s*" +
"min-width: 0;\\s*" +
"overflow-x: hidden;\\s*" +
"overflow-y: auto;\\s*" +
"padding: var\\(--reader-vertical-margin-y\\) 0;"
)
assertTrue(html.contains("--reader-vertical-margin-y: 16px;"))
assertTrue(html.contains("--reader-vertical-content-width: 92ch;"))
assertTrue(html.contains("--reader-vertical-page-width: max(0px, calc(100% - (var(--reader-margin-x) * 2)));"))
assertFalse(html.contains("body.reader-vertical .chapter,"))
assertFalse(html.contains("body.reader-vertical .chapter > :not(.reader-content)"))
assertTrue(verticalBodyCss.containsMatchIn(html))
assertTrue(verticalExpansionCss.containsMatchIn(html))
assertTrue(html.contains("content-visibility: auto;"))
assertTrue(html.contains("contain-intrinsic-size: auto 1200px;"))
assertTrue(verticalMarginCss.containsMatchIn(html))
assertTrue(verticalContentCss.containsMatchIn(html))
assertTrue(verticalChapterSiblingResetCss.containsMatchIn(html))
assertTrue(verticalTextAlignCss.containsMatchIn(html))
assertFalse(html.contains("body.reader-vertical .reader-content {\n flex: 1 0 auto;"))
assertFalse(html.contains("min-height: 100dvh;\n display: flex;"))
assertTrue(html.contains("text-align: var(--reader-align) !important;"))
assertTrue(verticalContentClampCss.containsMatchIn(html))
assertTrue(verticalPositionResetCss.containsMatchIn(html))
assertTrue(verticalNestedWrapperResetCss.containsMatchIn(html))
assertTrue(verticalTitleClampCss.containsMatchIn(html))
assertTrue(verticalMarginResetCss.containsMatchIn(html))
assertTrue(paginatedWidthCss.containsMatchIn(html))
}
@Test
fun `vertical document exposes page anchor updater for in place format changes`() {
val html = ReaderHtmlDocumentBuilder.verticalDocument(
book = repeatedWordBook("alpha beta"),
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL),
pages = listOf(
ReaderPage(0, 0, "One", "alpha", 0, 5),
ReaderPage(1, 0, "One", "beta", 6, 10)
)
)
assertTrue(html.contains("var readerPageAnchors = ["))
assertTrue(html.contains("window.readerSetPageAnchors = function (anchors)"))
assertTrue(html.contains("readerPageAnchors = anchors;"))
assertTrue(html.contains("function pageForVerticalScroll()"))
assertTrue(html.contains("desktop-scroll-page:"))
assertTrue(html.contains("pageIndex: numberAttribute(document.body, 'data-reader-active-page-index', null)"))
assertTrue(html.contains("chapterId: host.getAttribute('data-reader-chapter-id')"))
assertTrue(html.contains("href: host.getAttribute('data-reader-chapter-href')"))
assertTrue(html.contains("blockIndex: blockPosition ? blockPosition.blockIndex : null"))
assertTrue(html.contains("charOffset: blockPosition ? blockPosition.charOffset : null"))
assertTrue(html.contains("blockIndex: numberAttribute(document.body, 'data-reader-active-block-index', null)"))
assertTrue(html.contains("charOffset: numberAttribute(document.body, 'data-reader-active-char-offset', null)"))
assertTrue(html.contains("cfi: document.body.getAttribute('data-reader-active-cfi')"))
}
@Test
fun `appearance update script carries vertical format settings`() {
val script = ReaderHtmlDocumentBuilder.appearanceUpdateScript(
settings = ReaderSettings(
fontSize = 24,
lineSpacing = 1.8f,
margin = 60,
horizontalMargin = 72,
verticalMargin = 90,
pageWidth = 940,
paragraphSpacing = 1.4f,
imageScale = 1.35f,
textAlign = SharedReaderTextAlign.JUSTIFY,
fontFamily = "Serif"
)
)
assertTrue(script.contains("root.style.setProperty('--reader-font-size', \"24px\");"))
assertTrue(script.contains("root.style.setProperty('--reader-line-height', \"1.8\");"))
assertTrue(script.contains("root.style.setProperty('--reader-page-width', \"940px\");"))
assertTrue(script.contains("root.style.setProperty('--reader-margin-x', \"72px\");"))
assertTrue(script.contains("root.style.setProperty('--reader-vertical-margin-y', \"30px\");"))
assertTrue(script.contains("root.style.setProperty('--reader-vertical-page-width', 'max(0px, calc(100% - (var(--reader-margin-x) * 2)))');"))
assertTrue(script.contains("root.style.setProperty('--reader-image-scale', \"135%\");"))
assertTrue(script.contains("root.style.setProperty('--reader-align', \"justify\");"))
assertTrue(script.contains("root.style.setProperty('--reader-family', \"Georgia, 'Times New Roman', serif\");"))
}
@Test
fun `page anchor update script avoids full vertical document reload`() {
val script = ReaderHtmlDocumentBuilder.pageAnchorsUpdateScript(
listOf(
ReaderPage(3, 1, "Two", "chapter", 42, 84)
)
)
assertTrue(script.contains("window.readerSetPageAnchors"))
assertTrue(script.contains("""{"pageIndex":3,"chapterIndex":1,"startOffset":42,"endOffset":84}"""))
}
@Test
@ -284,6 +658,7 @@ class ReaderHtmlDocumentBuilderTest {
assertFalse(html.contains("""data-action="define""""))
assertFalse(html.contains("""data-action="speak""""))
assertTrue(html.contains("""data-action="web-search""""))
assertTrue(html.contains("""data-action="palette""""))
assertTrue(html.contains("""aria-label="Search""""))
assertTrue(html.contains("""<svg viewBox="0 0 960 960""""))
assertFalse(html.contains("""data-action="dictionary""""))
@ -311,6 +686,7 @@ class ReaderHtmlDocumentBuilderTest {
assertFalse(html.contains("""data-action="web-search""""))
assertFalse(html.contains("""data-action="translate""""))
assertFalse(html.contains("""data-action="find""""))
assertTrue(html.contains("""data-action="palette""""))
assertTrue(html.contains("""data-action="copy""""))
assertTrue(html.contains("""data-action="clear""""))
}
@ -343,6 +719,121 @@ class ReaderHtmlDocumentBuilderTest {
assertTrue(html.contains("document.addEventListener('contextmenu'"))
}
@Test
fun `vertical selection script derives offsets from raw html chapters`() {
val html = ReaderHtmlDocumentBuilder.verticalDocument(
book = SharedEpubBook(
id = "book",
fileName = "book.epub",
title = "Book",
chapters = listOf(
SharedEpubChapter(
id = "one",
title = "One",
plainText = "Alpha beta Gamma delta",
htmlContent = "<p><span>Alpha beta</span></p><p><span>Gamma delta</span></p>"
)
)
),
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
)
assertTrue(html.contains("function normalizedOffsetForBoundary(root, container, offset)"))
assertTrue(html.contains("var explicitOffset = absoluteOffsetForBoundary(content, container, offset);"))
assertTrue(html.contains("var normalizedOffset = normalizedOffsetForBoundary(content, container, offset);"))
assertTrue(html.contains("return normalizedOffset === null ? null : contentStartOffset(content) + normalizedOffset;"))
assertTrue(html.contains("var boundaryInside = nodeInside(content, range.startContainer) || nodeInside(content, range.endContainer);"))
assertTrue(html.contains("range.intersectsNode(content)"))
assertTrue(html.contains("selection_segments_rejected contents="))
assertTrue(html.contains("function readerHighlightFlowLog(message)"))
assertTrue(html.contains("selection_begin mode="))
assertTrue(html.contains("var actionSegments = selectionSegmentsForRange(actionRange);"))
assertTrue(html.contains("payload.locator = {"))
assertTrue(html.contains("window.kmpJsBridge.callNative('readerSelectionAction', JSON.stringify(payload));"))
assertTrue(html.contains("sendSelectionAction('palette', text)"))
assertTrue(html.contains("bridge_send_success attempt="))
assertTrue(html.contains("<p><span>Alpha beta</span></p><p><span>Gamma delta</span></p>"))
}
@Test
fun `vertical document prefers semantic blocks when available for cross mode locators`() {
val html = ReaderHtmlDocumentBuilder.verticalDocument(
book = SharedEpubBook(
id = "book",
fileName = "book.epub",
title = "Book",
chapters = listOf(
SharedEpubChapter(
id = "one",
title = "One",
plainText = "Semantic text",
htmlContent = "<p>Raw text</p>",
semanticBlocks = listOf(
SemanticParagraph(
text = "Semantic text",
spans = emptyList(),
style = CssStyle(),
elementId = null,
cfi = "/4/2/4",
startCharOffsetInSource = 40,
blockIndex = 12
)
)
)
)
),
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
)
assertTrue(html.contains("Semantic text"))
assertTrue(html.contains("""data-reader-cfi="/4/2/4""""))
assertTrue(html.contains("""data-reader-block-index="12""""))
assertFalse(html.contains("Raw text"))
}
@Test
fun `vertical selection menu renders every configured highlight palette slot`() {
val html = ReaderHtmlDocumentBuilder.verticalDocument(
book = repeatedWordBook("alpha beta"),
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL),
highlightPalette = ReaderHighlightPalette(
listOf(
HighlightColor.CYAN,
HighlightColor.MAGENTA,
HighlightColor.LIME,
HighlightColor.PINK
)
)
)
assertEquals(4, Regex("""class="reader-selection-color"""").findAll(html).count())
assertTrue(html.contains("""data-color-id="cyan""""))
assertTrue(html.contains("""data-color-id="pink""""))
assertTrue(html.contains("""class="reader-selection-spectrum""""))
assertTrue(html.contains("""data-action="palette""""))
}
@Test
fun `highlight palette update script replaces selection menu colors without document reload`() {
val script = ReaderHtmlDocumentBuilder.highlightPaletteUpdateScript(
ReaderHighlightPalette(
listOf(
HighlightColor.CYAN,
HighlightColor.MAGENTA,
HighlightColor.LIME,
HighlightColor.PINK
)
)
)
assertTrue(script.contains("reader-selection-colors"))
assertTrue(script.contains("""data-color-id=\"cyan\""""))
assertTrue(script.contains("""data-color-id=\"pink\""""))
assertTrue(script.contains("""reader-selection-spectrum"""))
assertTrue(script.contains("""data-action=\"palette\""""))
assertFalse(script.contains("location.reload"))
}
@Test
fun `selection menu renders icons and draggable handles`() {
val html = ReaderHtmlDocumentBuilder.pageDocument(
@ -402,10 +893,12 @@ class ReaderHtmlDocumentBuilderTest {
assertTrue(html.contains("function readerHostsForLocator(chapterIndex, startOffset, endOffset)"))
assertTrue(html.contains("function readerHostForLocator(chapterIndex, startOffset, endOffset)"))
assertTrue(html.contains("function readerHostElementForCfiPoint(chapterIndex, cfiPoint)"))
assertTrue(html.contains("var hosts = Array.prototype.slice.call(document.querySelectorAll(chapterSelector));"))
assertTrue(html.contains("var targetChapters = readerHostsForLocator(chapterIndex, startOffset, endOffset);"))
assertTrue(html.contains("var chapter = readerHostForLocator(chapterIndex, startOffset, endOffset);"))
assertTrue(html.contains("data-reader-active-page-index"))
assertTrue(html.contains("positionFromReaderHost(activePage, activeStart)"))
assertTrue(html.contains("positionFromReaderHost(activePage, activeStart, readerProbeY(), 'active_page')"))
}
@Test
@ -436,7 +929,8 @@ class ReaderHtmlDocumentBuilderTest {
assertTrue(html.contains("function selectionSegmentsForRange(range)"))
assertTrue(html.contains("var sameChapter = segments.every(function (segment)"))
assertTrue(html.contains("var cfi = 'desktop:' + chapterIndex + ':' + startOffset + ':' + endOffset;"))
assertTrue(html.contains("var cfi = readerHighlightCfiForRange(firstSegment, lastSegment, chapterIndex, startOffset, endOffset);"))
assertTrue(html.contains("return startPoint + '|' + endPoint;"))
assertTrue(html.contains("payloads.forEach(function (payload)"))
assertTrue(html.contains("wrapRangeTextSegments(segment.range"))
}
@ -477,7 +971,7 @@ class ReaderHtmlDocumentBuilderTest {
title = "One",
plainText = "Before image after image.",
semanticBlocks = listOf(
SemanticParagraph("Before image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 0),
SemanticParagraph("Before image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 0, blockIndex = 7),
SemanticImage("data:image/png;base64,abc", "Cover", null, null, CssStyle(), "cover-image", "/4/2"),
SemanticParagraph("after image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 13)
)
@ -492,6 +986,8 @@ class ReaderHtmlDocumentBuilderTest {
)
assertTrue(html.contains("""<img src="data:image/png;base64,abc" alt="Cover""""))
assertTrue(html.contains("""loading="lazy" decoding="async""""))
assertTrue(html.contains("""data-reader-block-index="7""""))
assertTrue(html.contains("""data-reader-cfi="/4/2""""))
assertTrue(html.contains("""data-reader-block-index="0""""))
}
@ -735,4 +1231,23 @@ class ReaderHtmlDocumentBuilderTest {
)
)
}
private fun semanticHighlightBook(leftText: String, rightText: String): SharedEpubBook {
return SharedEpubBook(
id = "book",
fileName = "book.epub",
title = "Book",
chapters = listOf(
SharedEpubChapter(
id = "one",
title = "One",
plainText = "$leftText\n\n$rightText",
semanticBlocks = listOf(
SemanticParagraph(leftText, emptyList(), CssStyle(), null, "/4/2", startCharOffsetInSource = 100, blockIndex = 42),
SemanticParagraph(rightText, emptyList(), CssStyle(), null, "/4/4", startCharOffsetInSource = 200, blockIndex = 43)
)
)
)
)
}
}

View file

@ -30,6 +30,20 @@ class ReaderSpreadLayoutTest {
assertEquals(3, ReaderSpreadLayout.pageNumberForSliderPosition(2, pageCount = 10, settings = settings))
}
@Test
fun `right to left pagination reverses only the displayed spread order`() {
val settings = ReaderSettings(
readingMode = ReaderReadingMode.PAGINATED,
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
rightToLeftPagination = true
)
assertEquals(listOf(2, 3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
assertEquals(listOf(3, 2), ReaderSpreadLayout.visiblePageIndicesForDisplay(3, pageCount = 10, settings = settings))
assertEquals(4, ReaderSpreadLayout.nextPageIndex(2, pageCount = 10, settings = settings))
assertEquals(0, ReaderSpreadLayout.previousPageIndex(2, pageCount = 10, settings = settings))
}
@Test
fun `two page mode advances by spread and clamps odd final page`() {
val settings = ReaderSettings(

View file

@ -39,22 +39,63 @@ class NonReaderLayoutModelsTest {
}
@Test
fun `desktop library keeps browse focused on books shelves and folders`() {
fun `desktop library includes organization and reading status tabs`() {
val visibleTabs = visibleNonReaderLibraryTabs(ReaderPlatform.DESKTOP)
assertEquals(
listOf(
NonReaderLibraryTab.BOOKS,
NonReaderLibraryTab.SHELVES,
NonReaderLibraryTab.FOLDERS
NonReaderLibraryTab.FOLDERS,
NonReaderLibraryTab.UNREAD,
NonReaderLibraryTab.IN_PROGRESS,
NonReaderLibraryTab.COMPLETED
),
visibleTabs
)
assertFalse(NonReaderLibraryTab.SMART_SHELVES in visibleTabs)
assertFalse(NonReaderLibraryTab.TAGS in visibleTabs)
assertFalse(NonReaderLibraryTab.UNREAD in visibleTabs)
assertFalse(NonReaderLibraryTab.IN_PROGRESS in visibleTabs)
assertFalse(NonReaderLibraryTab.COMPLETED in visibleTabs)
}
@Test
fun `desktop shelves tab exposes primary new shelf action only on desktop`() {
assertEquals(
listOf(NonReaderLibraryPrimaryAction.NEW_SHELF),
primaryLibraryActionsForTab(NonReaderLibraryTab.SHELVES, ReaderPlatform.DESKTOP)
)
assertEquals(
emptyList<NonReaderLibraryPrimaryAction>(),
primaryLibraryActionsForTab(NonReaderLibraryTab.SHELVES, ReaderPlatform.ANDROID)
)
assertEquals(
emptyList<NonReaderLibraryPrimaryAction>(),
primaryLibraryActionsForTab(NonReaderLibraryTab.BOOKS, ReaderPlatform.DESKTOP)
)
}
@Test
fun `desktop book overflow exposes add to shelf action without changing android`() {
assertEquals(
setOf(NonReaderBookOverflowAction.ADD_TO_SHELF),
bookOverflowActionsForPlatform(ReaderPlatform.DESKTOP)
)
assertEquals(emptySet<NonReaderBookOverflowAction>(), bookOverflowActionsForPlatform(ReaderPlatform.ANDROID))
}
@Test
fun `desktop library command bar uses inline layout only on wide panes`() {
assertEquals(
LibraryCommandBarLayout.STACKED,
libraryCommandBarLayoutForWidth(widthDp = 979f, platform = ReaderPlatform.DESKTOP)
)
assertEquals(
LibraryCommandBarLayout.INLINE,
libraryCommandBarLayoutForWidth(widthDp = 980f, platform = ReaderPlatform.DESKTOP)
)
assertEquals(
LibraryCommandBarLayout.STACKED,
libraryCommandBarLayoutForWidth(widthDp = 1200f, platform = ReaderPlatform.ANDROID)
)
}
@Test
@ -71,7 +112,7 @@ class NonReaderLayoutModelsTest {
assertTrue(FileType.PPTX in groupedTypes)
assertTrue(
nonReaderLibraryFileTypeGroups()
.any { it.title == "Comics" && FileType.CBR in it.fileTypes && FileType.CB7 in it.fileTypes }
.any { it.title == "Comics" && FileType.CBR in it.fileTypes && FileType.CB7 in it.fileTypes && FileType.CBT in it.fileTypes }
)
}
@ -176,7 +217,7 @@ class NonReaderLayoutModelsTest {
}
@Test
fun `hidden desktop status tabs fall back to all books`() {
fun `desktop status tabs filter books while android hidden statuses fall back`() {
val unread = book("unread", type = FileType.EPUB, progress = 0f)
val inProgress = book("progress", type = FileType.PDF, progress = 44f)
val complete = book("complete", type = FileType.CBZ, progress = 100f)
@ -186,17 +227,21 @@ class NonReaderLayoutModelsTest {
)
assertEquals(
listOf("unread", "progress", "complete"),
listOf("unread"),
state.booksForNonReaderLibraryTab(NonReaderLibraryTab.UNREAD, ReaderPlatform.DESKTOP).map { it.id }
)
assertEquals(
listOf("unread", "progress", "complete"),
listOf("progress"),
state.visibleBooksForLibrarySelection(NonReaderLibraryTab.IN_PROGRESS, ReaderPlatform.DESKTOP).map { it.id }
)
assertEquals(
listOf("unread", "progress", "complete"),
listOf("complete"),
state.booksForNonReaderLibraryTab(NonReaderLibraryTab.COMPLETED, ReaderPlatform.DESKTOP).map { it.id }
)
assertEquals(
listOf("unread", "progress", "complete"),
state.booksForNonReaderLibraryTab(NonReaderLibraryTab.UNREAD, ReaderPlatform.ANDROID).map { it.id }
)
}
@Test
@ -250,34 +295,100 @@ class NonReaderLayoutModelsTest {
}
@Test
fun `shell model keeps primary navigation simple and exposes all tool actions`() {
fun `shell model keeps account in primary navigation and exposes more actions`() {
val model = sharedAppShellModel(
selectedTab = SharedAppTab.CUSTOM_FONTS,
aiSettingsAvailable = true
)
assertEquals(
listOf(SharedAppTab.LIBRARY, SharedAppTab.CATALOGS),
listOf(SharedAppTab.LIBRARY, SharedAppTab.CATALOGS, SharedAppTab.PRO),
model.primaryTabs
)
assertEquals(listOf(SharedAppToolAction.AI_SETTINGS), model.primaryActions)
assertEquals(SharedAppTab.LIBRARY, model.selectedPrimaryTab)
assertTrue(SharedAppToolAction.IMPORT_FILES in model.toolActions)
assertTrue(SharedAppToolAction.SETTINGS in model.toolActions)
assertTrue(SharedAppToolAction.IMPORT_FOLDER in model.toolActions)
assertTrue(SharedAppToolAction.SYNC in model.toolActions)
assertTrue(SharedAppToolAction.APP_THEME in model.toolActions)
assertTrue(SharedAppToolAction.PRO in model.toolActions)
assertTrue(SharedAppToolAction.AI_SETTINGS in model.toolActions)
assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions)
assertTrue(SharedAppToolAction.HELP_FEEDBACK in model.toolActions)
assertTrue(SharedAppToolAction.SUPPORT in model.toolActions)
assertTrue(SharedAppToolAction.ABOUT in model.toolActions)
assertTrue(SharedAppToolAction.TABS_TOGGLE in model.toolActions)
assertFalse(SharedAppToolAction.IMPORT_FILES in model.toolActions)
assertFalse(SharedAppToolAction.IMPORT_FOLDER in model.toolActions)
assertFalse(SharedAppToolAction.SYNC in model.toolActions)
assertFalse(SharedAppToolAction.PRO in model.toolActions)
assertFalse(SharedAppToolAction.TABS_TOGGLE in model.toolActions)
assertTrue(model.showPrimaryNavigation)
assertEquals(
listOf(
SharedAppMoreGroup.PREFERENCES,
SharedAppMoreGroup.HELP
),
model.moreSections.map { it.group }
)
val accountModel = sharedAppShellModel(SharedAppTab.PRO, aiSettingsAvailable = true)
assertEquals(SharedAppTab.PRO, accountModel.selectedPrimaryTab)
val withoutAi = sharedAppShellModel(SharedAppTab.SHELVES, aiSettingsAvailable = false)
assertEquals(SharedAppTab.LIBRARY, withoutAi.selectedPrimaryTab)
assertEquals(emptyList(), withoutAi.primaryActions)
assertFalse(SharedAppToolAction.AI_SETTINGS in withoutAi.toolActions)
val byokModel = sharedAppShellModel(
selectedTab = SharedAppTab.LIBRARY,
aiSettingsAvailable = true,
featurePolicy = SharedFeaturePolicy.OssOnline
)
assertEquals(emptyList(), byokModel.primaryActions)
}
@Test
fun `shell model groups more menu preferences and help only`() {
val model = sharedAppShellModel(
selectedTab = SharedAppTab.LIBRARY,
aiSettingsAvailable = true
)
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.LIBRARY })
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.ACCOUNT })
assertEquals(
listOf(
SharedAppToolAction.SETTINGS,
SharedAppToolAction.APP_THEME,
SharedAppToolAction.AI_SETTINGS,
SharedAppToolAction.CUSTOM_FONTS
),
model.moreSections.single { it.group == SharedAppMoreGroup.PREFERENCES }.actions
)
assertEquals(
listOf(
SharedAppToolAction.HELP_FEEDBACK,
SharedAppToolAction.SUPPORT,
SharedAppToolAction.ABOUT
),
model.moreSections.single { it.group == SharedAppMoreGroup.HELP }.actions
)
val legacyActions = sharedAppMoreSections(
listOf(
SharedAppToolAction.IMPORT_FILES,
SharedAppToolAction.PRO,
SharedAppToolAction.SETTINGS,
SharedAppToolAction.TABS_TOGGLE,
SharedAppToolAction.ABOUT
)
)
assertEquals(
listOf(SharedAppMoreGroup.PREFERENCES, SharedAppMoreGroup.HELP),
legacyActions.map { it.group }
)
assertEquals(
listOf(SharedAppToolAction.SETTINGS),
legacyActions.single { it.group == SharedAppMoreGroup.PREFERENCES }.actions
)
}
@Test
@ -304,17 +415,68 @@ class NonReaderLayoutModelsTest {
)
assertEquals(listOf(SharedAppTab.LIBRARY), model.primaryTabs)
assertEquals(emptyList(), model.primaryActions)
assertEquals(SharedAppTab.LIBRARY, model.selectedPrimaryTab)
assertFalse(SharedAppToolAction.PRO in model.toolActions)
assertFalse(SharedAppToolAction.AI_SETTINGS in model.toolActions)
assertFalse(SharedAppToolAction.HELP_FEEDBACK in model.toolActions)
assertFalse(SharedAppToolAction.SUPPORT in model.toolActions)
assertFalse(SharedAppToolAction.PRO in model.toolActions)
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.ACCOUNT })
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.LIBRARY })
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.HELP && SharedAppToolAction.SUPPORT in it.actions })
assertTrue(SharedAppToolAction.SETTINGS in model.toolActions)
assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions)
assertTrue(SharedAppToolAction.ABOUT in model.toolActions)
assertTrue(model.showPrimaryNavigation)
}
@Test
fun `sidebar sync toggle is visible only for signed in account builds and follows pro gating`() {
assertEquals(
SharedSidebarSyncToggleModel(visible = false, enabled = false, checked = false),
sharedSidebarSyncToggleModel(
isSignedIn = false,
accountAvailable = true,
syncAvailable = true,
isProUser = true,
isSyncEnabled = true
)
)
assertEquals(
SharedSidebarSyncToggleModel(visible = true, enabled = true, checked = true),
sharedSidebarSyncToggleModel(
isSignedIn = true,
accountAvailable = true,
syncAvailable = true,
isProUser = true,
isSyncEnabled = true
)
)
assertEquals(
SharedSidebarSyncToggleModel(visible = true, enabled = false, checked = true),
sharedSidebarSyncToggleModel(
isSignedIn = true,
accountAvailable = true,
syncAvailable = true,
isProUser = false,
isSyncEnabled = true
)
)
assertFalse(
sharedSidebarSyncToggleModel(
isSignedIn = true,
accountAvailable = false,
syncAvailable = true,
isProUser = true,
isSyncEnabled = true,
featurePolicy = SharedFeaturePolicy.OssOffline
).visible
)
}
@Test
fun `collection cover stack uses Android cover order and limit`() {
val books = listOf(

View file

@ -0,0 +1,42 @@
package com.aryan.reader.shared.ui
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ReaderMinimalSliderTest {
@Test
fun markerFractionMapsValueIntoRange() {
assertEquals(
0.5f,
readerMinimalSliderMarkerFraction(
markerValue = 5f,
valueRange = 0f..10f
)
)
}
@Test
fun markerFractionClampsOutsideRange() {
assertEquals(
0f,
readerMinimalSliderMarkerFraction(
markerValue = -4f,
valueRange = 1f..9f
)
)
assertEquals(
1f,
readerMinimalSliderMarkerFraction(
markerValue = 12f,
valueRange = 1f..9f
)
)
}
@Test
fun markerFractionIsAbsentForMissingOrEmptyRange() {
assertNull(readerMinimalSliderMarkerFraction(null, 0f..10f))
assertNull(readerMinimalSliderMarkerFraction(4f, 5f..5f))
}
}

View file

@ -45,7 +45,7 @@ class ReaderWorkspaceModelsTest {
)
assertFalse(ReaderWorkspaceLeftSection.SEARCH in model.leftSections)
assertFalse(ReaderWorkspaceTopAction.BOOKMARK in model.topActions)
assertFalse(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
assertFalse(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections)
assertTrue(ReaderWorkspaceTopAction.SEARCH in model.topActions)
@ -75,13 +75,193 @@ class ReaderWorkspaceModelsTest {
assertFalse(model.preferAutoHide)
assertTrue(model.forceVisible)
assertEquals(
setOf("search", "inspector", "annotation", "rich-text", "loading", "error", "auto-scroll", "tts"),
setOf("search", "inspector", "annotation", "rich-text", "loading", "error", "auto-scroll"),
model.forceVisibleReasons
)
assertEquals(setOf("tts"), model.revealVisibleReasons)
}
@Test
fun `reader tap toggles chrome when it is not locked or forced`() {
assertTrue(
readerWorkspaceChromeVisibleAfterReaderTap(
requestedVisible = false,
lockedVisible = false,
forcedVisible = false
)
)
assertFalse(
readerWorkspaceChromeVisibleAfterReaderTap(
requestedVisible = true,
lockedVisible = false,
forcedVisible = false
)
)
}
@Test
fun `epub workspace ignores desktop visual options in inspector`() {
fun `reader tap closes inspector and reveals chrome when panel suppresses bars`() {
assertTrue(
readerWorkspaceChromeVisibleAfterReaderTap(
requestedVisible = false,
lockedVisible = false,
forcedVisible = false,
rightPanelClosedByTap = true
)
)
assertTrue(
readerWorkspaceShouldCloseRightPanelAfterReaderTap(
rightPanelOpen = true,
hasInspectorSections = true,
closeRightPanelOnReaderTap = true
)
)
assertFalse(
readerWorkspaceShouldCloseRightPanelAfterReaderTap(
rightPanelOpen = true,
hasInspectorSections = false,
closeRightPanelOnReaderTap = true
)
)
assertFalse(
readerWorkspaceShouldCloseRightPanelAfterReaderTap(
rightPanelOpen = true,
hasInspectorSections = true,
closeRightPanelOnReaderTap = false
)
)
}
@Test
fun `active tts reveals chrome without locking reader tap toggle`() {
val model = readerWorkspaceChromeModel(
preferAutoHide = true,
searchActive = false,
leftPanelOpen = false,
inspectorOpen = false,
annotationEditing = false,
richTextEditing = false,
loading = false,
errorMessage = null,
autoScroll = ReaderAutoScrollState(),
ttsBusy = true
)
assertFalse(model.forceVisible)
assertEquals(emptySet(), model.forceVisibleReasons)
assertEquals(setOf("tts"), model.revealVisibleReasons)
assertFalse(
readerWorkspaceChromeVisibleAfterReaderTap(
requestedVisible = true,
lockedVisible = false,
forcedVisible = model.forceVisible
)
)
}
@Test
fun `locked or forced reader chrome stays visible after reader taps`() {
assertTrue(
readerWorkspaceChromeVisible(
requestedVisible = false,
lockedVisible = true,
forcedVisible = false
)
)
assertTrue(
readerWorkspaceChromeVisibleAfterReaderTap(
requestedVisible = false,
lockedVisible = false,
forcedVisible = true
)
)
}
@Test
fun `left reader panel is drawn with chrome while preserving its toggle state`() {
assertFalse(
readerWorkspaceLeftPanelVisible(
toggledOpen = true,
chromeVisible = false,
hasNavigationSections = true
)
)
assertTrue(
readerWorkspaceLeftPanelVisible(
toggledOpen = true,
chromeVisible = true,
hasNavigationSections = true
)
)
assertFalse(
readerWorkspaceLeftPanelVisible(
toggledOpen = false,
chromeVisible = true,
hasNavigationSections = true
)
)
}
@Test
fun `reader focus is restored after closing the final workspace panel`() {
assertTrue(
readerWorkspaceShouldRestoreFocusAfterPanelClose(
closingPanelOpen = true,
otherPanelOpen = false
)
)
assertFalse(
readerWorkspaceShouldRestoreFocusAfterPanelClose(
closingPanelOpen = false,
otherPanelOpen = false
)
)
assertFalse(
readerWorkspaceShouldRestoreFocusAfterPanelClose(
closingPanelOpen = true,
otherPanelOpen = true
)
)
}
@Test
fun `reader focus is restored when an open sidebar is hidden with chrome`() {
assertTrue(
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
wasPanelVisible = true,
isPanelVisible = false,
panelOpen = true,
otherPanelOpen = false
)
)
assertFalse(
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
wasPanelVisible = true,
isPanelVisible = true,
panelOpen = true,
otherPanelOpen = false
)
)
assertFalse(
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
wasPanelVisible = true,
isPanelVisible = false,
panelOpen = true,
otherPanelOpen = true
)
)
assertFalse(
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
wasPanelVisible = true,
isPanelVisible = false,
panelOpen = false,
otherPanelOpen = false
)
)
}
@Test
fun `epub workspace exposes visual options through tools popup`() {
val session = ReaderEngine().createSession(readerFixtureBook())
val preferences = ReaderToolbarPreferences(
hiddenToolIds = ReaderTool.entries
@ -96,8 +276,31 @@ class ReaderWorkspaceModelsTest {
aiAvailable = true
)
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
assertTrue(ReaderWorkspaceTopAction.TOOLS in model.topActions)
}
@Test
fun `epub workspace maps reading mode into appearance popup instead of tools inspector`() {
val session = ReaderEngine().createSession(readerFixtureBook())
val preferences = ReaderToolbarPreferences(
hiddenToolIds = ReaderTool.entries
.filterNot { it == ReaderTool.READING_MODE }
.mapTo(mutableSetOf()) { it.id }
)
val model = epubReaderWorkspaceModel(
session = session,
toolbarPreferences = preferences,
extrasState = ReaderExtrasState(),
aiAvailable = false,
cloudTtsAvailable = false,
externalLookupAvailable = false
)
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
assertFalse(ReaderWorkspaceInspectorSection.TOOLS in model.inspectorSections)
assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions)
assertTrue(ReaderWorkspaceTopAction.TOOLS in model.topActions)
}
@Test
@ -122,12 +325,32 @@ class ReaderWorkspaceModelsTest {
assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions)
}
@Test
fun `epub workspace exposes tools popup for desktop app theme controls`() {
val session = ReaderEngine().createSession(readerFixtureBook())
val preferences = ReaderToolbarPreferences(
hiddenToolIds = ReaderTool.entries.mapTo(mutableSetOf()) { it.id }
)
val model = epubReaderWorkspaceModel(
session = session,
toolbarPreferences = preferences,
extrasState = ReaderExtrasState(),
aiAvailable = false,
cloudTtsAvailable = false,
externalLookupAvailable = false,
appThemeControlsAvailable = true
)
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
assertTrue(ReaderWorkspaceTopAction.TOOLS in model.topActions)
}
@Test
fun `toolbar quick actions preserve visibility order and bottom placement`() {
val preferences = ReaderToolbarPreferences(
hiddenToolIds = setOf(ReaderTool.BOOKMARK.id),
toolOrder = listOf(
ReaderTool.AUTO_SCROLL,
ReaderTool.SEARCH,
ReaderTool.AI_FEATURES,
ReaderTool.THEME,
@ -152,11 +375,12 @@ class ReaderWorkspaceModelsTest {
aiAvailable = true
)
assertEquals(listOf(ReaderTool.AUTO_SCROLL, ReaderTool.THEME), topTools.take(2))
assertEquals(ReaderTool.THEME, topTools.first())
assertEquals(listOf(ReaderTool.SEARCH), bottomToolsWithoutAi)
assertEquals(listOf(ReaderTool.SEARCH, ReaderTool.AI_FEATURES), bottomToolsWithAi)
assertEquals(listOf(ReaderTool.SEARCH), bottomToolsWithAi)
assertFalse(ReaderTool.BOOKMARK in topTools)
assertFalse(ReaderTool.BOOKMARK in bottomToolsWithAi)
assertFalse(ReaderTool.AI_FEATURES in bottomToolsWithAi)
}
@Test
@ -187,6 +411,97 @@ class ReaderWorkspaceModelsTest {
assertEquals(listOf(ReaderTool.SEARCH), tools)
}
@Test
fun `tts controls use top read aloud action instead of toolbar quick action`() {
val session = ReaderEngine().createSession(readerFixtureBook())
val preferences = ReaderToolbarPreferences(
toolOrder = listOf(ReaderTool.TTS_CONTROLS) + ReaderTool.entries,
bottomToolIds = setOf(ReaderTool.TTS_CONTROLS.id)
)
val tools = readerWorkspaceQuickActionTools(
toolbarPreferences = preferences,
bottom = true,
aiAvailable = false,
cloudTtsAvailable = true,
externalLookupAvailable = true
)
val model = epubReaderWorkspaceModel(
session = session,
toolbarPreferences = preferences,
extrasState = ReaderExtrasState(),
aiAvailable = false,
cloudTtsAvailable = true,
externalLookupAvailable = true
)
assertFalse(ReaderTool.TTS_CONTROLS in tools)
assertTrue(ReaderWorkspaceTopAction.READ_ALOUD in model.topActions)
}
@Test
fun `ai features use top hub action instead of toolbar quick action`() {
val session = ReaderEngine().createSession(readerFixtureBook())
val preferences = ReaderToolbarPreferences(
hiddenToolIds = ReaderTool.entries
.filterNot { it == ReaderTool.AI_FEATURES }
.mapTo(mutableSetOf()) { it.id },
toolOrder = listOf(ReaderTool.AI_FEATURES) + ReaderTool.entries,
bottomToolIds = setOf(ReaderTool.AI_FEATURES.id)
)
val tools = readerWorkspaceQuickActionTools(
toolbarPreferences = preferences,
bottom = true,
aiAvailable = true,
cloudTtsAvailable = false,
externalLookupAvailable = true
)
val model = epubReaderWorkspaceModel(
session = session,
toolbarPreferences = preferences,
extrasState = ReaderExtrasState(),
aiAvailable = true,
cloudTtsAvailable = false,
externalLookupAvailable = true
)
assertFalse(ReaderTool.AI_FEATURES in tools)
assertTrue(ReaderWorkspaceTopAction.AI in model.topActions)
assertFalse(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
}
@Test
fun `retired auto scroll preferences are ignored for desktop reader tools`() {
val session = ReaderEngine().createSession(readerFixtureBook())
val preferences = ReaderToolbarPreferences(
hiddenToolIds = setOf("auto_scroll"),
toolOrder = ReaderTool.entries
)
val tools = readerWorkspaceQuickActionTools(
toolbarPreferences = preferences,
bottom = false,
aiAvailable = false,
cloudTtsAvailable = false,
externalLookupAvailable = false
)
val model = epubReaderWorkspaceModel(
session = session,
toolbarPreferences = preferences,
extrasState = ReaderExtrasState(autoScroll = ReaderAutoScrollState(enabled = true)),
aiAvailable = false,
cloudTtsAvailable = false,
externalLookupAvailable = false
)
assertNull(ReaderTool.fromId("auto_scroll"))
assertFalse("auto_scroll" in preferences.sanitized().hiddenToolIds)
assertFalse(tools.any { it.id == "auto_scroll" })
assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions)
assertFalse("auto-scroll" in model.chrome.forceVisibleReasons)
}
@Test
fun `pdf workspace defaults to reading first while keeping annotation tools in inspector`() {
val model = pdfReaderWorkspaceModel(
@ -230,7 +545,7 @@ class ReaderWorkspaceModelsTest {
}
@Test
fun `pdf workspace forces chrome for search editing errors tts and vertical auto scroll`() {
fun `pdf workspace forces chrome for search editing errors and reveals tts`() {
val model = pdfReaderWorkspaceModel(
state = SharedPdfReaderState.initial(pageCount = 4).copy(searchQuery = "needle"),
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
@ -255,8 +570,9 @@ class ReaderWorkspaceModelsTest {
assertTrue("search" in model.chrome.forceVisibleReasons)
assertTrue("annotation" in model.chrome.forceVisibleReasons)
assertTrue("error" in model.chrome.forceVisibleReasons)
assertTrue("auto-scroll" in model.chrome.forceVisibleReasons)
assertTrue("tts" in model.chrome.forceVisibleReasons)
assertFalse("auto-scroll" in model.chrome.forceVisibleReasons)
assertFalse("tts" in model.chrome.forceVisibleReasons)
assertTrue("tts" in model.chrome.revealVisibleReasons)
assertFalse(ReaderWorkspaceTopAction.AI in model.topActions)
}

View file

@ -1,8 +1,18 @@
package com.aryan.reader.shared.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import com.aryan.reader.paginatedreader.CssStyle
import com.aryan.reader.paginatedreader.SemanticParagraph
import com.aryan.reader.shared.HighlightColor
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.UserHighlight
import com.aryan.reader.shared.reader.ReaderPage
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@ -31,6 +41,24 @@ class SharedNativePaginatedReaderInteractionTest {
assertNull(range)
}
@Test
fun `selection gesture key ignores paint-only annotated string changes`() {
val plain = AnnotatedString("Alpha beta")
val selected = buildAnnotatedString {
append("Alpha beta")
addStyle(SpanStyle(background = Color.Blue), start = 0, end = 5)
}
assertEquals(
sharedNativeReaderSelectionGestureKey("0:1:0", plain),
sharedNativeReaderSelectionGestureKey("0:1:0", selected)
)
assertNotEquals(
sharedNativeReaderSelectionGestureKey("0:1:0", plain),
sharedNativeReaderSelectionGestureKey("0:1:0", AnnotatedString("Alpha beta gamma"))
)
}
@Test
fun `highlight for native selection keeps desktop locator offsets`() {
val selection = SharedNativeReaderTextSelection(
@ -78,6 +106,272 @@ class SharedNativePaginatedReaderInteractionTest {
assertEquals(4, highlight.locator.pageIndex)
assertEquals(105, highlight.locator.startOffset)
assertEquals(220, highlight.locator.endOffset)
assertEquals(8, highlight.locator.blockIndex)
assertEquals(105, highlight.locator.charOffset)
assertEquals("selected across blocks", highlight.locator.textQuote)
}
@Test
fun `native paginated keeps cfi highlights visible only on anchored page`() {
val highlight = UserHighlight(
id = "highlight-1",
cfi = "/4/2:3|/4/2:8",
text = "alpha",
color = HighlightColor.YELLOW,
chapterIndex = 2
)
val page = ReaderPage(
pageIndex = 20,
chapterIndex = 2,
chapterTitle = "Chapter",
text = "alpha beta",
startOffset = 100,
endOffset = 110,
semanticBlocks = listOf(
SemanticParagraph(
text = "alpha beta",
spans = emptyList(),
style = CssStyle(),
elementId = null,
cfi = "/4/2",
startCharOffsetInSource = 100,
blockIndex = 7
)
)
)
val unrelatedPage = ReaderPage(
pageIndex = 21,
chapterIndex = 2,
chapterTitle = "Chapter",
text = "alpha beta",
startOffset = 200,
endOffset = 210,
semanticBlocks = listOf(
SemanticParagraph(
text = "alpha beta",
spans = emptyList(),
style = CssStyle(),
elementId = null,
cfi = "/4/4",
startCharOffsetInSource = 200,
blockIndex = 8
)
)
)
val visible = sharedNativeVisibleHighlightsForPage(listOf(highlight), page)
val unrelatedVisible = sharedNativeVisibleHighlightsForPage(listOf(highlight), unrelatedPage)
assertEquals(listOf(highlight), visible)
assertEquals(emptyList(), unrelatedVisible)
}
@Test
fun `native highlight mapping prefers locator offsets before cfi offsets`() {
val highlight = UserHighlight(
id = "highlight-1",
cfi = "/4/2:0|/4/2:5",
text = "target",
color = HighlightColor.YELLOW,
chapterIndex = 0,
locator = ReaderLocator(
chapterIndex = 0,
startOffset = 8,
endOffset = 14,
textQuote = "target",
cfi = "/4/2:0|/4/2:5"
)
)
val range = sharedNativeHighlightRangeForBlock(
highlight = highlight,
blockCfi = "/4/2",
textStartOffset = 0,
textLength = "prefix target suffix".length,
text = "prefix target suffix"
)
assertEquals(8, range?.start)
assertEquals(14, range?.end)
}
@Test
fun `native highlight mapping can use source cfi when locator offsets miss block range`() {
val highlight = UserHighlight(
id = "highlight-1",
cfi = "/4/2:8|/4/2:14",
text = "target",
color = HighlightColor.YELLOW,
chapterIndex = 0,
locator = ReaderLocator(
chapterIndex = 0,
startOffset = 108,
endOffset = 114,
textQuote = "target",
cfi = "/4/2:8|/4/2:14"
)
)
val range = sharedNativeHighlightRangeForBlock(
highlight = highlight,
blockCfi = "/4/2",
textStartOffset = 300,
textLength = "prefix target suffix".length,
text = "prefix target suffix"
)
assertEquals(8, range?.start)
assertEquals(14, range?.end)
}
@Test
fun `native highlight mapping ignores block local offsets on sibling cfi blocks`() {
val highlight = UserHighlight(
id = "highlight-1",
cfi = "/4/2:8|/4/2:14",
text = "target",
color = HighlightColor.YELLOW,
chapterIndex = 0,
locator = ReaderLocator(
chapterIndex = 0,
startOffset = 8,
endOffset = 14,
blockIndex = 42,
charOffset = 8,
textQuote = "target",
cfi = "/4/2:8|/4/2:14"
)
)
val selectedRange = sharedNativeHighlightRangeForBlock(
highlight = highlight,
blockCfi = "/4/2",
blockIndex = 42,
blockCharOffset = 0,
textStartOffset = 0,
textLength = "prefix target suffix".length,
text = "prefix target suffix"
)
val siblingRange = sharedNativeHighlightRangeForBlock(
highlight = highlight,
blockCfi = "/4/4",
blockIndex = 43,
blockCharOffset = 0,
textStartOffset = 0,
textLength = "prefix target suffix".length,
text = "prefix target suffix"
)
assertEquals(8, selectedRange?.start)
assertEquals(14, selectedRange?.end)
assertNull(siblingRange)
}
@Test
fun `native highlight mapping can use android style block locator`() {
val highlight = UserHighlight(
id = "highlight-1",
cfi = "android-locator:0:42:108",
text = "target",
color = HighlightColor.YELLOW,
chapterIndex = 0,
locator = ReaderLocator(
chapterIndex = 0,
blockIndex = 42,
charOffset = 108,
textQuote = "target",
cfi = "android-locator:0:42:108"
)
)
val range = sharedNativeHighlightRangeForBlock(
highlight = highlight,
blockCfi = "/4/2",
blockIndex = 42,
blockCharOffset = 100,
textStartOffset = 100,
textLength = "prefix target suffix".length,
text = "prefix target suffix"
)
assertEquals(8, range?.start)
assertEquals(14, range?.end)
}
@Test
fun `native highlight mapping prefers block locator before overlapping offsets`() {
val highlight = UserHighlight(
id = "highlight-1",
cfi = "android-locator:0:42:108",
text = "target",
color = HighlightColor.YELLOW,
chapterIndex = 0,
locator = ReaderLocator(
chapterIndex = 0,
startOffset = 0,
endOffset = 6,
blockIndex = 42,
charOffset = 108,
textQuote = "target",
cfi = "android-locator:0:42:108"
)
)
val range = sharedNativeHighlightRangeForBlock(
highlight = highlight,
blockCfi = "/4/2",
blockIndex = 42,
blockCharOffset = 100,
textStartOffset = 0,
textLength = "prefix target suffix".length,
text = "prefix target suffix"
)
assertEquals(8, range?.start)
assertEquals(14, range?.end)
}
@Test
fun `native highlight mapping treats source cfi offsets as block local`() {
val highlight = UserHighlight(
id = "highlight-1",
cfi = "/4/2:8|/4/2:14",
text = "target",
color = HighlightColor.YELLOW,
chapterIndex = 0
)
val range = sharedNativeHighlightRangeForBlock(
highlight = highlight,
blockCfi = "/4/2",
textStartOffset = 100,
textLength = "prefix target suffix".length,
text = "prefix target suffix"
)
assertEquals(8, range?.start)
assertEquals(14, range?.end)
}
@Test
fun `native highlight mapping still accepts legacy absolute cfi offsets`() {
val highlight = UserHighlight(
id = "highlight-1",
cfi = "/4/2:108|/4/2:114",
text = "target",
color = HighlightColor.YELLOW,
chapterIndex = 0
)
val range = sharedNativeHighlightRangeForBlock(
highlight = highlight,
blockCfi = "/4/2",
textStartOffset = 100,
textLength = "prefix target suffix".length,
text = "prefix target suffix"
)
assertEquals(8, range?.start)
assertEquals(14, range?.end)
}
}

View file

@ -0,0 +1,77 @@
package com.aryan.reader.shared.ui
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import kotlin.test.Test
import kotlin.test.assertEquals
class SharedPdfAnnotationUiTest {
@Test
fun `text highlight annotations render with readable highlighter blending`() {
val annotation = SharedPdfAnnotation(
id = "highlight-1",
pageIndex = 0,
kind = PdfAnnotationKind.HIGHLIGHT,
tool = PdfInkTool.HIGHLIGHTER,
colorArgb = Color.Yellow.copy(alpha = 0.9f).toArgb()
)
val style = sharedPdfHighlightAnnotationOverlayStyle(annotation)
assertEquals(BlendMode.Multiply, style.blendMode)
assertEquals(SharedPdfAndroidHighlightColors.RenderAlpha, style.color.alpha)
}
@Test
fun `text highlight annotations preserve lower custom opacity`() {
val annotation = SharedPdfAnnotation(
id = "highlight-1",
pageIndex = 0,
kind = PdfAnnotationKind.HIGHLIGHT,
tool = PdfInkTool.HIGHLIGHTER,
colorArgb = Color.Yellow.copy(alpha = 0.18f).toArgb()
)
val style = sharedPdfHighlightAnnotationOverlayStyle(annotation)
assertEquals(0.18f, style.color.alpha, 0.005f)
}
@Test
fun `interaction dock keeps reading modes before markup actions`() {
assertEquals(
listOf(
SharedPdfInteractionDockItem.PAN,
SharedPdfInteractionDockItem.SELECT_TEXT,
SharedPdfInteractionDockItem.PEN,
SharedPdfInteractionDockItem.HIGHLIGHTER,
SharedPdfInteractionDockItem.TEXT_NOTE,
SharedPdfInteractionDockItem.ERASER,
SharedPdfInteractionDockItem.UNDO,
SharedPdfInteractionDockItem.REDO,
SharedPdfInteractionDockItem.CLEAR_PAGE
),
sharedPdfInteractionDockItems()
)
}
@Test
fun `interaction dock only exposes available markup groups`() {
assertEquals(
listOf(
SharedPdfInteractionDockItem.PAN,
SharedPdfInteractionDockItem.SELECT_TEXT,
SharedPdfInteractionDockItem.TEXT_NOTE,
SharedPdfInteractionDockItem.UNDO,
SharedPdfInteractionDockItem.REDO,
SharedPdfInteractionDockItem.CLEAR_PAGE
),
sharedPdfInteractionDockItems(tools = listOf(PdfInkTool.TEXT))
)
}
}

View file

@ -1,8 +1,14 @@
package com.aryan.reader.shared.reader
private const val SharedReaderDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS"
private const val SharedReaderDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS"
private val SharedReaderDiagnosticTags: Set<String> =
System.getProperty(SharedReaderDiagnosticsTagsProperty)
.orEmpty()
listOfNotNull(
System.getProperty(SharedReaderDiagnosticsTagsProperty),
System.getenv(SharedReaderDiagnosticsTagsEnv)
)
.joinToString(" ")
.split(',', ';', ' ', '\t', '\n')
.mapNotNull { rawTag ->
rawTag.trim()
@ -15,9 +21,16 @@ internal actual val SharedReaderDiagnosticsEnabled: Boolean =
System.getProperty(SharedReaderDiagnosticsProperty)
?.trim()
?.equals("true", ignoreCase = true) == true ||
System.getenv(SharedReaderDiagnosticsEnv)
?.trim()
?.equals("true", ignoreCase = true) == true ||
SharedReaderDiagnosticTags.isNotEmpty()
internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean {
if (SharedReaderDiagnosticTags.isEmpty()) return true
return "*" in SharedReaderDiagnosticTags || tag.lowercase() in SharedReaderDiagnosticTags
}
internal actual fun writeSharedReaderDiagnostic(tag: String, message: String) {
println("$tag $message")
}

View file

@ -6,11 +6,12 @@ import org.jetbrains.skia.Image as SkiaImage
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.File
import java.util.concurrent.Semaphore
import javax.imageio.ImageIO
import kotlin.math.roundToInt
internal object DesktopBookCoverImageCache {
private const val MaxEntries = 96
private const val MaxEntries = 160
private const val MaxCoverDimensionPx = 512
private data class Entry(
@ -20,36 +21,40 @@ internal object DesktopBookCoverImageCache {
)
private val entries = LinkedHashMap<String, Entry>(MaxEntries, 0.75f, true)
private val decodeSlots = Semaphore(2)
fun peek(path: String): ImageBitmap? {
return synchronized(entries) {
entries[File(path).absolutePath]?.bitmap
}
}
fun load(path: String): ImageBitmap? {
val file = File(path)
if (!file.isFile) return null
val key = file.absolutePath
val length = file.length()
val lastModified = file.lastModified()
return synchronized(entries) {
synchronized(entries) {
val entry = entries[key]
if (entry != null && entry.length == length && entry.lastModified == lastModified) {
entry.bitmap
} else {
entries.remove(key)
null
return entry.bitmap
}
entries.remove(key)
}
}
fun load(path: String): ImageBitmap? {
peek(path)?.let { return it }
val file = File(path)
if (!file.isFile) return null
val bitmap = decodeCover(file) ?: return null
decodeSlots.acquireUninterruptibly()
val bitmap = try {
decodeCover(file)
} finally {
decodeSlots.release()
} ?: return null
val entry = Entry(
length = file.length(),
lastModified = file.lastModified(),
length = length,
lastModified = lastModified,
bitmap = bitmap
)
synchronized(entries) {
entries[file.absolutePath] = entry
entries[key] = entry
trimToMaxEntries()
}
return bitmap

View file

@ -23,10 +23,11 @@ internal actual fun LocalBookCoverImage(
}
LaunchedEffect(path) {
if (bitmap == null) {
bitmap = withContext(Dispatchers.IO) {
DesktopBookCoverImageCache.load(path)
}
val loaded = withContext(Dispatchers.IO) {
DesktopBookCoverImageCache.load(path)
}
if (loaded != null && loaded != bitmap) {
bitmap = loaded
}
}

View file

@ -11,6 +11,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
@ -24,9 +25,13 @@ import java.awt.Point
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import java.awt.Window as AwtWindow
import java.util.Collections
import java.util.WeakHashMap
import javax.swing.RootPaneContainer
private val LocalSharedReaderModalOwnerWindow = compositionLocalOf<AwtWindow?> { null }
private val SharedReaderModalOwnerByWindow: MutableMap<AwtWindow, AwtWindow> =
Collections.synchronizedMap(WeakHashMap<AwtWindow, AwtWindow>())
@Composable
actual fun SharedReaderModalOwnerWindowProvider(
@ -47,9 +52,14 @@ internal actual fun SharedReaderModalLayer(
) {
val anchor = LocalSharedReaderModalAnchorBounds.current
val density = LocalDensity.current
val focusableOverride = LocalSharedReaderModalFocusableOverride.current
val explicitOwnerWindow = LocalSharedReaderModalOwnerWindow.current
val fallbackOwnerWindow = remember { currentNonModalOwnerWindow() }
val ownerWindow = explicitOwnerWindow ?: fallbackOwnerWindow
val modalWindowFocusable = sharedReaderModalLayerWindowFocusable(
level = level,
focusableOverride = focusableOverride
)
val dialogSize = with(density) {
anchor?.let {
when {
@ -61,7 +71,10 @@ internal actual fun SharedReaderModalLayer(
}
level.isEdgePanelLayer() -> {
DpSize(
width = level.edgePanelLayerWidth(it.widthPx.toDp()),
width = sharedReaderModalEdgePanelLayerWidth(
level = level,
anchorWidth = it.widthPx.toDp()
),
height = it.heightPx.toDp().coerceAtLeast(360.dp)
)
}
@ -91,7 +104,7 @@ internal actual fun SharedReaderModalLayer(
SharedReaderModalLevel.ChromeBottom -> "Reader Chrome Bottom"
}
var modalVisible by remember(ownerWindow, explicitOwnerWindow, level) {
mutableStateOf(sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, level))
mutableStateOf(sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null))
}
LaunchedEffect(dialogPosition, dialogSize) {
@ -110,26 +123,61 @@ internal actual fun SharedReaderModalLayer(
}
}
DisposableEffect(ownerWindow, explicitOwnerWindow, level) {
if (ownerWindow == null || explicitOwnerWindow == null || !level.isChromeLayer()) {
if (ownerWindow == null || explicitOwnerWindow == null) {
modalVisible = true
onDispose {}
} else {
fun syncVisibility() {
modalVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, level)
var disposed = false
fun hideImmediately(ownerClosing: Boolean) {
if (disposed) return
if (
sharedReaderModalLayerShouldHideImmediately(
ownerShowing = ownerWindow.isShowing,
ownerDisplayable = ownerWindow.isDisplayable,
ownerClosing = ownerClosing
)
) {
modalVisible = false
}
}
fun syncVisibility(oppositeWindow: AwtWindow?) {
if (disposed) return
if (
sharedReaderModalLayerShouldHideImmediately(
ownerShowing = ownerWindow.isShowing,
ownerDisplayable = ownerWindow.isDisplayable,
ownerClosing = false
)
) {
hideImmediately(ownerClosing = false)
return
}
val nextVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, oppositeWindow)
if (nextVisible) {
modalVisible = true
} else {
EventQueue.invokeLater {
if (!disposed) {
modalVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null)
}
}
}
}
val listener = object : WindowAdapter() {
override fun windowActivated(e: WindowEvent?) = syncVisibility()
override fun windowDeactivated(e: WindowEvent?) = syncVisibility()
override fun windowGainedFocus(e: WindowEvent?) = syncVisibility()
override fun windowLostFocus(e: WindowEvent?) = syncVisibility()
override fun windowIconified(e: WindowEvent?) = syncVisibility()
override fun windowDeiconified(e: WindowEvent?) = syncVisibility()
override fun windowClosed(e: WindowEvent?) = syncVisibility()
override fun windowClosing(e: WindowEvent?) = hideImmediately(ownerClosing = true)
override fun windowClosed(e: WindowEvent?) = hideImmediately(ownerClosing = true)
override fun windowActivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
override fun windowDeactivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
override fun windowGainedFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
override fun windowLostFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
override fun windowIconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
override fun windowDeiconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
}
ownerWindow.addWindowListener(listener)
ownerWindow.addWindowFocusListener(listener)
syncVisibility()
syncVisibility(null)
onDispose {
disposed = true
ownerWindow.removeWindowListener(listener)
ownerWindow.removeWindowFocusListener(listener)
}
@ -145,12 +193,65 @@ internal actual fun SharedReaderModalLayer(
transparent = true,
resizable = false,
alwaysOnTop = true,
focusable = !level.isChromeLayer()
focusable = modalWindowFocusable
) {
val modalWindow = window
LaunchedEffect(modalWindow, level) {
DisposableEffect(modalWindow, ownerWindow, explicitOwnerWindow, level) {
modalWindow.name = SharedReaderModalWindowNamePrefix + level.name
if (ownerWindow != null && explicitOwnerWindow != null) {
synchronized(SharedReaderModalOwnerByWindow) {
SharedReaderModalOwnerByWindow[modalWindow] = ownerWindow
}
}
var disposed = false
fun syncVisibility(oppositeWindow: AwtWindow?) {
if (disposed) return
if (ownerWindow != null && explicitOwnerWindow != null) {
if (
sharedReaderModalLayerShouldHideImmediately(
ownerShowing = ownerWindow.isShowing,
ownerDisplayable = ownerWindow.isDisplayable,
ownerClosing = false
)
) {
modalVisible = false
return
}
val nextVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, oppositeWindow)
if (nextVisible) {
modalVisible = true
} else {
EventQueue.invokeLater {
if (!disposed) {
modalVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null)
}
}
}
}
}
val listener = object : WindowAdapter() {
override fun windowActivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
override fun windowDeactivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
override fun windowGainedFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
override fun windowLostFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
override fun windowIconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
override fun windowClosed(e: WindowEvent?) = syncVisibility(e?.oppositeWindow)
}
modalWindow.addWindowListener(listener)
modalWindow.addWindowFocusListener(listener)
onDispose {
disposed = true
modalWindow.removeWindowListener(listener)
modalWindow.removeWindowFocusListener(listener)
synchronized(SharedReaderModalOwnerByWindow) {
SharedReaderModalOwnerByWindow.remove(modalWindow)
}
}
}
LaunchedEffect(modalWindow, level, modalWindowFocusable) {
modalWindow.name = SharedReaderModalWindowNamePrefix + level.name
modalWindow.isAlwaysOnTop = true
modalWindow.setFocusableWindowState(modalWindowFocusable)
val frontAttempts = when (level) {
SharedReaderModalLevel.Popup -> 4
SharedReaderModalLevel.Panel,
@ -161,11 +262,14 @@ internal actual fun SharedReaderModalLayer(
}
repeat(frontAttempts) { attempt ->
delay(if (attempt == 0) 30L else 80L)
modalWindow.isAlwaysOnTop = true
modalWindow.toFront()
if (!level.isChromeLayer()) {
modalWindow.requestFocus()
modalWindow.requestFocusInWindow()
if (!modalWindow.isDisplayable) return@repeat
runCatching {
modalWindow.isAlwaysOnTop = true
modalWindow.toFront()
if (modalWindowFocusable && !level.isEdgePanelLayer()) {
modalWindow.requestFocus()
modalWindow.requestFocusInWindow()
}
}
}
}
@ -174,6 +278,13 @@ internal actual fun SharedReaderModalLayer(
}
}
internal fun sharedReaderModalLayerWindowFocusable(
level: SharedReaderModalLevel,
focusableOverride: Boolean?
): Boolean {
return focusableOverride ?: !level.isChromeLayer()
}
internal actual fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean {
return level.isEdgePanelLayer()
}
@ -181,6 +292,7 @@ internal actual fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReade
private const val SharedReaderModalWindowNamePrefix = "shared-reader-modal:"
private val SharedReaderChromeTopLayerHeight = 104.dp
private val SharedReaderChromeBottomLayerHeight = 164.dp
private val SharedReaderChromeBottomLayerOverlap = 8.dp
private val SharedReaderLeftPanelWidth = 340.dp
private val SharedReaderRightPanelWidth = 380.dp
private val SharedReaderLeftNarrowPanelMaxWidth = 320.dp
@ -191,12 +303,46 @@ private val SharedReaderWidePanelBreakpoint = 1120.dp
private fun sharedReaderModalLayerVisible(
ownerWindow: AwtWindow?,
explicitOwnerWindow: AwtWindow?,
level: SharedReaderModalLevel
oppositeWindow: AwtWindow?
): Boolean {
if (explicitOwnerWindow == null || !level.isChromeLayer()) return true
return ownerWindow?.let { window ->
window.isShowing && window.isDisplayable && (window.isActive || window.isFocused)
} == true
if (explicitOwnerWindow == null) return true
return ownerWindow?.sharedReaderChromeLayerVisible(oppositeWindow) == true
}
internal fun sharedReaderModalChromeLayerVisible(
ownerShowing: Boolean,
ownerDisplayable: Boolean,
ownerMinimized: Boolean,
ownerActive: Boolean,
ownerFocused: Boolean,
ownerModalActive: Boolean
): Boolean {
return ownerShowing &&
ownerDisplayable &&
!ownerMinimized &&
(ownerActive || ownerFocused || ownerModalActive)
}
internal fun sharedReaderModalLayerShouldHideImmediately(
ownerShowing: Boolean,
ownerDisplayable: Boolean,
ownerClosing: Boolean
): Boolean {
return ownerClosing || !ownerShowing || !ownerDisplayable
}
private fun AwtWindow.sharedReaderChromeLayerVisible(oppositeWindow: AwtWindow?): Boolean {
return sharedReaderModalChromeLayerVisible(
ownerShowing = isShowing,
ownerDisplayable = isDisplayable,
ownerMinimized = (this as? java.awt.Frame)?.let { frame ->
frame.extendedState and java.awt.Frame.ICONIFIED != 0
} == true,
ownerActive = isActive,
ownerFocused = isFocused,
ownerModalActive = oppositeWindow.isSharedReaderModalWindowForOwner(this) ||
sharedReaderModalWindowActiveForOwner(this)
)
}
private fun sharedReaderModalLayerPosition(
@ -212,7 +358,12 @@ private fun sharedReaderModalLayerPosition(
}
if (anchor != null && ownerLocation != null) {
val topPx = when (level) {
SharedReaderModalLevel.ChromeBottom -> anchor.topPx + anchor.heightPx - dialogSize.height.toPx()
SharedReaderModalLevel.ChromeBottom -> sharedReaderModalChromeBottomLayerTopPx(
anchorTopPx = anchor.topPx,
anchorHeightPx = anchor.heightPx,
dialogHeightPx = dialogSize.height.toPx(),
overlapPx = SharedReaderChromeBottomLayerOverlap.toPx()
)
else -> anchor.topPx
}
val leftPx = when (level) {
@ -229,6 +380,15 @@ private fun sharedReaderModalLayerPosition(
}
}
internal fun sharedReaderModalChromeBottomLayerTopPx(
anchorTopPx: Float,
anchorHeightPx: Float,
dialogHeightPx: Float,
overlapPx: Float
): Float {
return anchorTopPx + anchorHeightPx - dialogHeightPx + overlapPx
}
private fun AwtWindow.sharedReaderModalContentLocationOnScreen(): Point {
val contentPane = (this as? RootPaneContainer)?.contentPane
if (contentPane != null && contentPane.isShowing) {
@ -251,14 +411,17 @@ private fun SharedReaderModalLevel.chromeLayerHeight() = when (this) {
else -> 0.dp
}
private fun SharedReaderModalLevel.edgePanelLayerWidth(anchorWidth: androidx.compose.ui.unit.Dp): androidx.compose.ui.unit.Dp {
val preferredWideWidth = when (this) {
SharedReaderModalLevel.PanelRight -> SharedReaderRightPanelWidth
else -> SharedReaderLeftPanelWidth
internal fun sharedReaderModalEdgePanelLayerWidth(
level: SharedReaderModalLevel,
anchorWidth: Dp
): Dp {
val preferredWideWidth = when (level) {
SharedReaderModalLevel.PanelLeft -> SharedReaderLeftPanelWidth
else -> SharedReaderRightPanelWidth
}
val preferredNarrowWidth = when (this) {
SharedReaderModalLevel.PanelRight -> minOf(SharedReaderRightNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction)
else -> minOf(SharedReaderLeftNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction)
val preferredNarrowWidth = when (level) {
SharedReaderModalLevel.PanelLeft -> minOf(SharedReaderLeftNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction)
else -> minOf(SharedReaderRightNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction)
}
return if (anchorWidth >= SharedReaderWidePanelBreakpoint) {
preferredWideWidth.coerceAtMost(anchorWidth)
@ -308,3 +471,29 @@ private fun AwtWindow.isSharedReaderModalWindow(): Boolean {
windowTitle.startsWith("Reader Popup") ||
windowTitle.startsWith("Reader Chrome")
}
private fun AwtWindow?.isSharedReaderModalWindowForOwner(ownerWindow: AwtWindow): Boolean {
val window = this ?: return false
return window.sharedReaderModalOwnerInWindowChain() == ownerWindow
}
private fun sharedReaderModalWindowActiveForOwner(ownerWindow: AwtWindow): Boolean {
return AwtWindow.getWindows().any { window ->
window.isShowing &&
window.isDisplayable &&
window.isSharedReaderModalWindowForOwner(ownerWindow) &&
(window.isActive || window.isFocused)
}
}
private fun AwtWindow.sharedReaderModalOwnerInWindowChain(): AwtWindow? {
var current: AwtWindow? = this
while (current != null) {
val window = current
synchronized(SharedReaderModalOwnerByWindow) {
SharedReaderModalOwnerByWindow[window]
}?.let { owner -> return owner }
current = window.owner
}
return null
}

View file

@ -0,0 +1,111 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class HtmlParserLinkTest {
@Test
fun `block anchor propagates href to paragraph text`() {
val blocks = parse(
"""
<html>
<body>
<a href="chapter2.xhtml#start"><p>Continue reading</p></a>
</body>
</html>
""".trimIndent()
)
val paragraph = blocks.single() as SemanticParagraph
val linkSpan = paragraph.spans.single { it.linkHref == "chapter2.xhtml#start" }
assertEquals("Continue reading", paragraph.text)
assertEquals(0, linkSpan.start)
assertEquals(paragraph.text.length, linkSpan.end)
}
@Test
fun `block anchor propagates href to heading text`() {
val blocks = parse(
"""
<html>
<body>
<a href="#details"><h2>Details</h2></a>
</body>
</html>
""".trimIndent()
)
val heading = blocks.single() as SemanticHeader
assertEquals("Details", heading.text)
assertTrue(heading.spans.any { span ->
span.linkHref == "#details" &&
span.start == 0 &&
span.end == heading.text.length
})
}
@Test
fun `nested inline spans inherit anchor href`() {
val blocks = parse(
"""
<html>
<body>
<p><a href="notes.xhtml#n1"><span>note</span></a></p>
</body>
</html>
""".trimIndent()
)
val paragraph = blocks.single() as SemanticParagraph
assertEquals("note", paragraph.text)
assertTrue(paragraph.spans.any { span ->
span.tag == "span" &&
span.linkHref == "notes.xhtml#n1" &&
span.start == 0 &&
span.end == paragraph.text.length
})
}
@Test
fun `namespaced anchor href is treated as link`() {
val blocks = parse(
"""
<html>
<body>
<p><a xlink:href="appendix.xhtml#more">Appendix</a></p>
</body>
</html>
""".trimIndent()
)
val paragraph = blocks.single() as SemanticParagraph
assertEquals("Appendix", paragraph.text)
assertTrue(paragraph.spans.any { span ->
span.linkHref == "appendix.xhtml#more" &&
span.start == 0 &&
span.end == paragraph.text.length
})
}
private fun parse(html: String): List<SemanticBlock> {
return htmlToSemanticBlocks(
html = html,
cssRules = OptimizedCssRules(),
textStyle = TextStyle(fontSize = 16.sp),
chapterAbsPath = "OEBPS/chapter1.xhtml",
extractionBasePath = "",
density = Density(1f),
fontFamilyMap = emptyMap(),
constraints = Constraints(maxWidth = 400, maxHeight = 800)
)
}
}

View file

@ -151,6 +151,89 @@ class SharedOpdsParserTest {
assertEquals("https://example.org/root/stream/{pageNumber}", entry.pseUrlTemplate)
}
@Test
fun `parse cover links from opds cover relations and image typed links`() {
val xmlFeed = SharedOpdsParser().parse(
bodyString = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>XML Catalog</title>
<entry>
<id>xml-cover</id>
<title>XML Cover Book</title>
<link rel="http://opds-spec.org/cover" href="/api/v1/opds/42/cover" type="image/jpeg" />
<link rel="http://opds-spec.org/acquisition/open-access" type="application/epub+zip" href="/api/v1/opds/42/download" />
</entry>
</feed>
""".trimIndent(),
baseUrl = "https://grimmory.example/api/v1/opds/catalog"
)
assertEquals("https://grimmory.example/api/v1/opds/42/cover", xmlFeed.entries.single().coverUrl)
val jsonFeed = SharedOpdsParser().parse(
bodyString = """
{
"publications": [
{
"metadata": {"identifier": "json-cover", "title": "JSON Cover Book"},
"links": [
{"rel": "cover", "href": "/api/v1/opds/77/cover", "type": "image/jpeg"},
{"rel": "http://opds-spec.org/acquisition", "href": "/api/v1/opds/77/download", "type": "application/pdf"}
]
}
]
}
""".trimIndent(),
baseUrl = "https://grimmory.example/api/v1/opds/catalog"
)
assertEquals("https://grimmory.example/api/v1/opds/77/cover", jsonFeed.entries.single().coverUrl)
}
@Test
fun `extract OpenSearch template prefers OPDS acquisition feeds over generic Atom feeds`() {
val template = SharedOpdsParser().extractOpenSearchTemplate(
bodyString = """
<?xml version="1.0" encoding="utf-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<Url type="application/atom+xml" template="https://example.org/feeds/atom/all?query={searchTerms}&amp;per-page={count}&amp;page={startPage}"/>
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="https://example.org/feeds/opds/all?query={searchTerms}&amp;per-page={count}&amp;page={startPage}"/>
</OpenSearchDescription>
""".trimIndent(),
openSearchUrl = "https://example.org/opensearch"
)
assertEquals(
"https://example.org/feeds/opds/all?query={searchTerms}&per-page={count}&page={startPage}",
template
)
}
@Test
fun `parse ebook enclosure links as acquisitions`() {
val feed = SharedOpdsParser().parse(
bodyString = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom Search</title>
<entry>
<id>atom-book</id>
<title>Atom Book</title>
<link rel="enclosure" title="EPUB" type="application/epub+zip" href="downloads/book.epub" />
<link rel="enclosure" title="MP3" type="audio/mpeg" href="downloads/audio.mp3" />
</entry>
</feed>
""".trimIndent(),
baseUrl = "https://example.org/feeds/atom/all"
)
assertEquals(
OpdsAcquisition("https://example.org/feeds/atom/downloads/book.epub", "application/epub+zip"),
feed.entries.single().acquisitions.single()
)
}
@Test
fun `parse OPDS 2 groups and fallback metadata produce navigation entries`() {
val feed = SharedOpdsParser().parse(

View file

@ -1,5 +1,8 @@
package com.aryan.reader.shared.reader
import androidx.compose.ui.unit.sp
import com.aryan.reader.paginatedreader.CssStyle
import com.aryan.reader.paginatedreader.SemanticParagraph
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import kotlin.test.Test
@ -81,6 +84,51 @@ class SharedEpubPaginationCacheTest {
}
}
@Test
fun `chapter page cache round trips one measured chapter`() = runBlocking {
val root = Files.createTempDirectory("reader-page-cache").toFile()
try {
val cache = SharedEpubPaginationCache(root)
val book = cacheBook().copy(
chapters = listOf(
cacheBook().chapters.first(),
cacheBook().chapters.first().copy(id = "chapter-2", title = "Two", plainText = "Second chapter.")
)
)
val settings = ReaderSettings()
val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720)
val pages = listOf(
ReaderPage(
pageIndex = 0,
chapterIndex = 0,
chapterTitle = "One",
text = "First cached page",
startOffset = 0,
endOffset = 17
),
ReaderPage(
pageIndex = 1,
chapterIndex = 1,
chapterTitle = "Two",
text = "Second cached page",
startOffset = 0,
endOffset = 18
)
)
cache.save(book, settings, viewport, pages)
val loadedChapter = cache.loadChapter(book, settings, viewport, chapterIndex = 1)
assertNotNull(loadedChapter)
assertEquals(1, loadedChapter.size)
assertEquals(1, loadedChapter.first().pageIndex)
assertEquals(1, loadedChapter.first().chapterIndex)
assertEquals("Second cached page", loadedChapter.first().text)
} finally {
root.deleteRecursively()
}
}
@Test
fun `pagination cache key changes for spread mode`() {
val root = Files.createTempDirectory("reader-page-cache").toFile()
@ -97,6 +145,49 @@ class SharedEpubPaginationCacheTest {
}
}
@Test
fun `page cache ignores semanticless pages for semantic books`() = runBlocking {
val root = Files.createTempDirectory("reader-page-cache").toFile()
try {
val cache = SharedEpubPaginationCache(root)
val book = cacheBook().copy(
chapters = listOf(
cacheBook().chapters.first().copy(
semanticBlocks = listOf(
SemanticParagraph(
text = "Cached page content.",
spans = emptyList(),
style = CssStyle(fontSize = 22.sp),
elementId = null,
cfi = null,
startCharOffsetInSource = 0,
blockIndex = 0
)
)
)
)
)
val settings = ReaderSettings()
val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720)
val pages = listOf(
ReaderPage(
pageIndex = 0,
chapterIndex = 0,
chapterTitle = "One",
text = "Cached page",
startOffset = 0,
endOffset = 11
)
)
cache.save(book, settings, viewport, pages)
assertNull(cache.load(book, settings, viewport))
} finally {
root.deleteRecursively()
}
}
@Test
fun `clear all removes persisted and memory pagination pages`() = runBlocking {
val root = Files.createTempDirectory("reader-page-cache").toFile()

View file

@ -1,5 +1,8 @@
package com.aryan.reader.shared.reader
import androidx.compose.ui.unit.sp
import com.aryan.reader.paginatedreader.CssStyle
import com.aryan.reader.paginatedreader.SemanticParagraph
import com.aryan.reader.shared.FileType
import java.nio.file.Files
import kotlin.test.Test
@ -32,6 +35,17 @@ class SharedJvmBookLoadCacheTest {
title = "One",
plainText = "Hello cache.",
htmlContent = "<p>Hello cache.</p>",
semanticBlocks = listOf(
SemanticParagraph(
text = "Hello cache.",
spans = emptyList(),
style = CssStyle(fontSize = 18.sp),
elementId = null,
cfi = null,
startCharOffsetInSource = 0,
blockIndex = 0
)
),
baseHref = "one.xhtml"
)
)
@ -49,6 +63,40 @@ class SharedJvmBookLoadCacheTest {
}
}
@Test
fun `book load cache rejects styled reader books without semantic blocks`() {
val root = Files.createTempDirectory("reader-book-load-cache").toFile()
try {
val cache = SharedJvmBookLoadCache(root)
val key = SharedJvmBookLoadCacheKey(
canonicalPath = "C:/Books/book.epub",
type = FileType.EPUB,
length = 1234L,
lastModified = 5678L
)
val book = SharedEpubBook(
id = "C:/Books/book.epub",
fileName = "book.epub",
title = "Cached Book",
chapters = listOf(
SharedEpubChapter(
id = "one",
title = "One",
plainText = "Hello cache.",
htmlContent = "<p>Hello cache.</p>",
baseHref = "one.xhtml"
)
)
)
cache.save(key, book)
assertNull(cache.load(key))
} finally {
root.deleteRecursively()
}
}
@Test
fun `book load cache misses when source fingerprint changes`() {
val root = Files.createTempDirectory("reader-book-load-cache").toFile()

View file

@ -171,49 +171,7 @@ class SharedJvmBookLoaderTest {
@Test
fun `epub loader keeps embedded images in semantic pagination blocks`() = withTempDir { dir ->
val file = File(dir, "image-book.epub")
writeZip(file) {
text(
"META-INF/container.xml",
"""
<container>
<rootfiles>
<rootfile full-path="OPS/content.opf"/>
</rootfiles>
</container>
""".trimIndent()
)
text(
"OPS/content.opf",
"""
<package>
<metadata>
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Image Book</dc:title>
</metadata>
<manifest>
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
<item id="pixel" href="images/pixel.png" media-type="image/png"/>
</manifest>
<spine>
<itemref idref="chapter"/>
</spine>
</package>
""".trimIndent()
)
text(
"OPS/chapter.xhtml",
"""
<html>
<body>
<h1>One</h1>
<p>Before</p>
<img src="images/pixel.png" alt="Pixel"/>
<p>After</p>
</body>
</html>
""".trimIndent()
)
bytes("OPS/images/pixel.png", onePixelPng)
}
writeImageEpub(file)
val book = SharedJvmBookLoader.loadEpub(file)
val image = book.chapters.single().semanticBlocks.filterIsInstance<SemanticImage>().single()
@ -222,6 +180,49 @@ class SharedJvmBookLoaderTest {
assertEquals("Pixel", image.altText)
}
@Test
fun `epub loader can skip semantic blocks for vertical fast path`() = withTempDir { dir ->
val file = File(dir, "image-book.epub")
writeImageEpub(file)
val book = SharedJvmBookLoader.loadEpub(file, parseSemanticBlocks = false)
val chapter = book.chapters.single()
assertTrue(chapter.semanticBlocks.isEmpty())
assertTrue(chapter.htmlContent.contains("data:image/png;base64,"))
assertTrue(chapter.plainText.contains("Before"))
assertTrue(chapter.plainText.contains("After"))
}
@Test
fun `epub loader can prepare html for selected vertical chapters only`() = withTempDir { dir ->
val file = File(dir, "two-chapters.epub")
writeTwoChapterEpub(file)
val book = SharedJvmBookLoader.loadEpub(
file = file,
parseSemanticBlocks = false,
preparedHtmlChapterRange = 1..1
)
assertEquals(2, book.chapters.size)
assertTrue(book.chapters[0].plainText.contains("First chapter text"))
assertTrue(book.chapters[0].htmlContent.isBlank())
assertTrue(book.chapters[1].plainText.contains("Second chapter text"))
assertTrue(book.chapters[1].htmlContent.contains("Second chapter text"))
}
@Test
fun `epub loader does not inline stylesheet font resources`() = withTempDir { dir ->
val file = File(dir, "font-book.epub")
writeImageEpub(file)
val css = SharedJvmBookLoader.loadEpub(file).css.values.single()
assertTrue(css.contains("fonts/reader.woff2"))
assertTrue(!css.contains("data:font/woff2"))
}
private fun withTempDir(block: (File) -> Unit) {
val dir = Files.createTempDirectory("reader-shared-loader").toFile()
try {
@ -245,6 +246,110 @@ class SharedJvmBookLoaderTest {
}
}
private fun writeImageEpub(file: File) {
writeZip(file) {
text(
"META-INF/container.xml",
"""
<container>
<rootfiles>
<rootfile full-path="OPS/content.opf"/>
</rootfiles>
</container>
""".trimIndent()
)
text(
"OPS/content.opf",
"""
<package>
<metadata>
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Image Book</dc:title>
</metadata>
<manifest>
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
<item id="style" href="styles/book.css" media-type="text/css"/>
<item id="font" href="styles/fonts/reader.woff2" media-type="font/woff2"/>
<item id="pixel" href="images/pixel.png" media-type="image/png"/>
</manifest>
<spine>
<itemref idref="chapter"/>
</spine>
</package>
""".trimIndent()
)
text(
"OPS/chapter.xhtml",
"""
<html>
<body>
<h1>One</h1>
<p>Before</p>
<img src="images/pixel.png" alt="Pixel"/>
<p>After</p>
</body>
</html>
""".trimIndent()
)
text(
"OPS/styles/book.css",
"""
@font-face {
font-family: "Fixture Serif";
src: url("fonts/reader.woff2") format("woff2");
}
body { font-family: "Fixture Serif"; }
""".trimIndent()
)
bytes("OPS/images/pixel.png", onePixelPng)
bytes("OPS/styles/fonts/reader.woff2", byteArrayOf(0, 1, 2, 3))
}
}
private fun writeTwoChapterEpub(file: File) {
writeZip(file) {
text(
"META-INF/container.xml",
"""
<container>
<rootfiles>
<rootfile full-path="OPS/content.opf"/>
</rootfiles>
</container>
""".trimIndent()
)
text(
"OPS/content.opf",
"""
<package>
<metadata>
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Two Chapters</dc:title>
</metadata>
<manifest>
<item id="first" href="first.xhtml" media-type="application/xhtml+xml"/>
<item id="second" href="second.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine>
<itemref idref="first"/>
<itemref idref="second"/>
</spine>
</package>
""".trimIndent()
)
text(
"OPS/first.xhtml",
"""
<html><body><h1>First</h1><p>First chapter text.</p></body></html>
""".trimIndent()
)
text(
"OPS/second.xhtml",
"""
<html><body><h1>Second</h1><p>Second chapter text.</p></body></html>
""".trimIndent()
)
}
}
private fun minimalMobi(textRecord: ByteArray): ByteArray {
val record0 = ByteArray(16)
record0.writeU16(0, 1)

View file

@ -20,6 +20,7 @@ class SharedMeasuredEpubPaginatorTest {
settings = ReaderSettings(
pageWidth = 760,
margin = 48,
readingMode = ReaderReadingMode.PAGINATED,
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
),
viewport = ReaderViewportSpec(widthPx = 2_400, heightPx = 1_200)
@ -29,6 +30,51 @@ class SharedMeasuredEpubPaginatorTest {
assertEquals(1_104, geometry.pageHeightPx)
}
@Test
fun `two page geometry subtracts margins inside each rendered page on constrained viewports`() {
val geometry = measuredPageGeometryFor(
settings = ReaderSettings(
pageWidth = 760,
horizontalMargin = 80,
verticalMargin = 40,
readingMode = ReaderReadingMode.PAGINATED,
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
),
viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900)
)
assertEquals(476, geometry.pageWidthPx)
assertEquals(820, geometry.pageHeightPx)
}
@Test
fun `paginated single page geometry matches one rendered page in a spread`() {
val singlePageGeometry = measuredPageGeometryFor(
settings = ReaderSettings(
pageWidth = 760,
horizontalMargin = 80,
verticalMargin = 40,
readingMode = ReaderReadingMode.PAGINATED,
pageSpreadMode = ReaderPageSpreadMode.SINGLE
),
viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900)
)
val twoPageGeometry = measuredPageGeometryFor(
settings = ReaderSettings(
pageWidth = 760,
horizontalMargin = 80,
verticalMargin = 40,
readingMode = ReaderReadingMode.PAGINATED,
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
),
viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900)
)
assertEquals(twoPageGeometry, singlePageGeometry)
assertEquals(476, singlePageGeometry.pageWidthPx)
assertEquals(820, singlePageGeometry.pageHeightPx)
}
@Test
fun `geometry does not invent minimum page space beyond the rendered viewport`() {
val geometry = measuredPageGeometryFor(

Some files were not shown because too many files have changed in this diff Show more