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 import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val appVersionName = "1.0" val appVersionName = "1.0"
val appVersionCode = 4 val appVersionCode = 5
val appVersionDisplay = "$appVersionName.$appVersionCode" val appVersionDisplay = "$appVersionName.$appVersionCode"
plugins { plugins {

View File

@ -372,7 +372,7 @@ actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(D
}.getOrDefault(false) }.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) { actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> 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) { actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true 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 ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode" private const val ThemeModeFlag = "theme_mode"
private const val ReaderFontSettingsFlag = "reader_font_settings"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically" private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val AppLocaleTagFlag = "app_locale_tag" private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned" 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.Fb2Binary
import net.sergeych.toread.fb2.Fb2Book import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.storage.BookReadingStatus import net.sergeych.toread.storage.BookReadingStatus
import kotlin.math.abs
data class LibraryItem( data class LibraryItem(
val fileId: String, val fileId: String,
@ -63,6 +64,13 @@ data class ReadingPosition(
val readAloudSentenceIndex: Int? = null, 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( data class BookInfoExtras(
val sourceFileName: String? = null, val sourceFileName: String? = null,
val sourceFilePath: 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 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 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 saveThemeMode(mode: ThemeMode)
expect suspend fun loadReaderFontSettings(): ReaderFontSettings
expect suspend fun saveReaderFontSettings(settings: ReaderFontSettings)
expect suspend fun loadScanDownloadsAutomatically(): Boolean expect suspend fun loadScanDownloadsAutomatically(): Boolean
expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean) expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean)
@ -172,3 +184,116 @@ internal fun Fb2Book.libraryCoverBinary(): Fb2Binary? {
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull() val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
return image?.let(::binaryFor) 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 recentlyAdded = recentlyAddedItems.filterNot { it.fileId in hiddenFileIds }
val visibleItems = selectedFilter.apply(sourceItems, recentlyAdded, searchActive) val visibleItems = selectedFilter.apply(sourceItems, recentlyAdded, searchActive)
.withoutDuplicateFileIds() .withoutDuplicateFileIds()
val showShareAction = canShareLibraryBookFile()
val showViewFileAction = canViewLibraryBookFile()
suspend fun loadLibrary() { suspend fun loadLibrary() {
if (loadingLibrary) return 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 = { onDelete = {
val previousItems = items val previousItems = items
val previousSearchResults = searchResults val previousSearchResults = searchResults
@ -638,6 +660,8 @@ internal fun LibraryScreen(
item = item, item = item,
coverCache = coverCache, coverCache = coverCache,
enabled = !busy, enabled = !busy,
showShareAction = showShareAction,
showViewFileAction = showViewFileAction,
actions = itemActions(item), actions = itemActions(item),
) )
} }
@ -843,6 +867,8 @@ private fun LibraryRow(
item: LibraryItem, item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>, coverCache: MutableMap<String, LibraryCover?>,
enabled: Boolean, enabled: Boolean,
showShareAction: Boolean,
showViewFileAction: Boolean,
actions: LibraryItemActions, actions: LibraryItemActions,
) { ) {
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
@ -958,6 +984,27 @@ private fun LibraryRow(
actions.onNotInterested() 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() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
text = { Text(strings.delete) }, text = { Text(strings.delete) },
@ -980,6 +1027,8 @@ private data class LibraryItemActions(
val onMarkToRead: () -> Unit, val onMarkToRead: () -> Unit,
val onNotInterested: () -> Unit, val onNotInterested: () -> Unit,
val onFavoriteChange: (Boolean) -> Unit, val onFavoriteChange: (Boolean) -> Unit,
val onShare: () -> Unit,
val onViewFile: () -> Unit,
val onDelete: () -> Unit, val onDelete: () -> Unit,
) )

View File

@ -136,6 +136,15 @@ internal open class AppStrings {
open val readAloud = "Read aloud" open val readAloud = "Read aloud"
open val readerMenu = "Book reader menu" open val readerMenu = "Book reader menu"
open val info = "Info..." 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 share = "Share"
open val viewFile = "View file" open val viewFile = "View file"
open val previousSentence = "Previous sentence" open val previousSentence = "Previous sentence"
@ -331,6 +340,15 @@ internal object RussianStrings : AppStrings() {
override val readAloud = "Читать вслух" override val readAloud = "Читать вслух"
override val readerMenu = "Меню чтения" override val readerMenu = "Меню чтения"
override val info = "Информация..." 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 share = "Поделиться"
override val viewFile = "Показать файл" override val viewFile = "Показать файл"
override val previousSentence = "Предыдущее предложение" override val previousSentence = "Предыдущее предложение"

View File

@ -102,6 +102,7 @@ import kotlinx.coroutines.launch
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt
@Composable @Composable
internal fun ContinuousBookReader( internal fun ContinuousBookReader(
@ -111,13 +112,15 @@ internal fun ContinuousBookReader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) }, contentPlan: ReaderContentPlan = remember(book) { buildReaderContentPlan(book) },
highlightedSentence: ReadAloudSentence? = null, highlightedSentence: ReadAloudSentence? = null,
readerFontSettings: ReaderFontSettings = defaultReaderFontSettings(),
onReaderFontZoom: (Int) -> Unit = {},
onUserScroll: () -> Unit = {}, onUserScroll: () -> Unit = {},
onImageOpen: (ViewedBookImage) -> Unit = {}, onImageOpen: (ViewedBookImage) -> Unit = {},
onNoteOpen: (String) -> Unit = {}, onNoteOpen: (String) -> Unit = {},
) { ) {
val hyphenation = remember { HyphenationRegistry() } val hyphenation = remember { HyphenationRegistry() }
val scope = rememberCoroutineScope() 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 contentPadding = PaddingValues(top=6.dp, bottom = 6.dp, start = 4.dp, end = 6.dp)
val userScrollConnection = remember(onUserScroll) { val userScrollConnection = remember(onUserScroll) {
object : NestedScrollConnection { object : NestedScrollConnection {
@ -135,6 +138,7 @@ internal fun ContinuousBookReader(
modifier = modifier modifier = modifier
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.nestedScroll(userScrollConnection) .nestedScroll(userScrollConnection)
.readerFontZoomGesture(onReaderFontZoom)
.pageTurnOnTouchTap( .pageTurnOnTouchTap(
onPageDown = { onPageDown = {
onUserScroll() onUserScroll()
@ -206,7 +210,7 @@ internal fun ContinuousBookReader(
text = element.text, text = element.text,
language = book.language, language = book.language,
hyphenation = hyphenation, hyphenation = hyphenation,
style = readerParagraphTextStyle(book.language), style = readerParagraphTextStyle(book.language, readerFontSettings),
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
textAlign = TextAlign.Justify, textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp), 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( private fun Modifier.pageTurnOnTouchTap(
onPageDown: () -> Unit, onPageDown: () -> Unit,
onPageUp: () -> Unit, onPageUp: () -> Unit,
@ -927,16 +975,31 @@ private fun epigraphAuthorTextStyle(language: String?): TextStyle =
epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold) epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold)
@Composable @Composable
private fun readerParagraphTextStyle(language: String?): TextStyle = private fun readerParagraphTextStyle(
MaterialTheme.typography.bodyLarge.copy( language: String?,
fontWeight = if( isAndroidPlatform) FontWeight(350) else FontWeight.Normal, settings: ReaderFontSettings = defaultReaderFontSettings(),
fontSize = if( isAndroidPlatform) 20.sp else 18.sp, ): TextStyle {
lineHeight = 26.sp, 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, letterSpacing = if (isAndroidPlatform) 0.sp else MaterialTheme.typography.bodyLarge.letterSpacing,
hyphens = if (isAndroidPlatform) Hyphens.Auto else Hyphens.Unspecified, hyphens = if (isAndroidPlatform) Hyphens.Auto else Hyphens.Unspecified,
lineBreak = if (isAndroidPlatform) LineBreak.Paragraph else LineBreak.Unspecified, lineBreak = if (isAndroidPlatform) LineBreak.Paragraph else LineBreak.Unspecified,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) }, 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 { private val isAndroidPlatform: Boolean by lazy {
getPlatform().name.startsWith("Android") getPlatform().name.startsWith("Android")
@ -946,6 +1009,8 @@ private val isDesktopPlatform: Boolean by lazy {
getPlatform().name.startsWith("Java") getPlatform().name.startsWith("Java")
} }
private const val ReaderZoomGestureStep = 1.12f
private val MinimumBookImageMaxDimension = 800.dp private val MinimumBookImageMaxDimension = 800.dp
private fun minimumBookImageMaxDimension(availableWidth: Dp): Dp { private fun minimumBookImageMaxDimension(availableWidth: Dp): Dp {

View File

@ -1,6 +1,7 @@
package net.sergeych.toread package net.sergeych.toread
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp 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.FastForward
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay 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.Settings
import androidx.compose.material.icons.filled.Stop 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.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@ -48,7 +55,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.sergeych.toread.fb2.Fb2Book import net.sergeych.toread.fb2.Fb2Book
@ -58,6 +69,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
@ -86,14 +98,15 @@ internal fun BookView(
var libraryItem by remember(fileId) { mutableStateOf<LibraryItem?>(null) } var libraryItem by remember(fileId) { mutableStateOf<LibraryItem?>(null) }
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) } var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
var readAloudSettingsVisible 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 readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) }
var userScrollGeneration by remember(fileId) { mutableStateOf(0) } var userScrollGeneration by remember(fileId) { mutableStateOf(0) }
var selectedNoteId by remember(fileId) { mutableStateOf<String?>(null) } var selectedNoteId by remember(fileId) { mutableStateOf<String?>(null) }
val readAloudState by ReadAloudPlatform.state.collectAsState() val readAloudState by ReadAloudPlatform.state.collectAsState()
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState() val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
val platformName = getPlatform().name val showShareAction = canShareLibraryBookFile()
val showShareAction = platformName.startsWith("Android") val showViewFileAction = canViewLibraryBookFile()
val showViewFileAction = platformName.startsWith("Java")
val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty() val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty()
val activeReadAloudSentence = readAloudState.sentenceIndex val activeReadAloudSentence = readAloudState.sentenceIndex
?.let { index -> contentPlan.sentences.getOrNull(index) } ?.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) { fun setReadingStatus(status: BookReadingStatus, successMessage: String) {
scope.launch { scope.launch {
if (markLibraryReadingStatus(fileId, status)) { if (markLibraryReadingStatus(fileId, status)) {
@ -140,6 +162,10 @@ internal fun BookView(
} }
} }
LaunchedEffect(Unit) {
readerFontSettings = loadReaderFontSettings()
}
DisposableEffect(fileId) { DisposableEffect(fileId) {
onDispose { onDispose {
ReadAloudPlatform.stop() ReadAloudPlatform.stop()
@ -244,17 +270,18 @@ internal fun BookView(
showShareAction = showShareAction, showShareAction = showShareAction,
onShare = { onShare = {
scope.launch { scope.launch {
val shared = shareLibraryBookFile(fileId) showMessage(shareLibraryBook(fileId))
showMessage(if (shared) strings.shareOpened() else strings.couldNotShareBook())
} }
}, },
showViewFileAction = showViewFileAction, showViewFileAction = showViewFileAction,
onViewFile = { onViewFile = {
scope.launch { scope.launch {
val opened = viewLibraryBookFile(fileId) showMessage(viewLibraryBook(fileId))
showMessage(if (opened) strings.openedFileLocation() else strings.couldNotOpenFileLocation())
} }
}, },
onReaderSettings = {
readerSettingsPanelVisible = true
},
showReadAloudAction = showReadAloudAction, showReadAloudAction = showReadAloudAction,
onReadAloud = { onReadAloud = {
val position = ReadingPosition( val position = ReadingPosition(
@ -310,10 +337,41 @@ internal fun BookView(
listState = listState, listState = listState,
contentPlan = contentPlan, contentPlan = contentPlan,
highlightedSentence = highlightedSentence, highlightedSentence = highlightedSentence,
readerFontSettings = readerFontSettings,
onReaderFontZoom = { steps ->
updateReaderFontSettings { settings -> settings.adjustFontSize(steps) }
},
onUserScroll = { userScrollGeneration += 1 }, onUserScroll = { userScrollGeneration += 1 },
onImageOpen = onImageOpen, onImageOpen = onImageOpen,
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") }, 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) { if (readAloudPanelVisible && readAloudState.active) {
ReadAloudPanel( ReadAloudPanel(
playing = readAloudState.playing, playing = readAloudState.playing,
@ -375,6 +433,7 @@ private fun CompactReaderTopBar(
onViewFile: () -> Unit, onViewFile: () -> Unit,
showReadAloudAction: Boolean, showReadAloudAction: Boolean,
onReadAloud: () -> Unit, onReadAloud: () -> Unit,
onReaderSettings: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
@ -415,6 +474,13 @@ private fun CompactReaderTopBar(
onBookInfo() onBookInfo()
}, },
) )
DropdownMenuItem(
text = { Text(strings.readerSettings) },
onClick = {
menuOpen = false
onReaderSettings()
},
)
HorizontalDivider() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
text = { Text(strings.markAsRead) }, 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 @Composable
private fun ReadAloudPanel( private fun ReadAloudPanel(
playing: Boolean, 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 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 { runCatching {
if (!Desktop.isDesktopSupported()) return@withContext false val directory = File(folder).takeIf { it.isDirectory } ?: return@withContext false
val file = openLibraryDatabase().useLibrary { db -> val file = File(directory, fileName).takeIf { it.isFile } ?: return@withContext false
db.files.get(fileId)?.storageUri?.let(::File)?.takeIf { it.isFile } selectOrOpenBookFile(directory, file)
} ?: 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
}.getOrDefault(false) }.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) { actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras() 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) { actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true 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 ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode" private const val ThemeModeFlag = "theme_mode"
private const val ReaderFontSettingsFlag = "reader_font_settings"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically" private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val AppLocaleTagFlag = "app_locale_tag" private const val AppLocaleTagFlag = "app_locale_tag"
private const val DownloadsWasScannedFlag = "downloads_was_scanned" 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 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() 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 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 loadScanDownloadsAutomatically(): Boolean = true
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit
@ -111,6 +118,7 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
actual fun libraryLogPath(): String? = null actual fun libraryLogPath(): String? = null
private const val AppLocaleStorageKey = "toread.appLocaleTag" private const val AppLocaleStorageKey = "toread.appLocaleTag"
private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings"
actual fun formatLibraryLastReadTime(millis: Long): String { actual fun formatLibraryLastReadTime(millis: Long): String {
val totalMinutes = millis / 60_000L val totalMinutes = millis / 60_000L