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

@ -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
)
}
}
}