added visual settings (font/spacing/weight), imprpved linux "show file", added "share/show" to a library card
This commit is contained in:
parent
3e83185e4f
commit
2fddacb7e7
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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 = "Предыдущее предложение"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user