added visual settings (font/spacing/weight), imprpved linux "show file", added "share/show" to a library card

This commit is contained in:
Sergey Chernov 2026-05-25 10:10:01 +03:00
parent 3e83185e4f
commit 2fddacb7e7
9 changed files with 520 additions and 30 deletions

View File

@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val appVersionName = "1.0"
val appVersionCode = 4
val appVersionCode = 5
val appVersionDisplay = "$appVersionName.$appVersionCode"
plugins {

View File

@ -372,7 +372,7 @@ actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(D
}.getOrDefault(false)
}
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false
actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean = false
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
@ -432,6 +432,18 @@ actual suspend fun saveThemeMode(mode: ThemeMode) = withContext(Dispatchers.IO)
}
}
actual suspend fun loadReaderFontSettings(): ReaderFontSettings = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
readerFontSettingsFromStorageValue(db.getAppFlag(ReaderFontSettingsFlag))
}
}
actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ReaderFontSettingsFlag, settings.toStorageValue())
}
}
actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true
@ -803,6 +815,7 @@ private val SearchPrefixRegex = Regex("""[\p{L}\p{N}]+""")
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
private const val ReaderFontSettingsFlag = "reader_font_settings"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned"

View File

@ -3,6 +3,7 @@ package net.sergeych.toread
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.storage.BookReadingStatus
import kotlin.math.abs
data class LibraryItem(
val fileId: String,
@ -63,6 +64,13 @@ data class ReadingPosition(
val readAloudSentenceIndex: Int? = null,
)
data class ReaderFontSettings(
val fontSizeSp: Float = defaultReaderFontSizeSp(),
val lineHeightSp: Float = DefaultReaderLineHeightSp,
val fontWeight: Int = defaultReaderFontWeight(),
val fontName: String? = null,
)
data class BookInfoExtras(
val sourceFileName: String? = null,
val sourceFilePath: String? = null,
@ -132,7 +140,7 @@ expect suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boole
expect suspend fun shareLibraryBookFile(fileId: String): Boolean
expect suspend fun viewLibraryBookFile(fileId: String): Boolean
expect suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
@ -144,6 +152,10 @@ expect suspend fun loadThemeMode(): ThemeMode
expect suspend fun saveThemeMode(mode: ThemeMode)
expect suspend fun loadReaderFontSettings(): ReaderFontSettings
expect suspend fun saveReaderFontSettings(settings: ReaderFontSettings)
expect suspend fun loadScanDownloadsAutomatically(): Boolean
expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean)
@ -172,3 +184,116 @@ internal fun Fb2Book.libraryCoverBinary(): Fb2Binary? {
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
return image?.let(::binaryFor)
}
internal fun canShareLibraryBookFile(): Boolean =
getPlatform().name.startsWith("Android")
internal suspend fun shareLibraryBook(fileId: String): String =
if (shareLibraryBookFile(fileId)) strings.shareOpened() else strings.couldNotShareBook()
internal fun canViewLibraryBookFile(): Boolean =
getPlatform().name.startsWith("Java")
internal suspend fun viewLibraryBook(fileId: String): String {
val location = loadLibraryItem(fileId)?.storageUri?.toLibraryBookFileLocation()
?: return strings.couldNotOpenFileLocation()
return if (viewLibraryBookFile(location.folder, location.fileName)) {
strings.openedFileLocation()
} else {
strings.couldNotOpenFileLocation()
}
}
private data class LibraryBookFileLocation(
val folder: String,
val fileName: String,
)
private fun String.toLibraryBookFileLocation(): LibraryBookFileLocation? {
val separatorIndex = maxOf(lastIndexOf('/'), lastIndexOf('\\'))
if (separatorIndex < 0 || separatorIndex >= lastIndex) return null
return LibraryBookFileLocation(
folder = if (separatorIndex == 0) substring(0, 1) else substring(0, separatorIndex),
fileName = substring(separatorIndex + 1),
)
}
internal const val DefaultReaderLineHeightSp = 26f
internal const val ReaderFontSizeStepSp = 1f
internal const val ReaderLineHeightStepSp = 1f
private const val MinReaderFontSizeSp = 14f
private const val MaxReaderFontSizeSp = 34f
private const val MinReaderLineHeightSp = 18f
private const val MaxReaderLineHeightSp = 48f
private const val MinReaderFontWeight = 100
private const val MaxReaderFontWeight = 900
private val ReaderFontWeightSteps = listOf(100, 200, 300, 400, 500, 600, 700, 800, 900)
internal fun defaultReaderFontSettings(): ReaderFontSettings = ReaderFontSettings()
internal fun defaultReaderFontSizeSp(): Float =
if (getPlatform().name.startsWith("Android")) 20f else 18f
internal fun defaultReaderFontWeight(): Int =
if (getPlatform().name.startsWith("Android")) 350 else 400
internal fun ReaderFontSettings.coerced(): ReaderFontSettings =
copy(
fontSizeSp = fontSizeSp.coerceIn(MinReaderFontSizeSp, MaxReaderFontSizeSp),
lineHeightSp = lineHeightSp.coerceIn(MinReaderLineHeightSp, MaxReaderLineHeightSp),
fontWeight = fontWeight.coerceIn(MinReaderFontWeight, MaxReaderFontWeight),
fontName = fontName?.takeIf { it.isNotBlank() },
)
internal fun ReaderFontSettings.adjustFontSize(steps: Int): ReaderFontSettings {
val current = coerced()
val nextFontSize = (current.fontSizeSp + steps * ReaderFontSizeStepSp)
.coerceIn(MinReaderFontSizeSp, MaxReaderFontSizeSp)
val scale = if (current.fontSizeSp > 0f) nextFontSize / current.fontSizeSp else 1f
return current.copy(
fontSizeSp = nextFontSize,
lineHeightSp = (current.lineHeightSp * scale).coerceIn(MinReaderLineHeightSp, MaxReaderLineHeightSp),
)
}
internal fun ReaderFontSettings.adjustLineHeight(steps: Int): ReaderFontSettings =
coerced().let { current ->
current.copy(
lineHeightSp = (current.lineHeightSp + steps * ReaderLineHeightStepSp)
.coerceIn(MinReaderLineHeightSp, MaxReaderLineHeightSp),
)
}
internal fun ReaderFontSettings.adjustFontWeight(steps: Int): ReaderFontSettings {
val current = coerced()
if (steps == 0) return current
var nextWeight = current.fontWeight
repeat(abs(steps)) {
nextWeight = current.fontWeight + if (steps > 0) 50 else -50
if( nextWeight == 100 || nextWeight == 900 ) return@repeat
}
return current.copy(fontWeight = nextWeight)
}
internal fun ReaderFontSettings.toStorageValue(): String =
coerced().let { current ->
listOf(
current.fontSizeSp.toString(),
current.lineHeightSp.toString(),
current.fontWeight.toString(),
current.fontName.orEmpty(),
).joinToString("|")
}
internal fun readerFontSettingsFromStorageValue(value: String?): ReaderFontSettings {
val defaults = defaultReaderFontSettings()
val parts = value?.split("|") ?: return defaults
return ReaderFontSettings(
fontSizeSp = parts.getOrNull(0)?.toFloatOrNull() ?: defaults.fontSizeSp,
lineHeightSp = parts.getOrNull(1)?.toFloatOrNull() ?: defaults.lineHeightSp,
fontWeight = parts.getOrNull(2)?.toIntOrNull() ?: defaults.fontWeight,
fontName = parts.getOrNull(3)?.takeIf { it.isNotBlank() } ?: defaults.fontName,
).coerced()
}

View File

@ -120,6 +120,8 @@ internal fun LibraryScreen(
val recentlyAdded = recentlyAddedItems.filterNot { it.fileId in hiddenFileIds }
val visibleItems = selectedFilter.apply(sourceItems, recentlyAdded, searchActive)
.withoutDuplicateFileIds()
val showShareAction = canShareLibraryBookFile()
val showViewFileAction = canViewLibraryBookFile()
suspend fun loadLibrary() {
if (loadingLibrary) return
@ -605,6 +607,26 @@ internal fun LibraryScreen(
}
}
},
onShare = {
scope.launch {
busy = true
try {
message = shareLibraryBook(item.fileId)
} finally {
busy = false
}
}
},
onViewFile = {
scope.launch {
busy = true
try {
message = viewLibraryBook(item.fileId)
} finally {
busy = false
}
}
},
onDelete = {
val previousItems = items
val previousSearchResults = searchResults
@ -638,6 +660,8 @@ internal fun LibraryScreen(
item = item,
coverCache = coverCache,
enabled = !busy,
showShareAction = showShareAction,
showViewFileAction = showViewFileAction,
actions = itemActions(item),
)
}
@ -843,6 +867,8 @@ private fun LibraryRow(
item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>,
enabled: Boolean,
showShareAction: Boolean,
showViewFileAction: Boolean,
actions: LibraryItemActions,
) {
var menuOpen by remember { mutableStateOf(false) }
@ -958,6 +984,27 @@ private fun LibraryRow(
actions.onNotInterested()
},
)
if (showShareAction || showViewFileAction) {
HorizontalDivider()
}
if (showShareAction) {
DropdownMenuItem(
text = { Text(strings.share) },
onClick = {
menuOpen = false
actions.onShare()
},
)
}
if (showViewFileAction) {
DropdownMenuItem(
text = { Text(strings.viewFile) },
onClick = {
menuOpen = false
actions.onViewFile()
},
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text(strings.delete) },
@ -980,6 +1027,8 @@ private data class LibraryItemActions(
val onMarkToRead: () -> Unit,
val onNotInterested: () -> Unit,
val onFavoriteChange: (Boolean) -> Unit,
val onShare: () -> Unit,
val onViewFile: () -> Unit,
val onDelete: () -> Unit,
)

View File

@ -136,6 +136,15 @@ internal open class AppStrings {
open val readAloud = "Read aloud"
open val readerMenu = "Book reader menu"
open val info = "Info..."
open val readerSettings = "Settings"
open val readerFontSizeIncrease = "Increase font size"
open val readerFontSizeDecrease = "Decrease font size"
open val readerLineHeightIncrease = "Increase line spacing"
open val readerLineHeightDecrease = "Decrease line spacing"
open val readerFontWeightIncrease = "Increase font weight"
open val readerFontWeightDecrease = "Decrease font weight"
open val resetReaderFontSettings = "Reset font settings"
open val closeReaderSettings = "Close reader settings"
open val share = "Share"
open val viewFile = "View file"
open val previousSentence = "Previous sentence"
@ -331,6 +340,15 @@ internal object RussianStrings : AppStrings() {
override val readAloud = "Читать вслух"
override val readerMenu = "Меню чтения"
override val info = "Информация..."
override val readerSettings = "Настройки"
override val readerFontSizeIncrease = "Увеличить шрифт"
override val readerFontSizeDecrease = "Уменьшить шрифт"
override val readerLineHeightIncrease = "Увеличить межстрочный интервал"
override val readerLineHeightDecrease = "Уменьшить межстрочный интервал"
override val readerFontWeightIncrease = "Увеличить насыщенность шрифта"
override val readerFontWeightDecrease = "Уменьшить насыщенность шрифта"
override val resetReaderFontSettings = "Сбросить настройки шрифта"
override val closeReaderSettings = "Закрыть настройки чтения"
override val share = "Поделиться"
override val viewFile = "Показать файл"
override val previousSentence = "Предыдущее предложение"

View File

@ -102,6 +102,7 @@ import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt
@Composable
internal fun ContinuousBookReader(
@ -111,13 +112,15 @@ internal fun ContinuousBookReader(
modifier: Modifier = Modifier,
contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) },
highlightedSentence: ReadAloudSentence? = null,
readerFontSettings: ReaderFontSettings = defaultReaderFontSettings(),
onReaderFontZoom: (Int) -> Unit = {},
onUserScroll: () -> Unit = {},
onImageOpen: (ViewedBookImage) -> Unit = {},
onNoteOpen: (String) -> Unit = {},
) {
val hyphenation = remember { HyphenationRegistry() }
val scope = rememberCoroutineScope()
val textLineMetricsByItem = remember(contentPlan) { mutableStateMapOf<Int, TextLineMetrics>() }
val textLineMetricsByItem = remember(contentPlan, readerFontSettings) { mutableStateMapOf<Int, TextLineMetrics>() }
val contentPadding = PaddingValues(top=6.dp, bottom = 6.dp, start = 4.dp, end = 6.dp)
val userScrollConnection = remember(onUserScroll) {
object : NestedScrollConnection {
@ -135,6 +138,7 @@ internal fun ContinuousBookReader(
modifier = modifier
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(userScrollConnection)
.readerFontZoomGesture(onReaderFontZoom)
.pageTurnOnTouchTap(
onPageDown = {
onUserScroll()
@ -206,7 +210,7 @@ internal fun ContinuousBookReader(
text = element.text,
language = book.language,
hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language),
style = readerParagraphTextStyle(book.language, readerFontSettings),
highlightedRange = highlightedRange,
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp),
@ -253,6 +257,50 @@ internal fun ContinuousBookReader(
}
}
private fun Modifier.readerFontZoomGesture(
onZoom: (Int) -> Unit,
): Modifier = pointerInput(onZoom) {
awaitEachGesture {
var baselineDistance: Float? = null
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Main)
val touches = event.changes.filter { it.pressed && it.type == PointerType.Touch }
if (touches.isEmpty()) break
if (touches.size < 2) {
baselineDistance = null
continue
}
val distance = touches.firstTwoDistance()
val baseline = baselineDistance
if (baseline == null || baseline <= 0f) {
baselineDistance = distance
} else {
val zoom = distance / baseline
when {
zoom >= ReaderZoomGestureStep -> {
onZoom(1)
baselineDistance = distance
}
zoom <= 1f / ReaderZoomGestureStep -> {
onZoom(-1)
baselineDistance = distance
}
}
}
touches.forEach { it.consume() }
}
}
}
private fun List<androidx.compose.ui.input.pointer.PointerInputChange>.firstTwoDistance(): Float {
val first = this[0].position
val second = this[1].position
val dx = second.x - first.x
val dy = second.y - first.y
return sqrt(dx * dx + dy * dy)
}
private fun Modifier.pageTurnOnTouchTap(
onPageDown: () -> Unit,
onPageUp: () -> Unit,
@ -927,16 +975,31 @@ private fun epigraphAuthorTextStyle(language: String?): TextStyle =
epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold)
@Composable
private fun readerParagraphTextStyle(language: String?): TextStyle =
MaterialTheme.typography.bodyLarge.copy(
fontWeight = if( isAndroidPlatform) FontWeight(350) else FontWeight.Normal,
fontSize = if( isAndroidPlatform) 20.sp else 18.sp,
lineHeight = 26.sp,
private fun readerParagraphTextStyle(
language: String?,
settings: ReaderFontSettings = defaultReaderFontSettings(),
): TextStyle {
val coerced = settings.coerced()
return MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight(coerced.fontWeight),
fontSize = coerced.fontSizeSp.sp,
lineHeight = coerced.lineHeightSp.sp,
fontFamily = coerced.readerFontFamily(),
letterSpacing = if (isAndroidPlatform) 0.sp else MaterialTheme.typography.bodyLarge.letterSpacing,
hyphens = if (isAndroidPlatform) Hyphens.Auto else Hyphens.Unspecified,
lineBreak = if (isAndroidPlatform) LineBreak.Paragraph else LineBreak.Unspecified,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },
)
}
private fun ReaderFontSettings.readerFontFamily(): FontFamily? =
when (fontName?.lowercase()) {
"serif" -> FontFamily.Serif
"sans", "sansserif", "sans-serif" -> FontFamily.SansSerif
"monospace", "mono" -> FontFamily.Monospace
"cursive" -> FontFamily.Cursive
else -> null
}
private val isAndroidPlatform: Boolean by lazy {
getPlatform().name.startsWith("Android")
@ -946,6 +1009,8 @@ private val isDesktopPlatform: Boolean by lazy {
getPlatform().name.startsWith("Java")
}
private const val ReaderZoomGestureStep = 1.12f
private val MinimumBookImageMaxDimension = 800.dp
private fun minimumBookImageMaxDimension(availableWidth: Dp): Dp {

View File

@ -1,6 +1,7 @@
package net.sergeych.toread
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -11,16 +12,22 @@ 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.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.UnfoldLess
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
@ -48,7 +55,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import net.sergeych.toread.fb2.Fb2Book
@ -58,6 +69,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
@ -86,14 +98,15 @@ internal fun BookView(
var libraryItem by remember(fileId) { mutableStateOf<LibraryItem?>(null) }
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
var readerSettingsPanelVisible by remember(fileId) { mutableStateOf(false) }
var readerFontSettings by remember { mutableStateOf(defaultReaderFontSettings()) }
var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) }
var userScrollGeneration by remember(fileId) { mutableStateOf(0) }
var selectedNoteId by remember(fileId) { mutableStateOf<String?>(null) }
val readAloudState by ReadAloudPlatform.state.collectAsState()
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
val platformName = getPlatform().name
val showShareAction = platformName.startsWith("Android")
val showViewFileAction = platformName.startsWith("Java")
val showShareAction = canShareLibraryBookFile()
val showViewFileAction = canViewLibraryBookFile()
val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty()
val activeReadAloudSentence = readAloudState.sentenceIndex
?.let { index -> contentPlan.sentences.getOrNull(index) }
@ -107,6 +120,15 @@ internal fun BookView(
}
}
fun updateReaderFontSettings(transform: (ReaderFontSettings) -> ReaderFontSettings) {
val next = transform(readerFontSettings).coerced()
if (next == readerFontSettings) return
readerFontSettings = next
scope.launch {
saveReaderFontSettings(next)
}
}
fun setReadingStatus(status: BookReadingStatus, successMessage: String) {
scope.launch {
if (markLibraryReadingStatus(fileId, status)) {
@ -140,6 +162,10 @@ internal fun BookView(
}
}
LaunchedEffect(Unit) {
readerFontSettings = loadReaderFontSettings()
}
DisposableEffect(fileId) {
onDispose {
ReadAloudPlatform.stop()
@ -244,17 +270,18 @@ internal fun BookView(
showShareAction = showShareAction,
onShare = {
scope.launch {
val shared = shareLibraryBookFile(fileId)
showMessage(if (shared) strings.shareOpened() else strings.couldNotShareBook())
showMessage(shareLibraryBook(fileId))
}
},
showViewFileAction = showViewFileAction,
onViewFile = {
scope.launch {
val opened = viewLibraryBookFile(fileId)
showMessage(if (opened) strings.openedFileLocation() else strings.couldNotOpenFileLocation())
showMessage(viewLibraryBook(fileId))
}
},
onReaderSettings = {
readerSettingsPanelVisible = true
},
showReadAloudAction = showReadAloudAction,
onReadAloud = {
val position = ReadingPosition(
@ -310,10 +337,41 @@ internal fun BookView(
listState = listState,
contentPlan = contentPlan,
highlightedSentence = highlightedSentence,
readerFontSettings = readerFontSettings,
onReaderFontZoom = { steps ->
updateReaderFontSettings { settings -> settings.adjustFontSize(steps) }
},
onUserScroll = { userScrollGeneration += 1 },
onImageOpen = onImageOpen,
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") },
)
if (readerSettingsPanelVisible) {
ReaderFontSettingsPanel(
settings = readerFontSettings,
onFontSizeDecrease = {
updateReaderFontSettings { settings -> settings.adjustFontSize(-1) }
},
onFontSizeIncrease = {
updateReaderFontSettings { settings -> settings.adjustFontSize(1) }
},
onLineHeightDecrease = {
updateReaderFontSettings { settings -> settings.adjustLineHeight(-1) }
},
onLineHeightIncrease = {
updateReaderFontSettings { settings -> settings.adjustLineHeight(1) }
},
onFontWeightDecrease = {
updateReaderFontSettings { settings -> settings.adjustFontWeight(-1) }
},
onFontWeightIncrease = {
updateReaderFontSettings { settings -> settings.adjustFontWeight(1) }
},
onReset = {
updateReaderFontSettings { defaultReaderFontSettings() }
},
onClose = { readerSettingsPanelVisible = false },
)
}
if (readAloudPanelVisible && readAloudState.active) {
ReadAloudPanel(
playing = readAloudState.playing,
@ -375,6 +433,7 @@ private fun CompactReaderTopBar(
onViewFile: () -> Unit,
showReadAloudAction: Boolean,
onReadAloud: () -> Unit,
onReaderSettings: () -> Unit,
onDelete: () -> Unit,
onBack: () -> Unit,
) {
@ -415,6 +474,13 @@ private fun CompactReaderTopBar(
onBookInfo()
},
)
DropdownMenuItem(
text = { Text(strings.readerSettings) },
onClick = {
menuOpen = false
onReaderSettings()
},
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(strings.markAsRead) },
@ -496,6 +562,106 @@ private fun CompactReaderTopBar(
}
}
@Composable
private fun ReaderFontSettingsPanel(
settings: ReaderFontSettings,
onFontSizeDecrease: () -> Unit,
onFontSizeIncrease: () -> Unit,
onLineHeightDecrease: () -> Unit,
onLineHeightIncrease: () -> Unit,
onFontWeightDecrease: () -> Unit,
onFontWeightIncrease: () -> Unit,
onReset: () -> Unit,
onClose: () -> Unit,
) {
Surface(
tonalElevation = 3.dp,
shadowElevation = 4.dp,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Column {
Row(
modifier = Modifier.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(bottom = 0.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
// Text("A ${settings.fontSizeSp.roundToInt()}", style = MaterialTheme.typography.labelMedium)
IconButton(onClick = onFontSizeDecrease) {
ReaderLetterChangeIcon(
letter = "A",
increase = false,
contentDescription = strings.readerFontSizeDecrease,
)
}
IconButton(onClick = onFontSizeIncrease) {
ReaderLetterChangeIcon(
letter = "A",
increase = true,
contentDescription = strings.readerFontSizeIncrease,
)
}
IconButton(onClick = onLineHeightDecrease) {
Icon(Icons.Filled.UnfoldLess, contentDescription = strings.readerLineHeightDecrease)
}
IconButton(onClick = onLineHeightIncrease) {
Icon(Icons.Filled.UnfoldMore, contentDescription = strings.readerLineHeightIncrease)
}
// Text("W ${settings.fontWeight}", style = MaterialTheme.typography.labelMedium)
IconButton(onClick = onFontWeightDecrease) {
ReaderLetterChangeIcon(
letter = "B",
increase = false,
contentDescription = strings.readerFontWeightDecrease,
fontWeight = FontWeight.Bold,
)
}
IconButton(onClick = onFontWeightIncrease) {
ReaderLetterChangeIcon(
letter = "B",
increase = true,
contentDescription = strings.readerFontWeightIncrease,
fontWeight = FontWeight.Bold,
)
}
IconButton(onClick = onReset) {
Icon(Icons.Filled.RestartAlt, contentDescription = strings.resetReaderFontSettings)
}
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = strings.closeReaderSettings)
}
}
// Row(Modifier.fillMaxWidth().padding(bottom = 2.dp, top = 0.dp), horizontalArrangement = Arrangement.Center) {
// Text(
// "size: ${settings.fontSizeSp.roundToInt()} height: ${settings.lineHeightSp.roundToInt()}, weight: ${settings.fontWeight}",
// style = MaterialTheme.typography.bodySmall,
// maxLines = 1,
// overflow = TextOverflow.Ellipsis,
// )
// }
}
}
}
@Composable
private fun ReaderLetterChangeIcon(
letter: String,
increase: Boolean,
contentDescription: String,
fontWeight: FontWeight = FontWeight.SemiBold,
) {
Text(
text = letter + if (increase) "" else "",
style = MaterialTheme.typography.titleMedium,
fontWeight = fontWeight,
modifier = Modifier.semantics {
this.contentDescription = contentDescription
},
)
}
@Composable
private fun ReadAloudPanel(
playing: Boolean,

View File

@ -293,22 +293,55 @@ actual suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boole
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) {
actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
if (!Desktop.isDesktopSupported()) return@withContext false
val file = openLibraryDatabase().useLibrary { db ->
db.files.get(fileId)?.storageUri?.let(::File)?.takeIf { it.isFile }
} ?: return@withContext false
val desktop = Desktop.getDesktop()
if (desktop.isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
desktop.browseFileDirectory(file)
} else {
desktop.open(file.parentFile ?: file)
}
true
val directory = File(folder).takeIf { it.isDirectory } ?: return@withContext false
val file = File(directory, fileName).takeIf { it.isFile } ?: return@withContext false
selectOrOpenBookFile(directory, file)
}.getOrDefault(false)
}
private fun selectOrOpenBookFile(directory: File, file: File): Boolean {
if (isLinuxDesktop() && trySelectLinuxFile(file)) return true
if (!Desktop.isDesktopSupported()) return false
val desktop = Desktop.getDesktop()
return when {
desktop.isSupported(Desktop.Action.BROWSE_FILE_DIR) -> {
desktop.browseFileDirectory(file)
true
}
desktop.isSupported(Desktop.Action.OPEN) -> {
desktop.open(directory)
true
}
else -> false
}
}
private fun isLinuxDesktop(): Boolean =
System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT).contains("linux")
private fun trySelectLinuxFile(file: File): Boolean =
runCatching {
val process = ProcessBuilder(
"dbus-send",
"--session",
"--dest=org.freedesktop.FileManager1",
"--type=method_call",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems",
"array:string:${file.toURI()}",
"string:",
).start()
if (process.waitFor(2, TimeUnit.SECONDS)) {
process.exitValue() == 0
} else {
process.destroyForcibly()
false
}
}.getOrDefault(false)
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
@ -367,6 +400,18 @@ actual suspend fun saveThemeMode(mode: ThemeMode) = withContext(Dispatchers.IO)
}
}
actual suspend fun loadReaderFontSettings(): ReaderFontSettings = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
readerFontSettingsFromStorageValue(db.getAppFlag(ReaderFontSettingsFlag))
}
}
actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ReaderFontSettingsFlag, settings.toStorageValue())
}
}
actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true
@ -611,6 +656,7 @@ private fun runCommand(vararg command: String): String? =
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
private const val ReaderFontSettingsFlag = "reader_font_settings"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned"

View File

@ -61,7 +61,7 @@ actual suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boole
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false
actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean = false
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
@ -73,6 +73,13 @@ actual suspend fun loadThemeMode(): ThemeMode = ThemeMode.SYSTEM
actual suspend fun saveThemeMode(mode: ThemeMode) = Unit
actual suspend fun loadReaderFontSettings(): ReaderFontSettings =
readerFontSettingsFromStorageValue(window.localStorage.getItem(ReaderFontSettingsStorageKey))
actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) {
window.localStorage.setItem(ReaderFontSettingsStorageKey, settings.toStorageValue())
}
actual suspend fun loadScanDownloadsAutomatically(): Boolean = true
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit
@ -111,6 +118,7 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
actual fun libraryLogPath(): String? = null
private const val AppLocaleStorageKey = "toread.appLocaleTag"
private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings"
actual fun formatLibraryLastReadTime(millis: Long): String {
val totalMinutes = millis / 60_000L