Reading progress, persist library mode and improve scan feedback

This commit is contained in:
Sergey Chernov 2026-05-31 12:39:13 +03:00
parent e8b18ec472
commit dbf63c351c
13 changed files with 599 additions and 177 deletions

View File

@ -473,6 +473,18 @@ actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) = withCo
} }
} }
actual suspend fun loadReaderFullScreen(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ReaderFullScreenFlag)?.toBooleanStrictOrNull() ?: false
}
}
actual suspend fun saveReaderFullScreen(fullScreen: Boolean) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ReaderFullScreenFlag, fullScreen.toString())
}
}
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
@ -485,6 +497,20 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
} }
} }
internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(LibraryFilterFlag)
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
?: LibraryFilter.MyLibrary
}
}
internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(LibraryFilterFlag, filter.name)
}
}
actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) { actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() } db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() }
@ -868,6 +894,8 @@ 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 ReaderFontSettingsFlag = "reader_font_settings"
private const val ReaderFullScreenFlag = "reader_full_screen"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically" private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val LibraryFilterFlag = "library_filter"
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

@ -27,13 +27,13 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import net.sergeych.toread.fb2.Fb2Format
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val DefaultToastDurationMillis = 1_600L private const val DefaultToastDurationMillis = 1_600L
private const val DeleteUndoDurationMillis = 5_000L private const val DeleteUndoDurationMillis = 5_000L
private const val ScanResultToastDurationMillis = 5_000L
private const val BackgroundLibraryPageSize = 50 private const val BackgroundLibraryPageSize = 50
private data class AppToastData( private data class AppToastData(
@ -220,7 +220,12 @@ private fun BookReaderApp(
is AppState.BookInfo -> current.scanPath is AppState.BookInfo -> current.scanPath
is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty() is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty()
} }
state = AppState.Library(emptyList(), scanPath, requestResult.exceptionOrNull()?.message ?: strings.couldNotOpenLibrary) state = AppState.Library(
emptyList(),
scanPath,
requestResult.exceptionOrNull()?.message ?: strings.couldNotOpenLibrary,
loadLibraryFilter(),
)
continue continue
} }
val request = requestResult.getOrNull() ?: continue val request = requestResult.getOrNull() ?: continue
@ -232,7 +237,7 @@ private fun BookReaderApp(
is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty() is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty()
} }
val nextState = runCatching { val nextState = runCatching {
val book = Fb2Format.parse(request.bytes, request.displayName) val book = parseBookInBackground(request.bytes, request.displayName)
saveActiveReadingFileId(request.id) saveActiveReadingFileId(request.id)
AppState.Reader( AppState.Reader(
fileId = request.id, fileId = request.id,
@ -241,11 +246,11 @@ private fun BookReaderApp(
scanPath = scanPath, scanPath = scanPath,
) )
}.getOrElse { }.getOrElse {
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName)) AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), loadLibraryFilter())
} }
libraryBackState = when (val current = state) { libraryBackState = when (val current = state) {
is AppState.Library -> current is AppState.Library -> current
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message) is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message, current.selectedFilter)
is AppState.Reader, is AppState.BookInfo -> libraryBackState is AppState.Reader, is AppState.BookInfo -> libraryBackState
is AppState.Error, AppState.LoadingStartup -> null is AppState.Error, AppState.LoadingStartup -> null
} }
@ -362,7 +367,7 @@ private fun BookReaderApp(
downloadsRescanRequestGeneration += 1 downloadsRescanRequestGeneration += 1
backState backState
} }
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message) is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message, current.selectedFilter)
is AppState.Error -> AppState.LoadingStartup is AppState.Error -> AppState.LoadingStartup
is AppState.Library, AppState.LoadingStartup -> current is AppState.Library, AppState.LoadingStartup -> current
} }
@ -380,7 +385,23 @@ private fun BookReaderApp(
onBack = ::navigateBack, onBack = ::navigateBack,
) )
fun startScan(path: String) { fun setLibraryFilter(filter: LibraryFilter) {
state = when (val current = state) {
is AppState.Library -> current.copy(selectedFilter = filter)
is AppState.Scan -> current.copy(selectedFilter = filter)
else -> current
}
libraryBackState = libraryBackState?.copy(selectedFilter = filter)
}
fun saveAndSetLibraryFilter(filter: LibraryFilter) {
setLibraryFilter(filter)
scope.launch {
saveLibraryFilter(filter)
}
}
fun startScan(path: String, userInitiated: Boolean) {
if (scanJob?.isActive == true) return if (scanJob?.isActive == true) return
activeScan = LibraryScanProgress(0, 0, 0, 0) activeScan = LibraryScanProgress(0, 0, 0, 0)
scanJob = scope.launch { scanJob = scope.launch {
@ -400,19 +421,34 @@ private fun BookReaderApp(
}, },
onFailure = { it.message ?: strings.scanFailed }, onFailure = { it.message ?: strings.scanFailed },
) )
val importedFiles = report.getOrNull()?.importedFiles
when {
importedFiles == null -> Unit
importedFiles > 0 -> {
onShowToast(
strings.newBooksAdded(importedFiles),
strings.show,
{ saveAndSetLibraryFilter(LibraryFilter.RecentlyAdded) },
ScanResultToastDurationMillis,
)
}
userInitiated -> {
onShowToast(strings.noNewBooksFound, null, null, DefaultToastDurationMillis)
}
}
scanJob = null scanJob = null
activeScan = null activeScan = null
state = when (val current = state) { state = when (val current = state) {
is AppState.Library -> if (report.getOrNull()?.hasLibraryChanges() == true) { is AppState.Library -> current.copy(
loadLibraryState(message, path) scanPath = path,
} else { message = message,
current.copy(scanPath = path, message = message) )
} is AppState.Scan -> AppState.Library(
is AppState.Scan -> if (report.getOrNull()?.hasLibraryChanges() == true) { current.items,
loadLibraryState(message, path) path,
} else { message,
AppState.Library(current.items, path, message) selectedFilter = current.selectedFilter,
} )
AppState.LoadingStartup -> loadLibraryState(message, path) AppState.LoadingStartup -> loadLibraryState(message, path)
is AppState.Reader -> current.copy(message = message) is AppState.Reader -> current.copy(message = message)
is AppState.BookInfo -> current.copy(message = message) is AppState.BookInfo -> current.copy(message = message)
@ -426,7 +462,7 @@ private fun BookReaderApp(
if (!loadScanDownloadsAutomatically() || !loadDownloadsWasScanned()) return@LaunchedEffect if (!loadScanDownloadsAutomatically() || !loadDownloadsWasScanned()) return@LaunchedEffect
val path = downloadsScanPath() ?: return@LaunchedEffect val path = downloadsScanPath() ?: return@LaunchedEffect
state = state.withMessage(strings.scanningDownloads) state = state.withMessage(strings.scanningDownloads)
startScan(path) startScan(path, userInitiated = false)
} }
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@ -448,9 +484,15 @@ private fun BookReaderApp(
} }
state = next state = next
}, },
onLibraryFilterChange = ::setLibraryFilter,
onNavigateToScan = { onNavigateToScan = {
libraryBackState = null libraryBackState = null
state = AppState.Scan(libraryState.items, libraryState.scanPath, libraryState.message) state = AppState.Scan(
libraryState.items,
libraryState.scanPath,
libraryState.message,
libraryState.selectedFilter,
)
}, },
onDeleteRequested = ::requestDelete, onDeleteRequested = ::requestDelete,
) )
@ -464,8 +506,8 @@ private fun BookReaderApp(
activeScan = activeScan, activeScan = activeScan,
onStateChange = { state = it }, onStateChange = { state = it },
onStartScan = { path -> onStartScan = { path ->
startScan(path) startScan(path, userInitiated = true)
state = AppState.Library(current.items, path, strings.scanning) state = AppState.Library(current.items, path, strings.scanning, current.selectedFilter)
}, },
) )
is AppState.Reader -> BookView( is AppState.Reader -> BookView(
@ -516,9 +558,6 @@ internal data class ViewedBookImage(
val title: String, val title: String,
) )
private fun LibraryScanReport.hasLibraryChanges(): Boolean =
importedFiles > 0
private fun AppState.withMessage(message: String): AppState = private fun AppState.withMessage(message: String): AppState =
when (this) { when (this) {
is AppState.Library -> copy(message = message) is AppState.Library -> copy(message = message)

View File

@ -2,6 +2,8 @@ package net.sergeych.toread
import net.sergeych.toread.fb2.Fb2Book import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Format import net.sergeych.toread.fb2.Fb2Format
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal sealed interface AppState { internal sealed interface AppState {
data object LoadingStartup : AppState data object LoadingStartup : AppState
@ -9,12 +11,14 @@ internal sealed interface AppState {
val items: List<LibraryItem>, val items: List<LibraryItem>,
val scanPath: String, val scanPath: String,
val message: String? = null, val message: String? = null,
val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary,
) : AppState ) : AppState
data class Scan( data class Scan(
val items: List<LibraryItem>, val items: List<LibraryItem>,
val scanPath: String, val scanPath: String,
val message: String? = null, val message: String? = null,
val selectedFilter: LibraryFilter = LibraryFilter.MyLibrary,
) : AppState ) : AppState
data class Reader( data class Reader(
@ -42,12 +46,13 @@ internal suspend fun loadStartupState(): AppState {
} catch (t: Throwable) { } catch (t: Throwable) {
return AppState.Error(t.message ?: strings.couldNotOpenLibrary) return AppState.Error(t.message ?: strings.couldNotOpenLibrary)
} }
val libraryFilter = loadLibraryFilter()
val platformRequest = runCatching { loadPlatformOpenBookRequest() }.getOrElse { val platformRequest = runCatching { loadPlatformOpenBookRequest() }.getOrElse {
return AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpenLibrary) return AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpenLibrary, libraryFilter)
} }
platformRequest?.let { request -> platformRequest?.let { request ->
return runCatching { return runCatching {
val book = Fb2Format.parse(request.bytes, request.displayName) val book = parseBookInBackground(request.bytes, request.displayName)
saveActiveReadingFileId(request.id) saveActiveReadingFileId(request.id)
AppState.Reader( AppState.Reader(
fileId = request.id, fileId = request.id,
@ -56,36 +61,46 @@ internal suspend fun loadStartupState(): AppState {
scanPath = scanPath, scanPath = scanPath,
) )
}.getOrElse { }.getOrElse {
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName)) AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName), libraryFilter)
} }
} }
val activeFileId = loadActiveReadingFileId() ?: return AppState.Library(emptyList(), scanPath) val activeFileId = loadActiveReadingFileId() ?: return AppState.Library(emptyList(), scanPath, selectedFilter = libraryFilter)
val item = loadLibraryItem(activeFileId) val item = loadLibraryItem(activeFileId)
if (item == null) { if (item == null) {
saveActiveReadingFileId(null) saveActiveReadingFileId(null)
return AppState.Library(emptyList(), scanPath) return AppState.Library(emptyList(), scanPath, selectedFilter = libraryFilter)
} }
return runCatching { return runCatching {
val bytes = openLibraryBook(activeFileId) ?: error(strings.bookFileNotAvailable) val bytes = openLibraryBook(activeFileId) ?: error(strings.bookFileNotAvailable)
AppState.Reader( AppState.Reader(
fileId = activeFileId, fileId = activeFileId,
book = Fb2Format.parse(bytes, item.storageUri ?: item.title), book = parseBookInBackground(bytes, item.storageUri ?: item.title),
libraryItems = emptyList(), libraryItems = emptyList(),
scanPath = scanPath, scanPath = scanPath,
) )
}.getOrElse { }.getOrElse {
saveActiveReadingFileId(null) saveActiveReadingFileId(null)
AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotReopenLastBook()) AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotReopenLastBook(), libraryFilter)
} }
} }
internal suspend fun loadLibraryState(message: String? = null, scanPath: String? = null): AppState = internal suspend fun loadLibraryState(
message: String? = null,
scanPath: String? = null,
selectedFilter: LibraryFilter? = null,
): AppState =
runCatching { runCatching {
AppState.Library( AppState.Library(
items = emptyList(), items = emptyList(),
scanPath = scanPath ?: defaultLibraryScanPath().orEmpty(), scanPath = scanPath ?: defaultLibraryScanPath().orEmpty(),
message = message, message = message,
selectedFilter = selectedFilter ?: loadLibraryFilter(),
) )
}.getOrElse { }.getOrElse {
AppState.Error(it.message ?: strings.couldNotOpenLibrary) AppState.Error(it.message ?: strings.couldNotOpenLibrary)
} }
internal suspend fun parseBookInBackground(input: ByteArray, fileName: String? = null): Fb2Book =
withContext(Dispatchers.Default) {
Fb2Format.parse(input, fileName)
}

