From 2fddacb7e7a6eb26207c527047fd878134bb433d Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 25 May 2026 10:10:01 +0300 Subject: [PATCH] added visual settings (font/spacing/weight), imprpved linux "show file", added "share/show" to a library card --- composeApp/build.gradle.kts | 2 +- .../sergeych/toread/BookPlatform.android.kt | 15 +- .../net/sergeych/toread/LibraryPlatform.kt | 127 +++++++++++- .../net/sergeych/toread/LibraryScreen.kt | 49 +++++ .../net/sergeych/toread/Localization.kt | 18 ++ .../net/sergeych/toread/ReaderContent.kt | 79 +++++++- .../net/sergeych/toread/ReaderScreen.kt | 180 +++++++++++++++++- .../net/sergeych/toread/BookPlatform.jvm.kt | 70 +++++-- .../net/sergeych/toread/BookPlatform.web.kt | 10 +- 9 files changed, 520 insertions(+), 30 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9d88f9c..c668488 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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 { diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt index bcc3abc..7107e02 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -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" diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index dfc74c0..a80875d 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -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() +} diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index cce757f..314e66e 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -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, 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, ) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt index 2321975..a8c7b48 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt @@ -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 = "Предыдущее предложение" diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index 8e586c2..c95e49c 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -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() } + val textLineMetricsByItem = remember(contentPlan, readerFontSettings) { mutableStateMapOf() } 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.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 { diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index 2682a01..6f81649 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -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(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(null) } var userScrollGeneration by remember(fileId) { mutableStateOf(0) } var selectedNoteId by remember(fileId) { mutableStateOf(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, diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt index 01a10c5..01901ec 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -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" diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt index e099e5d..368880d 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -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