View File

@ -159,10 +159,18 @@ expect suspend fun loadReaderFontSettings(): ReaderFontSettings
expect suspend fun saveReaderFontSettings(settings: ReaderFontSettings) expect suspend fun saveReaderFontSettings(settings: ReaderFontSettings)
expect suspend fun loadReaderFullScreen(): Boolean
expect suspend fun saveReaderFullScreen(fullScreen: Boolean)
expect suspend fun loadScanDownloadsAutomatically(): Boolean expect suspend fun loadScanDownloadsAutomatically(): Boolean
expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean) expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean)
internal expect suspend fun loadLibraryFilter(): LibraryFilter
internal expect suspend fun saveLibraryFilter(filter: LibraryFilter)
expect suspend fun loadAppLocaleTag(): String? expect suspend fun loadAppLocaleTag(): String?
expect suspend fun saveAppLocaleTag(localeTag: String?) expect suspend fun saveAppLocaleTag(localeTag: String?)

View File

@ -83,7 +83,6 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight 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.Fb2Format
import net.sergeych.toread.storage.BookReadingStatus import net.sergeych.toread.storage.BookReadingStatus
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -97,6 +96,7 @@ internal fun LibraryScreen(
itemRefreshRequest: LibraryItemRefreshRequest?, itemRefreshRequest: LibraryItemRefreshRequest?,
hiddenFileIds: Set<String>, hiddenFileIds: Set<String>,
onStateChange: (AppState) -> Unit, onStateChange: (AppState) -> Unit,
onLibraryFilterChange: (LibraryFilter) -> Unit,
onNavigateToScan: () -> Unit, onNavigateToScan: () -> Unit,
onDeleteRequested: ( onDeleteRequested: (
request: LibraryDeleteRequest, request: LibraryDeleteRequest,
@ -107,6 +107,7 @@ internal fun LibraryScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var openingBook by remember { mutableStateOf(false) }
var message by remember(state.message) { mutableStateOf(state.message) } var message by remember(state.message) { mutableStateOf(state.message) }
var items by remember(state.items) { mutableStateOf(state.items) } var items by remember(state.items) { mutableStateOf(state.items) }
var loadingLibrary by remember(state.items) { mutableStateOf(false) } var loadingLibrary by remember(state.items) { mutableStateOf(false) }
@ -119,8 +120,7 @@ internal fun LibraryScreen(
var searchFocused by remember { mutableStateOf(false) } var searchFocused by remember { mutableStateOf(false) }
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) } var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
var searching by remember { mutableStateOf(false) } var searching by remember { mutableStateOf(false) }
var selectedFilter by remember(state.scanPath) { mutableStateOf(LibraryFilter.ReadingNow) } var selectedFilter by remember(state.scanPath, state.selectedFilter) { mutableStateOf(state.selectedFilter) }
var filterChosenByUser by remember(state.scanPath) { mutableStateOf(false) }
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() } val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
val searchActive = searchText.isNotBlank() val searchActive = searchText.isNotBlank()
val libraryItems = items.filterNot { it.fileId in hiddenFileIds } val libraryItems = items.filterNot { it.fileId in hiddenFileIds }
@ -302,13 +302,6 @@ internal fun LibraryScreen(
} }
} }
LaunchedEffect(searchActive, loadingLibrary, libraryItems, recentlyAdded) {
val libraryDataLoaded = libraryItems.isNotEmpty() || recentlyAdded.isNotEmpty()
if (!filterChosenByUser && !searchActive && !loadingLibrary && libraryDataLoaded) {
selectedFilter = defaultLibraryFilter(libraryItems, recentlyAdded)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
autoScanDownloads = loadScanDownloadsAutomatically() autoScanDownloads = loadScanDownloadsAutomatically()
} }
@ -328,7 +321,8 @@ internal fun LibraryScreen(
} }
} }
Scaffold( Box(Modifier.fillMaxSize()) {
Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
@ -341,8 +335,11 @@ internal fun LibraryScreen(
LibraryFilterSelect( LibraryFilterSelect(
selected = selectedFilter, selected = selectedFilter,
onSelected = { onSelected = {
filterChosenByUser = true
selectedFilter = it selectedFilter = it
onLibraryFilterChange(it)
scope.launch {
saveLibraryFilter(it)
}
}, },
) )
} }
@ -456,31 +453,31 @@ internal fun LibraryScreen(
Icon(Icons.Filled.Add, contentDescription = strings.scanFolder) Icon(Icons.Filled.Add, contentDescription = strings.scanFolder)
} }
}, },
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(it)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when {
event.key == Key.Escape && searchText.isNotBlank() -> {
clearSearch()
true
}
event.key == Key.Escape && searchFocused -> {
closeSearch()
true
}
event.key.isEnterKey() && searchFocused && searchText.isBlank() -> {
closeSearch()
true
}
else -> false
}
}
.background(readerBackground()),
) { ) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(it)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when {
event.key == Key.Escape && searchText.isNotBlank() -> {
clearSearch()
true
}
event.key == Key.Escape && searchFocused -> {
closeSearch()
true
}
event.key.isEnterKey() && searchFocused && searchText.isBlank() -> {
closeSearch()
true
}
else -> false
}
}
.background(readerBackground()),
) {
val wide = maxWidth >= 800.dp val wide = maxWidth >= 800.dp
if (visibleItems.isEmpty() && (loadingLibrary || searching)) { if (visibleItems.isEmpty() && (loadingLibrary || searching)) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -505,10 +502,11 @@ internal fun LibraryScreen(
onOpen = { onOpen = {
scope.launch { scope.launch {
busy = true busy = true
openingBook = true
try { try {
val next = runCatching { val next = runCatching {
val bytes = openLibraryBook(item.fileId) ?: error(strings.bookFileNotAvailable) val bytes = openLibraryBook(item.fileId) ?: error(strings.bookFileNotAvailable)
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title) val book = parseBookInBackground(bytes, item.storageUri ?: item.title)
var readerLibraryItems = visibleItems var readerLibraryItems = visibleItems
refreshLibraryItemFromParsedBook(item.fileId, book)?.let { updatedItem -> refreshLibraryItemFromParsedBook(item.fileId, book)?.let { updatedItem ->
items = items.replaceLibraryItem(updatedItem) items = items.replaceLibraryItem(updatedItem)
@ -534,10 +532,16 @@ internal fun LibraryScreen(
message = message, message = message,
) )
}.getOrElse { }.getOrElse {
AppState.Library(visibleItems, state.scanPath, it.message ?: strings.couldNotOpenBook) AppState.Library(
visibleItems,
state.scanPath,
it.message ?: strings.couldNotOpenBook,
state.selectedFilter,
)
} }
onStateChange(next) onStateChange(next)
} finally { } finally {
openingBook = false
busy = false busy = false
} }
} }
@ -674,6 +678,10 @@ internal fun LibraryScreen(
.padding(horizontal = if (wide) 24.dp else 14.dp, vertical = 14.dp), .padding(horizontal = if (wide) 24.dp else 14.dp, vertical = 14.dp),
) )
} }
}
}
if (openingBook) {
LoadingOverlay(strings.loadingOpeningBook)
} }
} }
} }
@ -1061,7 +1069,7 @@ private data class LibraryItemActions(
val onDelete: () -> Unit, val onDelete: () -> Unit,
) )
private enum class LibraryFilter { internal enum class LibraryFilter {
ReadingNow, ReadingNow,
RecentlyAdded, RecentlyAdded,
MyLibrary, MyLibrary,
@ -1178,16 +1186,6 @@ private val LibraryFilter.label: String
LibraryFilter.NotInterested -> strings.notInterested LibraryFilter.NotInterested -> strings.notInterested
} }
private fun defaultLibraryFilter(
libraryItems: List<LibraryItem>,
recentlyAddedItems: List<LibraryItem>,
): LibraryFilter =
when {
libraryItems.any { it.readingStatus == BookReadingStatus.READING } -> LibraryFilter.ReadingNow
recentlyAddedItems.any { it.readingStatus == BookReadingStatus.NEW } -> LibraryFilter.RecentlyAdded
else -> LibraryFilter.MyLibrary
}
@Composable @Composable
private fun LibraryCover( private fun LibraryCover(
item: LibraryItem, item: LibraryItem,

View File

@ -139,6 +139,7 @@ internal open class AppStrings {
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 readerSettings = "Settings"
open val fullScreen = "Full screen"
open val readerFontSizeIncrease = "Increase font size" open val readerFontSizeIncrease = "Increase font size"
open val readerFontSizeDecrease = "Decrease font size" open val readerFontSizeDecrease = "Decrease font size"
open val readerLineHeightIncrease = "Increase line spacing" open val readerLineHeightIncrease = "Increase line spacing"
@ -187,6 +188,10 @@ internal open class AppStrings {
open fun couldNotReopenLastBook(): String = "Could not reopen last book." open fun couldNotReopenLastBook(): String = "Could not reopen last book."
open fun scanReport(scanned: Int, imported: Int, skipped: Int, failed: Int): String = open fun scanReport(scanned: Int, imported: Int, skipped: Int, failed: Int): String =
"Scanned $scanned, imported $imported, skipped $skipped, failed $failed." "Scanned $scanned, imported $imported, skipped $skipped, failed $failed."
open val noNewBooksFound = "Nothing new has been found."
open val show = "Show"
open fun newBooksAdded(count: Int): String =
if (count == 1) "1 new book added." else "$count new books added."
open fun rescanReport(scanned: Int, updated: Int, failed: Int): String = open fun rescanReport(scanned: Int, updated: Int, failed: Int): String =
"Rescanned $scanned, updated $updated, failed $failed." "Rescanned $scanned, updated $updated, failed $failed."
open fun checkingFile(currentFile: String?, scanned: Int, imported: Int, skipped: Int, failed: Int): String = open fun checkingFile(currentFile: String?, scanned: Int, imported: Int, skipped: Int, failed: Int): String =
@ -345,6 +350,7 @@ internal object RussianStrings : AppStrings() {
override val readerMenu = "Меню чтения" override val readerMenu = "Меню чтения"
override val info = "Информация..." override val info = "Информация..."
override val readerSettings = "Настройки" override val readerSettings = "Настройки"
override val fullScreen = "На весь экран"
override val readerFontSizeIncrease = "Увеличить шрифт" override val readerFontSizeIncrease = "Увеличить шрифт"
override val readerFontSizeDecrease = "Уменьшить шрифт" override val readerFontSizeDecrease = "Уменьшить шрифт"
override val readerLineHeightIncrease = "Увеличить межстрочный интервал" override val readerLineHeightIncrease = "Увеличить межстрочный интервал"
@ -393,6 +399,14 @@ internal object RussianStrings : AppStrings() {
override fun couldNotReopenLastBook(): String = "Не удалось открыть последнюю книгу." override fun couldNotReopenLastBook(): String = "Не удалось открыть последнюю книгу."
override fun scanReport(scanned: Int, imported: Int, skipped: Int, failed: Int): String = override fun scanReport(scanned: Int, imported: Int, skipped: Int, failed: Int): String =
"Проверено: $scanned, импортировано: $imported, пропущено: $skipped, ошибок: $failed." "Проверено: $scanned, импортировано: $imported, пропущено: $skipped, ошибок: $failed."
override val noNewBooksFound = "Ничего нового не найдено."
override val show = "Показать"
override fun newBooksAdded(count: Int): String =
when {
count % 10 == 1 && count % 100 != 11 -> "$count новая книга добавлена."
count % 10 in 2..4 && count % 100 !in 12..14 -> "$count новые книги добавлены."
else -> "$count новых книг добавлено."
}
override fun rescanReport(scanned: Int, updated: Int, failed: Int): String = override fun rescanReport(scanned: Int, updated: Int, failed: Int): String =
"Пересканировано: $scanned, обновлено: $updated, ошибок: $failed." "Пересканировано: $scanned, обновлено: $updated, ошибок: $failed."
override fun checkingFile(currentFile: String?, scanned: Int, imported: Int, skipped: Int, failed: Int): String = override fun checkingFile(currentFile: String?, scanned: Int, imported: Int, skipped: Int, failed: Int): String =

View File

@ -35,6 +35,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -52,6 +53,7 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass
@ -98,12 +100,19 @@ import net.sergeych.toread.fb2.Fb2TextSpan
import net.sergeych.toread.fb2.Fb2TextStyle import net.sergeych.toread.fb2.Fb2TextStyle
import net.sergeych.toread.text.HyphenationRegistry import net.sergeych.toread.text.HyphenationRegistry
import net.sergeych.toread.text.SoftHyphen import net.sergeych.toread.text.SoftHyphen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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 import kotlin.math.sqrt
private data class DecodedBookImage(
val bitmap: ImageBitmap,
val bytes: ByteArray,
)
@Composable @Composable
internal fun ContinuousBookReader( internal fun ContinuousBookReader(
book: Fb2Book, book: Fb2Book,
@ -1035,9 +1044,18 @@ private fun BookImage(
val binary = remember(book, image) { val binary = remember(book, image) {
image?.let(book::binaryFor) image?.let(book::binaryFor)
} }
val bitmap = remember(binary) { var decodedImage by remember(binary) { mutableStateOf<DecodedBookImage?>(null) }
binary?.let { decodeBookImage(it) } LaunchedEffect(binary) {
decodedImage = withContext(Dispatchers.Default) {
binary?.let {
val bytes = it.imageBytes()
val bitmap = decodeImageBytes(bytes) ?: return@let null
DecodedBookImage(bitmap, bytes)
}
}
} }
val currentDecodedImage = decodedImage
val bitmap = currentDecodedImage?.bitmap
val imageTitle = image?.alt?.ifBlank { null } ?: book.title val imageTitle = image?.alt?.ifBlank { null } ?: book.title
val imageBackgroundColor = readerImageBackgroundColor() val imageBackgroundColor = readerImageBackgroundColor()
val imageShape = RoundedCornerShape(8.dp) val imageShape = RoundedCornerShape(8.dp)
@ -1048,12 +1066,12 @@ private fun BookImage(
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.then( .then(
if (bitmap != null && binary != null) { if (currentDecodedImage != null && binary != null) {
Modifier.clickable { Modifier.clickable {
onOpen( onOpen(
ViewedBookImage( ViewedBookImage(
bitmap = bitmap, bitmap = currentDecodedImage.bitmap,
bytes = binary.imageBytes(), bytes = currentDecodedImage.bytes,
mimeType = binary.contentType, mimeType = binary.contentType,
title = imageTitle, title = imageTitle,
), ),

View File

@ -1,6 +1,13 @@
package net.sergeych.toread package net.sergeych.toread
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -11,6 +18,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -45,6 +53,7 @@ import androidx.compose.material.icons.filled.UnfoldLess
import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@ -64,6 +73,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -74,20 +84,32 @@ 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.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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
import net.sergeych.toread.storage.BookReadingStatus import net.sergeych.toread.storage.BookReadingStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce 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 kotlinx.coroutines.withContext
import kotlin.math.roundToInt import kotlin.math.roundToInt
private data class ReaderBookPreparation(
val stats: BookStats,
val contentPlan: ReaderContentPlan,
)
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
internal fun BookView( internal fun BookView(
@ -105,17 +127,17 @@ internal fun BookView(
) -> Unit, ) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val stats = remember(book) { BookStats.from(book) }
val contentPlan = remember(book) { buildReaderContentPlan(book) }
val listState = remember(fileId) { LazyListState() } val listState = remember(fileId) { LazyListState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var preparation by remember(book) { mutableStateOf<ReaderBookPreparation?>(null) }
var restored by remember(fileId) { mutableStateOf(false) } var restored by remember(fileId) { mutableStateOf(false) }
var markedRead by remember(fileId) { mutableStateOf(false) } var markedRead by remember(fileId) { mutableStateOf(false) }
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 readerSettingsPanelVisible by remember(fileId) { mutableStateOf(false) }
var fullScreenReader by remember { mutableStateOf(false) }
var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) } var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) }
var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) } var tableOfContentsBackStack by remember(fileId) { mutableStateOf<List<ReadingPosition>>(emptyList()) }
var tableOfContentsBackPosition by remember(fileId) { mutableStateOf<ReadingPosition?>(null) } var tableOfContentsBackPosition by remember(fileId) { mutableStateOf<ReadingPosition?>(null) }
@ -125,9 +147,31 @@ internal fun BookView(
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()
LaunchedEffect(book) {
preparation = withContext(Dispatchers.Default) {
ReaderBookPreparation(
stats = BookStats.from(book),
contentPlan = buildReaderContentPlan(book),
)
}
}
val preparedBook = preparation
if (preparedBook == null) {
LoadingScreen(strings.loadingOpeningBook)
return
}
val stats = preparedBook.stats
val contentPlan = preparedBook.contentPlan
val showShareAction = canShareLibraryBookFile() val showShareAction = canShareLibraryBookFile()
val showViewFileAction = canViewLibraryBookFile() val showViewFileAction = canViewLibraryBookFile()
val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty() val showReadAloudAction = ReadAloudPlatform.isSupported && contentPlan.sentences.isNotEmpty()
val fullScreenProgressTitle = remember(book) { book.fullScreenProgressTitle() }
val readingProgress by remember(listState) {
derivedStateOf { listState.readerProgress() }
}
val activeReadAloudSentence = readAloudState.sentenceIndex val activeReadAloudSentence = readAloudState.sentenceIndex
?.let { index -> contentPlan.sentences.getOrNull(index) } ?.let { index -> contentPlan.sentences.getOrNull(index) }
?.takeIf { readAloudState.active } ?.takeIf { readAloudState.active }
@ -157,6 +201,14 @@ internal fun BookView(
} }
} }
fun updateReaderFullScreen(fullScreen: Boolean) {
if (fullScreen == fullScreenReader) return
fullScreenReader = fullScreen
scope.launch {
saveReaderFullScreen(fullScreen)
}
}
fun currentReadingPosition(backStack: List<ReadingPosition> = tableOfContentsBackStack): ReadingPosition = fun currentReadingPosition(backStack: List<ReadingPosition> = tableOfContentsBackStack): ReadingPosition =
ReadingPosition( ReadingPosition(
listState.firstVisibleItemIndex, listState.firstVisibleItemIndex,
@ -225,6 +277,7 @@ internal fun BookView(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
readerFontSettings = loadReaderFontSettings() readerFontSettings = loadReaderFontSettings()
fullScreenReader = loadReaderFullScreen()
} }
DisposableEffect(fileId) { DisposableEffect(fileId) {
@ -294,94 +347,102 @@ internal fun BookView(
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
CompactReaderTopBar( AnimatedVisibility(
title = book.title, visible = !fullScreenReader,
onThemeToggle = onThemeToggle, enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
onBookInfo = { exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
scope.launch { ) {
saveLibraryReadingPosition( CompactReaderTopBar(
fileId, title = book.title,
currentReadingPosition(), onThemeToggle = onThemeToggle,
) onBookInfo = {
onBookInfo() scope.launch {
} saveLibraryReadingPosition(
}, fileId,
onTableOfContents = ::openTableOfContents, currentReadingPosition(),
onMarkAsRead = { )
setReadingStatus(BookReadingStatus.READ, strings.markedAsRead()) onBookInfo()
},
onMarkToRead = {
setReadingStatus(BookReadingStatus.TO_READ, strings.markedToRead())
},
onNotInterested = {
setReadingStatus(BookReadingStatus.NOT_INTERESTED, strings.markedNotInterested())
},
onClearMarks = {
setReadingStatus(BookReadingStatus.NEW, strings.removedMarks())
},
readingStatus = libraryItem?.readingStatus,
favorite = libraryItem?.favorite == true,
onFavoriteChange = { favorite ->
scope.launch {
if (markLibraryFavorite(fileId, favorite)) {
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite)
onBookChanged(fileId)
showMessage(if (favorite) strings.addedToFavorites() else strings.removedFromFavorites())
} else {
showMessage(strings.couldNotUpdateBook)
} }
} },
}, onTableOfContents = ::openTableOfContents,
showShareAction = showShareAction, onMarkAsRead = {
onShare = { setReadingStatus(BookReadingStatus.READ, strings.markedAsRead())
scope.launch { },
showMessage(shareLibraryBook(fileId)) onMarkToRead = {
} setReadingStatus(BookReadingStatus.TO_READ, strings.markedToRead())
}, },
showViewFileAction = showViewFileAction, onNotInterested = {
onViewFile = { setReadingStatus(BookReadingStatus.NOT_INTERESTED, strings.markedNotInterested())
scope.launch { },
showMessage(viewLibraryBook(fileId)) onClearMarks = {
} setReadingStatus(BookReadingStatus.NEW, strings.removedMarks())
}, },
onReaderSettings = { readingStatus = libraryItem?.readingStatus,
readerSettingsPanelVisible = true favorite = libraryItem?.favorite == true,
}, onFavoriteChange = { favorite ->
showReadAloudAction = showReadAloudAction, scope.launch {
onReadAloud = { if (markLibraryFavorite(fileId, favorite)) {
val position = currentReadingPosition() libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite)
val startIndex = contentPlan.resumeSentenceIndex(position) onBookChanged(fileId)
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex) showMessage(if (favorite) strings.addedToFavorites() else strings.removedFromFavorites())
readAloudPanelVisible = true } else {
ReadAloudPlatform.play() showMessage(strings.couldNotUpdateBook)
},
onDelete = {
onDeleteRequested(
LibraryDeleteRequest(fileId, book.title),
{
scope.launch {
saveActiveReadingFileId(null)
} }
onDeleted(null) }
}, },
{ fullScreen = fullScreenReader,
scope.launch { onFullScreenChange = ::updateReaderFullScreen,
saveActiveReadingFileId(fileId) showShareAction = showShareAction,
} onShare = {
}, scope.launch {
) showMessage(shareLibraryBook(fileId))
}, }
onBack = { },
scope.launch { showViewFileAction = showViewFileAction,
saveLibraryReadingPosition( onViewFile = {
fileId, scope.launch {
currentReadingPosition(), showMessage(viewLibraryBook(fileId))
}
},
onReaderSettings = {
readerSettingsPanelVisible = true
},
showReadAloudAction = showReadAloudAction,
onReadAloud = {
val position = currentReadingPosition()
val startIndex = contentPlan.resumeSentenceIndex(position)
ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex)
readAloudPanelVisible = true
ReadAloudPlatform.play()
},
onDelete = {
onDeleteRequested(
LibraryDeleteRequest(fileId, book.title),
{
scope.launch {
saveActiveReadingFileId(null)
}
onDeleted(null)
},
{
scope.launch {
saveActiveReadingFileId(fileId)
}
},
) )
saveActiveReadingFileId(null) },
onBack() onBack = {
} scope.launch {
}, saveLibraryReadingPosition(
) fileId,
currentReadingPosition(),
)
saveActiveReadingFileId(null)
onBack()
}
},
)
}
}, },
) { ) {
Box( Box(
@ -452,6 +513,12 @@ internal fun BookView(
}, },
) )
} }
ReaderProgressPane(
progress = readingProgress,
fullScreen = fullScreenReader,
fullScreenTitle = fullScreenProgressTitle,
onFullScreenToggle = { updateReaderFullScreen(!fullScreenReader) },
)
} }
} }
} }
@ -505,6 +572,151 @@ internal fun BookView(
} }
} }
@Composable
private fun ReaderProgressPane(
progress: Float,
fullScreen: Boolean,
fullScreenTitle: String,
onFullScreenToggle: () -> Unit,
) {
val percent = (progress.coerceIn(0f, 1f) * 100f).roundToInt()
val trackColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)
val readColor = MaterialTheme.colorScheme.primary
val fullScreenFillColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
val contentDescription = strings.progressLabel(progress.toDouble())
val height by animateDpAsState(if (fullScreen) 36.dp else 24.dp)
val progressRowHeight by animateDpAsState(24.dp)
val canvasHeight by animateDpAsState(if (fullScreen) 12.dp else 8.dp)
val trackStroke by animateDpAsState(1.dp)
val readStroke by animateDpAsState(3.dp)
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onFullScreenToggle),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height)
.then(
if (fullScreen) {
Modifier.drawBehind {
drawRect(
color = fullScreenFillColor,
size = Size(size.width * progress.coerceIn(0f, 1f), size.height),
)
}
} else {
Modifier
},
)
.semantics { this.contentDescription = contentDescription },
) {
if (fullScreen) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(progressRowHeight)
.align(Alignment.Center)
.padding(end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = fullScreenTitle,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
)
Spacer(Modifier.size(10.dp))
Text(
text = "$percent%",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.height(progressRowHeight)
.padding(start = 12.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Canvas(
modifier = Modifier
.weight(1f)
.height(canvasHeight),
) {
val centerY = size.height / 2f
drawLine(
color = trackColor,
start = Offset(0f, centerY),
end = Offset(size.width, centerY),
strokeWidth = trackStroke.toPx(),
cap = StrokeCap.Square,
)
if (progress > 0f) {
drawLine(
color = readColor,
start = Offset(0f, centerY),
end = Offset(size.width * progress.coerceIn(0f, 1f), centerY),
strokeWidth = readStroke.toPx(),
cap = StrokeCap.Square,
)
}
}
Spacer(Modifier.size(10.dp))
Text(
text = "$percent%",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
}
}
}
}
private fun Fb2Book.fullScreenProgressTitle(): String {
val author = authors.joinToString { it.displayName }.ifBlank { strings.unknownAuthor }
return "$author. $title"
}
private fun LazyListState.readerProgress(): Float {
val layoutInfo = layoutInfo
val totalItems = layoutInfo.totalItemsCount
if (totalItems <= 1) return 0f
val visibleItems = layoutInfo.visibleItemsInfo
val lastVisibleItem = visibleItems.lastOrNull()
if (
lastVisibleItem?.index == totalItems - 1 &&
lastVisibleItem.offset + lastVisibleItem.size <= layoutInfo.viewportEndOffset
) {
return 1f
}
val firstIndex = firstVisibleItemIndex.coerceIn(0, totalItems - 1)
val firstVisibleItemSize = visibleItems.firstOrNull { it.index == firstIndex }?.size
val itemFraction = firstVisibleItemSize
?.takeIf { it > 0 }
?.let { (firstVisibleItemScrollOffset.toFloat() / it.toFloat()).coerceIn(0f, 1f) }
?: 0f
return ((firstIndex + itemFraction) / (totalItems - 1).toFloat()).coerceIn(0f, 1f)
}
@Composable @Composable
private fun CompactReaderTopBar( private fun CompactReaderTopBar(
title: String, title: String,
@ -518,6 +730,8 @@ private fun CompactReaderTopBar(
readingStatus: BookReadingStatus?, readingStatus: BookReadingStatus?,
favorite: Boolean, favorite: Boolean,
onFavoriteChange: (Boolean) -> Unit, onFavoriteChange: (Boolean) -> Unit,
fullScreen: Boolean,
onFullScreenChange: (Boolean) -> Unit,
showShareAction: Boolean, showShareAction: Boolean,
onShare: () -> Unit, onShare: () -> Unit,
showViewFileAction: Boolean, showViewFileAction: Boolean,
@ -581,6 +795,16 @@ private fun CompactReaderTopBar(
onReaderSettings() onReaderSettings()
}, },
) )
DropdownMenuItem(
leadingIcon = {
Checkbox(checked = fullScreen, onCheckedChange = null)
},
text = { Text(strings.fullScreen) },
onClick = {
menuOpen = false
onFullScreenChange(!fullScreen)
},
)
HorizontalDivider() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
leadingIcon = { leadingIcon = {

View File

@ -57,7 +57,7 @@ internal fun ScanScreen(
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { Text(strings.scan) }, title = { Text(strings.scan) },
navigationIcon = { navigationIcon = {
IconButton(onClick = { onStateChange(AppState.Library(state.items, scanPath, message)) }) { IconButton(onClick = { onStateChange(AppState.Library(state.items, scanPath, message, state.selectedFilter)) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.backToLibrary) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.backToLibrary)
} }
}, },

View File

@ -1,5 +1,6 @@
package net.sergeych.toread package net.sergeych.toread
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -31,8 +32,39 @@ import kotlin.math.roundToInt
@Composable @Composable
internal fun LoadingScreen(message: String) { internal fun LoadingScreen(message: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
LoadingPanel(message)
}
}
@Composable
internal fun LoadingOverlay(message: String, modifier: Modifier = Modifier) {
Box(
modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.58f)),
contentAlignment = Alignment.Center,
) {
LoadingPanel(message)
}
}
@Composable
private fun LoadingPanel(message: String) {
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator() CircularProgressIndicator()
Text(message, style = MaterialTheme.typography.bodyMedium) Text(message, style = MaterialTheme.typography.bodyMedium)
} }

View File

@ -449,6 +449,18 @@ actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) = withCo
} }
} }
actual suspend fun loadReaderFullScreen(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ReaderFullScreenFlag)?.toBooleanStrictOrNull() ?: false
}
}
actual suspend fun saveReaderFullScreen(fullScreen: Boolean) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ReaderFullScreenFlag, fullScreen.toString())
}
}
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
@ -461,6 +473,20 @@ actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContex
} }
} }
internal actual suspend fun loadLibraryFilter(): LibraryFilter = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(LibraryFilterFlag)
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
?: LibraryFilter.MyLibrary
}
}
internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(LibraryFilterFlag, filter.name)
}
}
actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) { actual suspend fun loadAppLocaleTag(): String? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() } db.getAppFlag(AppLocaleTagFlag)?.takeIf { it.isNotBlank() }
@ -715,6 +741,8 @@ private fun String.isSupportedBookFile(): Boolean =
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 ReaderFontSettingsFlag = "reader_font_settings"
private const val ReaderFullScreenFlag = "reader_full_screen"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically" private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val LibraryFilterFlag = "library_filter"
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

@ -83,10 +83,26 @@ actual suspend fun saveReaderFontSettings(settings: ReaderFontSettings) {
window.localStorage.setItem(ReaderFontSettingsStorageKey, settings.toStorageValue()) window.localStorage.setItem(ReaderFontSettingsStorageKey, settings.toStorageValue())
} }
actual suspend fun loadReaderFullScreen(): Boolean =
window.localStorage.getItem(ReaderFullScreenStorageKey)?.toBooleanStrictOrNull() ?: false
actual suspend fun saveReaderFullScreen(fullScreen: Boolean) {
window.localStorage.setItem(ReaderFullScreenStorageKey, fullScreen.toString())
}
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
internal actual suspend fun loadLibraryFilter(): LibraryFilter =
window.localStorage.getItem(LibraryFilterStorageKey)
?.let { runCatching { LibraryFilter.valueOf(it) }.getOrNull() }
?: LibraryFilter.MyLibrary
internal actual suspend fun saveLibraryFilter(filter: LibraryFilter) {
window.localStorage.setItem(LibraryFilterStorageKey, filter.name)
}
actual suspend fun loadAppLocaleTag(): String? = actual suspend fun loadAppLocaleTag(): String? =
window.localStorage.getItem(AppLocaleStorageKey)?.takeIf { it.isNotBlank() } window.localStorage.getItem(AppLocaleStorageKey)?.takeIf { it.isNotBlank() }
@ -121,7 +137,9 @@ 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 LibraryFilterStorageKey = "toread.libraryFilter"
private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings" private const val ReaderFontSettingsStorageKey = "toread.readerFontSettings"
private const val ReaderFullScreenStorageKey = "toread.readerFullScreen"
actual fun formatLibraryLastReadTime(millis: Long): String { actual fun formatLibraryLastReadTime(millis: Long): String {
val totalMinutes = millis / 60_000L val totalMinutes = millis / 60_000L

View File

@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.11.2" agp = "8.13.2"
android-compileSdk = "36" android-compileSdk = "36"
android-minSdk = "26" android-minSdk = "26"
android-targetSdk = "36" android-targetSdk = "36"