Compare commits
No commits in common. "26343cb8a1f5aeaacda60682442656243d2db255" and "1239f15836e3ddfccd254ab2e242a527c6ed3ec3" have entirely different histories.
26343cb8a1
...
1239f15836
@ -16,9 +16,6 @@ import androidx.compose.ui.graphics.ImageBitmap
|
|||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import net.sergeych.toread.fb2.Fb2Binary
|
import net.sergeych.toread.fb2.Fb2Binary
|
||||||
import net.sergeych.toread.fb2.Fb2Book
|
|
||||||
import net.sergeych.toread.fb2.Fb2Format
|
|
||||||
import net.sergeych.toread.storage.BookRecord
|
|
||||||
import net.sergeych.toread.storage.BookReadingStatus
|
import net.sergeych.toread.storage.BookReadingStatus
|
||||||
import net.sergeych.toread.storage.ContentAnchor
|
import net.sergeych.toread.storage.ContentAnchor
|
||||||
import net.sergeych.toread.storage.LibraryFileRecord
|
import net.sergeych.toread.storage.LibraryFileRecord
|
||||||
@ -227,45 +224,6 @@ actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun refreshLibraryItemFromParsedBook(fileId: String, book: Fb2Book): LibraryItem? = withContext(Dispatchers.IO) {
|
|
||||||
openLibraryDatabase().useLibrary { db ->
|
|
||||||
db.refreshBookCardFromParsedBook(fileId, book).item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun rescanAllLibraryBooks(): LibraryRescanReport = withContext(Dispatchers.IO) {
|
|
||||||
appendLibraryLog("rescan all library requested")
|
|
||||||
openLibraryDatabase().useLibrary { db ->
|
|
||||||
var scanned = 0
|
|
||||||
var updated = 0
|
|
||||||
var failed = 0
|
|
||||||
db.files.list(Int.MAX_VALUE, 0).forEach { file ->
|
|
||||||
val bytes = file.storageUri?.let(::readStorageUriBytes)
|
|
||||||
if (bytes == null) {
|
|
||||||
failed += 1
|
|
||||||
appendLibraryLog("rescan missing fileId=${file.id} uri=${file.storageUri}")
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
scanned += 1
|
|
||||||
val parsed = runCatching {
|
|
||||||
Fb2Format.parse(bytes, file.originalFilename ?: file.storageUri ?: file.id)
|
|
||||||
}
|
|
||||||
parsed
|
|
||||||
.onSuccess {
|
|
||||||
if (db.refreshBookCardFromParsedBook(file.id, it).updated) {
|
|
||||||
updated += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onFailure {
|
|
||||||
failed += 1
|
|
||||||
appendLibraryLog("rescan failed fileId=${file.id} error=${it.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
appendLibraryLog("rescan all library finished scanned=$scanned updated=$updated failed=$failed")
|
|
||||||
LibraryRescanReport(scannedFiles = scanned, updatedFiles = updated, failedFiles = failed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
|
actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
appendLibraryLog("delete fileId=$fileId")
|
appendLibraryLog("delete fileId=$fileId")
|
||||||
openLibraryDatabase().useLibrary { db ->
|
openLibraryDatabase().useLibrary { db ->
|
||||||
@ -310,37 +268,6 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
val shareFile = openLibraryDatabase().useLibrary { db ->
|
|
||||||
val file = db.files.getLibraryFile(fileId) ?: return@useLibrary null
|
|
||||||
val bytes = readStorageUriBytes(file.storageUri ?: return@useLibrary null) ?: return@useLibrary null
|
|
||||||
val shareDir = File(appContext.cacheDir, "shared-books").also { it.mkdirs() }
|
|
||||||
File(shareDir, file.shareFileName()).also { it.writeBytes(bytes) }
|
|
||||||
} ?: return@withContext false
|
|
||||||
|
|
||||||
val uri = FileProvider.getUriForFile(
|
|
||||||
appContext,
|
|
||||||
"${appContext.packageName}.imageviewer.fileprovider",
|
|
||||||
shareFile,
|
|
||||||
)
|
|
||||||
val mimeType = shareFile.bookMimeType()
|
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
type = mimeType
|
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
val chooser = Intent.createChooser(intent, "Share book").apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
appContext.startActivity(chooser)
|
|
||||||
true
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false
|
|
||||||
|
|
||||||
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
|
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
|
||||||
openLibraryDatabase().useLibrary { db ->
|
openLibraryDatabase().useLibrary { db ->
|
||||||
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
|
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
|
||||||
@ -601,26 +528,6 @@ private fun String.requiresExternalFileAccess(): Boolean {
|
|||||||
private fun String.isSupportedBookFile(): Boolean =
|
private fun String.isSupportedBookFile(): Boolean =
|
||||||
endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true)
|
endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true)
|
||||||
|
|
||||||
private fun LibraryFileRecord.shareFileName(): String {
|
|
||||||
val raw = originalFilename?.takeIf { it.isNotBlank() }
|
|
||||||
?: title?.takeIf { it.isNotBlank() }?.let { title ->
|
|
||||||
val extension = when {
|
|
||||||
format.equals("fb2.zip", ignoreCase = true) || storageUri?.endsWith(".fb2.zip", ignoreCase = true) == true -> ".fb2.zip"
|
|
||||||
else -> ".fb2"
|
|
||||||
}
|
|
||||||
"$title$extension"
|
|
||||||
}
|
|
||||||
?: "book.fb2"
|
|
||||||
return raw.replace(Regex("""[\\/:*?"<>|]+"""), "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun File.bookMimeType(): String =
|
|
||||||
if (name.endsWith(".zip", ignoreCase = true)) {
|
|
||||||
"application/zip"
|
|
||||||
} else {
|
|
||||||
"application/x-fictionbook+xml"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun displayNameFor(uri: Uri): String =
|
private fun displayNameFor(uri: Uri): String =
|
||||||
appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||||
@ -675,58 +582,6 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
|
|||||||
importedAt = importedAt,
|
importedAt = importedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ParsedBookCover(
|
|
||||||
val bytes: ByteArray,
|
|
||||||
val mimeType: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class BookCardRefresh(
|
|
||||||
val updated: Boolean,
|
|
||||||
val item: LibraryItem?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun H2LibraryDatabase.refreshBookCardFromParsedBook(fileId: String, book: Fb2Book): BookCardRefresh {
|
|
||||||
val file = files.get(fileId) ?: return BookCardRefresh(updated = false, item = null)
|
|
||||||
val bookId = file.bookId ?: return BookCardRefresh(updated = false, item = null)
|
|
||||||
val stored = books.get(bookId) ?: return BookCardRefresh(updated = false, item = null)
|
|
||||||
val cover = book.libraryCardCover()
|
|
||||||
val next = stored.copy(
|
|
||||||
title = book.title.ifBlank { file.originalFilename?.substringBeforeLast('.') ?: stored.title.orEmpty() },
|
|
||||||
authors = book.authors.mapNotNull { it.displayName.takeIf(String::isNotBlank) },
|
|
||||||
language = book.language,
|
|
||||||
date = book.date,
|
|
||||||
description = book.annotation,
|
|
||||||
keywords = book.keywords,
|
|
||||||
coverImage = cover?.bytes,
|
|
||||||
coverImageMimeType = cover?.mimeType,
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
val updated = !stored.hasSameCardMetadata(next)
|
|
||||||
if (updated) {
|
|
||||||
appendLibraryLog("refresh book card fileId=$fileId bookId=$bookId title=${next.title}")
|
|
||||||
books.upsert(next)
|
|
||||||
}
|
|
||||||
return BookCardRefresh(updated = updated, item = files.getLibraryFile(fileId)?.toLibraryItem())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Fb2Book.libraryCardCover(): ParsedBookCover? {
|
|
||||||
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
|
|
||||||
val binary = image?.let(::binaryFor) ?: return null
|
|
||||||
return runCatching {
|
|
||||||
ParsedBookCover(bytes = binary.imageBytes(), mimeType = binary.contentType)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun BookRecord.hasSameCardMetadata(other: BookRecord): Boolean =
|
|
||||||
title == other.title &&
|
|
||||||
authors == other.authors &&
|
|
||||||
language == other.language &&
|
|
||||||
date == other.date &&
|
|
||||||
description == other.description &&
|
|
||||||
keywords == other.keywords &&
|
|
||||||
coverImage.contentEquals(other.coverImage) &&
|
|
||||||
coverImageMimeType == other.coverImageMimeType
|
|
||||||
|
|
||||||
private fun libraryLogFile(): File =
|
private fun libraryLogFile(): File =
|
||||||
File(appContext.filesDir, "logs/toread.log")
|
File(appContext.filesDir, "logs/toread.log")
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
package net.sergeych.toread
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal actual fun PlatformBackHandler(
|
|
||||||
enabled: Boolean,
|
|
||||||
navigationDepth: Int,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
BackHandler(enabled = enabled, onBack = onBack)
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<cache-path name="clipboard_images" path="clipboard-images/"/>
|
<cache-path name="clipboard_images" path="clipboard-images/"/>
|
||||||
<cache-path name="shared_books" path="shared-books/"/>
|
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
@ -103,41 +103,6 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
|||||||
state = loadStartupState()
|
state = loadStartupState()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun navigateBack() {
|
|
||||||
imageViewer?.let {
|
|
||||||
imageViewer = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
state = when (val current = state) {
|
|
||||||
is AppState.BookInfo -> AppState.Reader(
|
|
||||||
fileId = current.fileId,
|
|
||||||
book = current.book,
|
|
||||||
libraryItems = current.libraryItems,
|
|
||||||
scanPath = current.scanPath,
|
|
||||||
message = current.message,
|
|
||||||
)
|
|
||||||
is AppState.Reader -> {
|
|
||||||
scope.launch { saveActiveReadingFileId(null) }
|
|
||||||
AppState.Library(current.libraryItems, current.scanPath, current.message)
|
|
||||||
}
|
|
||||||
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
|
|
||||||
is AppState.Error -> AppState.LoadingLibrary
|
|
||||||
is AppState.Library, AppState.LoadingLibrary -> current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val navigationDepth = when (state) {
|
|
||||||
is AppState.BookInfo -> 2
|
|
||||||
is AppState.Error, is AppState.Reader, is AppState.Scan -> 1
|
|
||||||
is AppState.Library, AppState.LoadingLibrary -> 0
|
|
||||||
} + if (imageViewer != null) 1 else 0
|
|
||||||
|
|
||||||
PlatformBackHandler(
|
|
||||||
enabled = navigationDepth > 0,
|
|
||||||
navigationDepth = navigationDepth,
|
|
||||||
onBack = ::navigateBack,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun startScan(path: String) {
|
fun startScan(path: String) {
|
||||||
if (scanJob?.isActive == true) return
|
if (scanJob?.isActive == true) return
|
||||||
activeScan = LibraryScanProgress(0, 0, 0, 0)
|
activeScan = LibraryScanProgress(0, 0, 0, 0)
|
||||||
@ -161,17 +126,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
|||||||
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, is AppState.Scan, AppState.LoadingLibrary -> loadLibraryState(message, path)
|
||||||
loadLibraryState(message, path)
|
|
||||||
} else {
|
|
||||||
current.copy(scanPath = path, message = message)
|
|
||||||
}
|
|
||||||
is AppState.Scan -> if (report.getOrNull()?.hasLibraryChanges() == true) {
|
|
||||||
loadLibraryState(message, path)
|
|
||||||
} else {
|
|
||||||
AppState.Library(current.items, path, message)
|
|
||||||
}
|
|
||||||
AppState.LoadingLibrary -> 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)
|
||||||
is AppState.Error -> current
|
is AppState.Error -> current
|
||||||
@ -211,24 +166,31 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
|||||||
message = current.message,
|
message = current.message,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onDeleted = { message ->
|
onBack = {
|
||||||
state = AppState.Library(emptyList(), current.scanPath, message)
|
state = AppState.Library(emptyList(), current.scanPath, current.message)
|
||||||
},
|
},
|
||||||
onBack = ::navigateBack,
|
|
||||||
)
|
)
|
||||||
is AppState.BookInfo -> BookInfoScreen(
|
is AppState.BookInfo -> BookInfoScreen(
|
||||||
fileId = current.fileId,
|
fileId = current.fileId,
|
||||||
book = current.book,
|
book = current.book,
|
||||||
onImageOpen = { imageViewer = it },
|
onImageOpen = { imageViewer = it },
|
||||||
onBack = ::navigateBack,
|
onBack = {
|
||||||
|
state = AppState.Reader(
|
||||||
|
fileId = current.fileId,
|
||||||
|
book = current.book,
|
||||||
|
libraryItems = current.libraryItems,
|
||||||
|
scanPath = current.scanPath,
|
||||||
|
message = current.message,
|
||||||
)
|
)
|
||||||
is AppState.Error -> ErrorScreen(current.message, onBack = ::navigateBack)
|
},
|
||||||
|
)
|
||||||
|
is AppState.Error -> ErrorScreen(current.message, onBack = { state = AppState.LoadingLibrary })
|
||||||
}
|
}
|
||||||
|
|
||||||
imageViewer?.let { image ->
|
imageViewer?.let { image ->
|
||||||
ImageViewer(
|
ImageViewer(
|
||||||
image = image,
|
image = image,
|
||||||
onBack = ::navigateBack,
|
onBack = { imageViewer = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -239,6 +201,3 @@ internal data class ViewedBookImage(
|
|||||||
val mimeType: String,
|
val mimeType: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun LibraryScanReport.hasLibraryChanges(): Boolean =
|
|
||||||
importedFiles > 0
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -57,7 +58,9 @@ internal fun BookInfoScreen(
|
|||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to reader")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to reader")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = themedTopAppBarColors(),
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@ -104,7 +105,7 @@ internal fun ImageViewer(
|
|||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
ThemedTopBarSurface {
|
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
package net.sergeych.toread
|
|
||||||
|
|
||||||
internal data class LibraryDeleteResult(
|
|
||||||
val deleted: Boolean,
|
|
||||||
val message: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal suspend fun deleteLibraryBook(fileId: String, title: String): LibraryDeleteResult {
|
|
||||||
val deleted = runCatching { deleteLibraryItem(fileId) }.getOrDefault(false)
|
|
||||||
return LibraryDeleteResult(
|
|
||||||
deleted = deleted,
|
|
||||||
message = if (deleted) "Removed $title." else "Could not remove $title.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -44,12 +44,6 @@ data class LibraryScanProgress(
|
|||||||
val totalFiles: Int? = null,
|
val totalFiles: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class LibraryRescanReport(
|
|
||||||
val scannedFiles: Int,
|
|
||||||
val updatedFiles: Int,
|
|
||||||
val failedFiles: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PlatformOpenBookRequest(
|
data class PlatformOpenBookRequest(
|
||||||
val id: String,
|
val id: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
@ -106,10 +100,6 @@ expect suspend fun scanLibrarySubtree(path: String, onProgress: (LibraryScanProg
|
|||||||
|
|
||||||
expect suspend fun openLibraryBook(fileId: String): ByteArray?
|
expect suspend fun openLibraryBook(fileId: String): ByteArray?
|
||||||
|
|
||||||
expect suspend fun refreshLibraryItemFromParsedBook(fileId: String, book: Fb2Book): LibraryItem?
|
|
||||||
|
|
||||||
expect suspend fun rescanAllLibraryBooks(): LibraryRescanReport
|
|
||||||
|
|
||||||
expect suspend fun deleteLibraryItem(fileId: String): Boolean
|
expect suspend fun deleteLibraryItem(fileId: String): Boolean
|
||||||
|
|
||||||
expect suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
|
expect suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
|
||||||
@ -118,10 +108,6 @@ expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
|
|||||||
|
|
||||||
expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean
|
expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean
|
||||||
|
|
||||||
expect suspend fun shareLibraryBookFile(fileId: String): Boolean
|
|
||||||
|
|
||||||
expect suspend fun viewLibraryBookFile(fileId: String): Boolean
|
|
||||||
|
|
||||||
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
|
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
|
||||||
|
|
||||||
expect suspend fun loadActiveReadingFileId(): String?
|
expect suspend fun loadActiveReadingFileId(): String?
|
||||||
|
|||||||
@ -17,16 +17,13 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
@ -39,9 +36,12 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -53,7 +53,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
@ -61,7 +60,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
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.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import net.sergeych.toread.fb2.Fb2Format
|
import net.sergeych.toread.fb2.Fb2Format
|
||||||
@ -93,9 +91,6 @@ internal fun LibraryScreen(
|
|||||||
var searchText by remember { mutableStateOf("") }
|
var searchText by remember { mutableStateOf("") }
|
||||||
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 readingNowCollapsed by remember { mutableStateOf(false) }
|
|
||||||
var myLibraryCollapsed by remember { mutableStateOf(false) }
|
|
||||||
var notInterestedCollapsed by remember { mutableStateOf(false) }
|
|
||||||
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
|
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
|
||||||
val searchActive = searchText.isNotBlank()
|
val searchActive = searchText.isNotBlank()
|
||||||
val visibleItems = if (searchActive) searchResults else items
|
val visibleItems = if (searchActive) searchResults else items
|
||||||
@ -103,28 +98,18 @@ internal fun LibraryScreen(
|
|||||||
suspend fun loadPage(reset: Boolean = false) {
|
suspend fun loadPage(reset: Boolean = false) {
|
||||||
if (loadingPage) return
|
if (loadingPage) return
|
||||||
loadingPage = true
|
loadingPage = true
|
||||||
val previousItems = items
|
|
||||||
if (reset) {
|
if (reset) {
|
||||||
|
items = emptyList()
|
||||||
nextOffset = 0
|
nextOffset = 0
|
||||||
endReached = false
|
endReached = false
|
||||||
|
coverCache.clear()
|
||||||
}
|
}
|
||||||
val offset = if (reset) 0 else nextOffset
|
val offset = if (reset) 0 else nextOffset
|
||||||
try {
|
try {
|
||||||
val limit = if (reset) maxOf(LibraryPageSize, previousItems.size) else LibraryPageSize
|
val page = loadLibraryItemsPage(LibraryPageSize, offset)
|
||||||
val page = loadLibraryItemsPage(limit, offset)
|
items = if (reset) page else items + page
|
||||||
if (reset) {
|
|
||||||
if (page != previousItems) {
|
|
||||||
items = page
|
|
||||||
}
|
|
||||||
val visibleFileIds = page.mapTo(mutableSetOf()) { it.fileId }
|
|
||||||
coverCache.keys.toList().forEach { fileId ->
|
|
||||||
if (fileId !in visibleFileIds) coverCache.remove(fileId)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
items = items + page
|
|
||||||
}
|
|
||||||
nextOffset = offset + page.size
|
nextOffset = offset + page.size
|
||||||
endReached = page.size < limit
|
endReached = page.size < LibraryPageSize
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
message = t.message ?: "Could not load library."
|
message = t.message ?: "Could not load library."
|
||||||
endReached = true
|
endReached = true
|
||||||
@ -146,24 +131,6 @@ internal fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun rescanAllLibrary() {
|
|
||||||
settingsMenuOpen = false
|
|
||||||
scope.launch {
|
|
||||||
busy = true
|
|
||||||
message = "Rescanning library..."
|
|
||||||
try {
|
|
||||||
val report = rescanAllLibraryBooks()
|
|
||||||
refresh(
|
|
||||||
"Rescanned ${report.scannedFiles}, updated ${report.updatedFiles}, failed ${report.failedFiles}.",
|
|
||||||
)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
message = t.message ?: "Library rescan failed."
|
|
||||||
} finally {
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearSearch() {
|
fun clearSearch() {
|
||||||
searchText = ""
|
searchText = ""
|
||||||
searchResults = emptyList()
|
searchResults = emptyList()
|
||||||
@ -238,19 +205,18 @@ internal fun LibraryScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
colors = themedTopAppBarColors(),
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = "Refresh library")
|
||||||
|
}
|
||||||
Box {
|
Box {
|
||||||
IconButton(onClick = { settingsMenuOpen = true }) {
|
IconButton(onClick = { settingsMenuOpen = true }) {
|
||||||
Icon(Icons.Filled.MoreVert, contentDescription = "Library options")
|
Icon(Icons.Filled.MoreVert, contentDescription = "Library options")
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) {
|
DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) {
|
||||||
// DropdownMenuItem(
|
|
||||||
// text = { Text("Rescan all library") },
|
|
||||||
// enabled = !busy && activeScan == null,
|
|
||||||
// onClick = ::rescanAllLibrary,
|
|
||||||
// )
|
|
||||||
// HorizontalDivider()
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Checkbox(checked = autoScanDownloads, onCheckedChange = null)
|
Checkbox(checked = autoScanDownloads, onCheckedChange = null)
|
||||||
@ -305,7 +271,24 @@ internal fun LibraryScreen(
|
|||||||
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
|
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
fun itemActions(item: LibraryItem) = LibraryItemActions(
|
val hasReadingNow = !searchActive && visibleItems.firstOrNull()?.readingStatus == BookReadingStatus.READING
|
||||||
|
if (hasReadingNow) {
|
||||||
|
item(key = "section-reading") {
|
||||||
|
LibrarySectionHeader("reading now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemsIndexed(visibleItems, key = { _, item -> item.fileId }) { index, item ->
|
||||||
|
if (
|
||||||
|
hasReadingNow &&
|
||||||
|
item.readingStatus != BookReadingStatus.READING &&
|
||||||
|
(index == 0 || visibleItems[index - 1].readingStatus == BookReadingStatus.READING)
|
||||||
|
) {
|
||||||
|
LibrarySectionHeader("my library")
|
||||||
|
}
|
||||||
|
LibraryRow(
|
||||||
|
item = item,
|
||||||
|
coverCache = coverCache,
|
||||||
|
enabled = !busy,
|
||||||
onOpen = {
|
onOpen = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
busy = true
|
busy = true
|
||||||
@ -313,19 +296,12 @@ internal fun LibraryScreen(
|
|||||||
val next = runCatching {
|
val next = runCatching {
|
||||||
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
|
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
|
||||||
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
|
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
|
||||||
var readerLibraryItems = visibleItems
|
|
||||||
refreshLibraryItemFromParsedBook(item.fileId, book)?.let { updatedItem ->
|
|
||||||
items = items.replaceLibraryItem(updatedItem)
|
|
||||||
searchResults = searchResults.replaceLibraryItem(updatedItem)
|
|
||||||
readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem)
|
|
||||||
coverCache[updatedItem.fileId] = loadLibraryItemCover(updatedItem.fileId)
|
|
||||||
}
|
|
||||||
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
|
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
|
||||||
saveActiveReadingFileId(item.fileId)
|
saveActiveReadingFileId(item.fileId)
|
||||||
AppState.Reader(
|
AppState.Reader(
|
||||||
fileId = item.fileId,
|
fileId = item.fileId,
|
||||||
book = book,
|
book = book,
|
||||||
libraryItems = readerLibraryItems,
|
libraryItems = visibleItems,
|
||||||
scanPath = state.scanPath,
|
scanPath = state.scanPath,
|
||||||
message = message,
|
message = message,
|
||||||
)
|
)
|
||||||
@ -368,27 +344,12 @@ internal fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRemoveMarks = {
|
|
||||||
scope.launch {
|
|
||||||
busy = true
|
|
||||||
try {
|
|
||||||
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
|
|
||||||
message = "Removed marks from ${item.title}."
|
|
||||||
refresh()
|
|
||||||
} else {
|
|
||||||
message = "Could not update ${item.title}."
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNotInterested = {
|
onNotInterested = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
busy = true
|
busy = true
|
||||||
try {
|
try {
|
||||||
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
|
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
|
||||||
message = "Marked ${item.title} as not interested."
|
message = "Marked ${item.title} as not interesting."
|
||||||
refresh()
|
refresh()
|
||||||
} else {
|
} else {
|
||||||
message = "Could not update ${item.title}."
|
message = "Could not update ${item.title}."
|
||||||
@ -402,9 +363,9 @@ internal fun LibraryScreen(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
busy = true
|
busy = true
|
||||||
try {
|
try {
|
||||||
val result = deleteLibraryBook(item.fileId, item.title)
|
val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false)
|
||||||
message = result.message
|
message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}."
|
||||||
if (result.deleted) {
|
if (deleted) {
|
||||||
items = items.filterNot { it.fileId == item.fileId }
|
items = items.filterNot { it.fileId == item.fileId }
|
||||||
searchResults = searchResults.filterNot { it.fileId == item.fileId }
|
searchResults = searchResults.filterNot { it.fileId == item.fileId }
|
||||||
coverCache.remove(item.fileId)
|
coverCache.remove(item.fileId)
|
||||||
@ -416,52 +377,6 @@ internal fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
fun LazyListScope.libraryRows(sectionKey: String, sectionItems: List<LibraryItem>) {
|
|
||||||
itemsIndexed(sectionItems, key = { _, item -> "$sectionKey-${item.fileId}" }) { _, item ->
|
|
||||||
LibraryRow(
|
|
||||||
item = item,
|
|
||||||
coverCache = coverCache,
|
|
||||||
enabled = !busy,
|
|
||||||
actions = itemActions(item),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchActive) {
|
|
||||||
libraryRows("search", visibleItems)
|
|
||||||
} else {
|
|
||||||
val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING }
|
|
||||||
val myLibrary = visibleItems.filter { it.readingStatus != BookReadingStatus.READING && it.readingStatus != BookReadingStatus.NOT_INTERESTED }
|
|
||||||
val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED }
|
|
||||||
|
|
||||||
librarySection(
|
|
||||||
key = "reading",
|
|
||||||
title = "reading now",
|
|
||||||
count = readingNow.size,
|
|
||||||
collapsed = readingNowCollapsed,
|
|
||||||
onCollapsedChange = { readingNowCollapsed = it },
|
|
||||||
) {
|
|
||||||
libraryRows("reading", readingNow)
|
|
||||||
}
|
|
||||||
librarySection(
|
|
||||||
key = "library",
|
|
||||||
title = "my library",
|
|
||||||
count = myLibrary.size,
|
|
||||||
collapsed = myLibraryCollapsed,
|
|
||||||
onCollapsedChange = { myLibraryCollapsed = it },
|
|
||||||
) {
|
|
||||||
libraryRows("library", myLibrary)
|
|
||||||
}
|
|
||||||
librarySection(
|
|
||||||
key = "not-interested",
|
|
||||||
title = "not interested",
|
|
||||||
count = notInterested.size,
|
|
||||||
collapsed = notInterestedCollapsed,
|
|
||||||
onCollapsedChange = { notInterestedCollapsed = it },
|
|
||||||
) {
|
|
||||||
libraryRows("not-interested", notInterested)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!searchActive && !endReached) {
|
if (!searchActive && !endReached) {
|
||||||
item(key = "load-more") {
|
item(key = "load-more") {
|
||||||
@ -497,13 +412,14 @@ private fun LibrarySearchField(
|
|||||||
onClear: () -> Unit,
|
onClear: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val shape = RoundedCornerShape(16.dp)
|
val shape = RoundedCornerShape(18.dp)
|
||||||
Row(
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.height(42.dp)
|
.height(56.dp)
|
||||||
.clip(shape)
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && value.isNotBlank()) {
|
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && value.isNotBlank()) {
|
||||||
onClear()
|
onClear()
|
||||||
@ -512,43 +428,38 @@ private fun LibrarySearchField(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
placeholder = {
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Search,
|
|
||||||
contentDescription = "Search library",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(start = 8.dp, end = 6.dp),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
singleLine = true,
|
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
if (value.isBlank()) {
|
|
||||||
Text(
|
Text(
|
||||||
"Search library",
|
"Search library",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.72f),
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.72f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
}
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Search,
|
||||||
|
contentDescription = "Search library",
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
if (value.isNotBlank()) {
|
if (value.isNotBlank()) {
|
||||||
IconButton(onClick = onClear, modifier = Modifier.size(30.dp)) {
|
IconButton(onClick = onClear, modifier = Modifier.size(34.dp)) {
|
||||||
Icon(Icons.Filled.Close, contentDescription = "Clear search", modifier = Modifier.size(17.dp))
|
Icon(Icons.Filled.Close, contentDescription = "Clear search", modifier = Modifier.size(18.dp))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
shape = shape,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f),
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
cursorColor = MaterialTheme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -570,57 +481,16 @@ private fun EmptySearchPane(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun LazyListScope.librarySection(
|
|
||||||
key: String,
|
|
||||||
title: String,
|
|
||||||
count: Int,
|
|
||||||
collapsed: Boolean,
|
|
||||||
onCollapsedChange: (Boolean) -> Unit,
|
|
||||||
content: LazyListScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
if (count == 0) return
|
|
||||||
item(key = "section-$key") {
|
|
||||||
LibrarySectionHeader(
|
|
||||||
text = title,
|
|
||||||
count = count,
|
|
||||||
collapsed = collapsed,
|
|
||||||
onToggle = { onCollapsedChange(!collapsed) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!collapsed) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LibrarySectionHeader(
|
private fun LibrarySectionHeader(text: String) {
|
||||||
text: String,
|
|
||||||
count: Int,
|
|
||||||
collapsed: Boolean,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onToggle)
|
|
||||||
.padding(top = 10.dp, bottom = 4.dp, start = 8.dp, end = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
if (collapsed) Icons.AutoMirrored.Filled.KeyboardArrowRight else Icons.Filled.KeyboardArrowDown,
|
|
||||||
contentDescription = if (collapsed) "Expand $text" else "Collapse $text",
|
|
||||||
tint = MaterialTheme.colorScheme.outline,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
"$text ($count)",
|
text,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.outline,
|
color = MaterialTheme.colorScheme.outline,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LibraryScanStatusPanel(progress: LibraryScanProgress, modifier: Modifier = Modifier) {
|
private fun LibraryScanStatusPanel(progress: LibraryScanProgress, modifier: Modifier = Modifier) {
|
||||||
@ -657,7 +527,11 @@ private fun LibraryRow(
|
|||||||
item: LibraryItem,
|
item: LibraryItem,
|
||||||
coverCache: MutableMap<String, LibraryCover?>,
|
coverCache: MutableMap<String, LibraryCover?>,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
actions: LibraryItemActions,
|
onOpen: () -> Unit,
|
||||||
|
onMarkAsRead: () -> Unit,
|
||||||
|
onMarkAsUnread: () -> Unit,
|
||||||
|
onNotInterested: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var menuOpen by remember { mutableStateOf(false) }
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -665,7 +539,7 @@ private fun LibraryRow(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(enabled = enabled, onClick = actions.onOpen)
|
.clickable(enabled = enabled, onClick = onOpen)
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@ -677,14 +551,12 @@ private fun LibraryRow(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
item.authors.joinToString().ifBlank { "Unknown author" },
|
item.authors.joinToString().ifBlank { "Unknown author" },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
item.libraryMetadataLine(),
|
item.libraryMetadataLine(),
|
||||||
@ -702,7 +574,7 @@ private fun LibraryRow(
|
|||||||
text = { Text("Open") },
|
text = { Text("Open") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
actions.onOpen()
|
onOpen()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
@ -711,7 +583,7 @@ private fun LibraryRow(
|
|||||||
text = { Text("Mark as read") },
|
text = { Text("Mark as read") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
actions.onMarkAsRead()
|
onMarkAsRead()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -720,24 +592,15 @@ private fun LibraryRow(
|
|||||||
text = { Text("Mark as unread") },
|
text = { Text("Mark as unread") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
actions.onMarkAsUnread()
|
onMarkAsUnread()
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (item.readingStatus != BookReadingStatus.NEW) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Remove marks") },
|
|
||||||
onClick = {
|
|
||||||
menuOpen = false
|
|
||||||
actions.onRemoveMarks()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Not interested") },
|
text = { Text("Not interesting") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
actions.onNotInterested()
|
onNotInterested()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
@ -745,7 +608,7 @@ private fun LibraryRow(
|
|||||||
text = { Text("Delete") },
|
text = { Text("Delete") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
actions.onDelete()
|
onDelete()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -754,15 +617,6 @@ private fun LibraryRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class LibraryItemActions(
|
|
||||||
val onOpen: () -> Unit,
|
|
||||||
val onMarkAsRead: () -> Unit,
|
|
||||||
val onMarkAsUnread: () -> Unit,
|
|
||||||
val onRemoveMarks: () -> Unit,
|
|
||||||
val onNotInterested: () -> Unit,
|
|
||||||
val onDelete: () -> Unit,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LibraryCover(
|
private fun LibraryCover(
|
||||||
item: LibraryItem,
|
item: LibraryItem,
|
||||||
@ -831,9 +685,6 @@ private fun Long.formatBytes(): String =
|
|||||||
else -> "$this B"
|
else -> "$this B"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<LibraryItem>.replaceLibraryItem(item: LibraryItem): List<LibraryItem> =
|
|
||||||
map { current -> if (current.fileId == item.fileId) item else current }
|
|
||||||
|
|
||||||
private fun LibraryScanProgress.toCatalogScanMessage(): String {
|
private fun LibraryScanProgress.toCatalogScanMessage(): String {
|
||||||
val total = totalFiles ?: return "Scanned $scannedFiles books"
|
val total = totalFiles ?: return "Scanned $scannedFiles books"
|
||||||
val percent = if (total <= 0) {
|
val percent = if (total <= 0) {
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
package net.sergeych.toread
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal expect fun PlatformBackHandler(
|
|
||||||
enabled: Boolean,
|
|
||||||
navigationDepth: Int,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
)
|
|
||||||
@ -3,10 +3,6 @@ package net.sergeych.toread
|
|||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
|
||||||
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
|
||||||
@ -34,7 +30,6 @@ import androidx.compose.runtime.Composable
|
|||||||
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
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -43,9 +38,6 @@ import androidx.compose.ui.draw.drawWithContent
|
|||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
|
||||||
import androidx.compose.ui.input.pointer.PointerType
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
@ -75,7 +67,6 @@ 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.launch
|
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
@ -88,29 +79,16 @@ internal fun ContinuousBookReader(
|
|||||||
onImageOpen: (ViewedBookImage) -> Unit = {},
|
onImageOpen: (ViewedBookImage) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val hyphenation = remember { HyphenationRegistry() }
|
val hyphenation = remember { HyphenationRegistry() }
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val contentPadding = if (isAndroidPlatform()) {
|
val contentPadding = if (isAndroidPlatform()) {
|
||||||
PaddingValues(start = 0.dp, top = 6.dp, end = 0.dp, bottom = 6.dp)
|
PaddingValues(start = 6.dp, top = 6.dp, end = 0.dp, bottom = 6.dp)
|
||||||
} else {
|
} else {
|
||||||
PaddingValues(horizontal = 4.dp, vertical = 6.dp)
|
PaddingValues(horizontal = 8.dp, vertical = 6.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
.pageTurnOnTouchTap(
|
|
||||||
onPageDown = {
|
|
||||||
scope.launch {
|
|
||||||
listState.animateScrollBy(listState.pageScrollDistance())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPageUp = {
|
|
||||||
scope.launch {
|
|
||||||
listState.animateScrollBy(-listState.pageScrollDistance())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
@ -138,34 +116,6 @@ internal fun ContinuousBookReader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Modifier.pageTurnOnTouchTap(
|
|
||||||
onPageDown: () -> Unit,
|
|
||||||
onPageUp: () -> Unit,
|
|
||||||
): Modifier = pointerInput(onPageDown, onPageUp) {
|
|
||||||
awaitEachGesture {
|
|
||||||
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final)
|
|
||||||
if (down.type != PointerType.Touch) {
|
|
||||||
waitForUpOrCancellation(pass = PointerEventPass.Final)
|
|
||||||
return@awaitEachGesture
|
|
||||||
}
|
|
||||||
|
|
||||||
val up = waitForUpOrCancellation(pass = PointerEventPass.Final) ?: return@awaitEachGesture
|
|
||||||
if (up.isConsumed) return@awaitEachGesture
|
|
||||||
|
|
||||||
if (down.position.x < size.width / 2f) {
|
|
||||||
onPageDown()
|
|
||||||
} else {
|
|
||||||
onPageUp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun LazyListState.pageScrollDistance(): Float {
|
|
||||||
val layoutInfo = layoutInfo
|
|
||||||
val viewportHeight = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
|
|
||||||
return viewportHeight.toFloat().coerceAtLeast(0f)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun LazyListScope.sectionItems(
|
private fun LazyListScope.sectionItems(
|
||||||
book: Fb2Book,
|
book: Fb2Book,
|
||||||
section: Fb2Section,
|
section: Fb2Section,
|
||||||
@ -461,9 +411,8 @@ private fun ReaderText(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun readerParagraphTextStyle(language: String?): TextStyle =
|
private fun readerParagraphTextStyle(language: String?): TextStyle =
|
||||||
MaterialTheme.typography.bodyLarge.copy(
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
fontWeight = if( isAndroidPlatform()) FontWeight(350) else FontWeight.Normal,
|
fontSize = 18.sp,
|
||||||
fontSize = if( isAndroidPlatform()) 21.sp else 18.sp,
|
lineHeight = 27.sp,
|
||||||
lineHeight = 28.sp,
|
|
||||||
hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified,
|
hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified,
|
||||||
lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified,
|
lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified,
|
||||||
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },
|
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },
|
||||||
|
|||||||
@ -12,19 +12,14 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@ -53,36 +48,13 @@ internal fun BookView(
|
|||||||
onImageOpen: (ViewedBookImage) -> Unit,
|
onImageOpen: (ViewedBookImage) -> Unit,
|
||||||
onThemeToggle: () -> Unit,
|
onThemeToggle: () -> Unit,
|
||||||
onBookInfo: () -> Unit,
|
onBookInfo: () -> Unit,
|
||||||
onDeleted: (String) -> Unit,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val stats = remember(book) { BookStats.from(book) }
|
val stats = remember(book) { BookStats.from(book) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
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) }
|
||||||
val platformName = getPlatform().name
|
|
||||||
val showShareAction = platformName.startsWith("Android")
|
|
||||||
val showViewFileAction = platformName.startsWith("Java")
|
|
||||||
|
|
||||||
fun showMessage(message: String) {
|
|
||||||
scope.launch {
|
|
||||||
snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setReadingStatus(status: BookReadingStatus, successMessage: String) {
|
|
||||||
scope.launch {
|
|
||||||
if (markLibraryReadingStatus(fileId, status)) {
|
|
||||||
if (status == BookReadingStatus.READ) markedRead = true
|
|
||||||
if (status == BookReadingStatus.NEW) markedRead = false
|
|
||||||
showMessage(successMessage)
|
|
||||||
} else {
|
|
||||||
showMessage("Could not update book.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(fileId) {
|
LaunchedEffect(fileId) {
|
||||||
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
|
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
|
||||||
@ -120,7 +92,6 @@ internal fun BookView(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
topBar = {
|
||||||
CompactReaderTopBar(
|
CompactReaderTopBar(
|
||||||
title = book.title,
|
title = book.title,
|
||||||
@ -134,40 +105,6 @@ internal fun BookView(
|
|||||||
onBookInfo()
|
onBookInfo()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMarkAsRead = {
|
|
||||||
setReadingStatus(BookReadingStatus.READ, "Marked as read.")
|
|
||||||
},
|
|
||||||
onNotInterested = {
|
|
||||||
setReadingStatus(BookReadingStatus.NOT_INTERESTED, "Marked as not interested.")
|
|
||||||
},
|
|
||||||
onClearMarks = {
|
|
||||||
setReadingStatus(BookReadingStatus.NEW, "Cleared marks.")
|
|
||||||
},
|
|
||||||
showShareAction = showShareAction,
|
|
||||||
onShare = {
|
|
||||||
scope.launch {
|
|
||||||
val shared = shareLibraryBookFile(fileId)
|
|
||||||
showMessage(if (shared) "Share opened." else "Could not share book.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
showViewFileAction = showViewFileAction,
|
|
||||||
onViewFile = {
|
|
||||||
scope.launch {
|
|
||||||
val opened = viewLibraryBookFile(fileId)
|
|
||||||
showMessage(if (opened) "Opened file location." else "Could not open file location.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDelete = {
|
|
||||||
scope.launch {
|
|
||||||
val result = deleteLibraryBook(fileId, book.title)
|
|
||||||
if (result.deleted) {
|
|
||||||
saveActiveReadingFileId(null)
|
|
||||||
onDeleted(result.message)
|
|
||||||
} else {
|
|
||||||
showMessage(result.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onBack = {
|
onBack = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
saveLibraryReadingPosition(
|
saveLibraryReadingPosition(
|
||||||
@ -203,19 +140,9 @@ private fun CompactReaderTopBar(
|
|||||||
title: String,
|
title: String,
|
||||||
onThemeToggle: () -> Unit,
|
onThemeToggle: () -> Unit,
|
||||||
onBookInfo: () -> Unit,
|
onBookInfo: () -> Unit,
|
||||||
onMarkAsRead: () -> Unit,
|
|
||||||
onNotInterested: () -> Unit,
|
|
||||||
onClearMarks: () -> Unit,
|
|
||||||
showShareAction: Boolean,
|
|
||||||
onShare: () -> Unit,
|
|
||||||
showViewFileAction: Boolean,
|
|
||||||
onViewFile: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var menuOpen by remember { mutableStateOf(false) }
|
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||||
|
|
||||||
ThemedTopBarSurface {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@ -232,74 +159,12 @@ private fun CompactReaderTopBar(
|
|||||||
IconButton(onClick = onThemeToggle) {
|
IconButton(onClick = onThemeToggle) {
|
||||||
Icon(Icons.Filled.Palette, contentDescription = "Theme")
|
Icon(Icons.Filled.Palette, contentDescription = "Theme")
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = onBookInfo) {
|
||||||
|
Icon(Icons.Filled.Info, contentDescription = "Properties")
|
||||||
|
}
|
||||||
IconButton(onClick = { }) {
|
IconButton(onClick = { }) {
|
||||||
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
|
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
|
||||||
}
|
}
|
||||||
Box {
|
|
||||||
IconButton(onClick = { menuOpen = true }) {
|
|
||||||
Icon(Icons.Filled.MoreVert, contentDescription = "Book reader menu")
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Info...") },
|
|
||||||
onClick = {
|
|
||||||
menuOpen = false
|
|
||||||
onBookInfo()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Mark as read") },
|
|
||||||
onClick = {
|
|
||||||
menuOpen = false
|
|
||||||
onMarkAsRead()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Not interested") },
|
|
||||||
onClick = {
|
|
||||||
menuOpen = false
|
|
||||||
onNotInterested()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Clear marks") },
|
|
||||||
onClick = {
|
|
||||||
menuOpen = false
|
|
||||||
onClearMarks()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (showShareAction || showViewFileAction) {
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
if (showShareAction) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Share") },
|
|
||||||
onClick = {
|
|
||||||
menuOpen = false
|
|
||||||
onShare()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showViewFileAction) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("View file") },
|
|
||||||
onClick = {
|
|
||||||
menuOpen = false
|
|
||||||
onViewFile()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Delete") },
|
|
||||||
onClick = {
|
|
||||||
menuOpen = false
|
|
||||||
onDelete()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -61,7 +62,9 @@ internal fun ScanScreen(
|
|||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = themedTopAppBarColors(),
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -3,21 +3,16 @@ package net.sergeych.toread
|
|||||||
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
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBarColors
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -80,30 +75,6 @@ internal fun quietCardColors() = CardDefaults.cardColors(
|
|||||||
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
|
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun ThemedTopBarSurface(content: @Composable ColumnScope.() -> Unit) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
content()
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
internal fun themedTopAppBarColors(): TopAppBarColors =
|
|
||||||
TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background)
|
internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background)
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,6 @@ package net.sergeych.toread
|
|||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
import net.sergeych.toread.fb2.Fb2Binary
|
import net.sergeych.toread.fb2.Fb2Binary
|
||||||
import net.sergeych.toread.fb2.Fb2Book
|
|
||||||
import net.sergeych.toread.fb2.Fb2Format
|
|
||||||
import net.sergeych.toread.storage.BookRecord
|
|
||||||
import net.sergeych.toread.storage.BookReadingStatus
|
import net.sergeych.toread.storage.BookReadingStatus
|
||||||
import net.sergeych.toread.storage.ContentAnchor
|
import net.sergeych.toread.storage.ContentAnchor
|
||||||
import net.sergeych.toread.storage.LibraryFileRecord
|
import net.sergeych.toread.storage.LibraryFileRecord
|
||||||
@ -13,7 +10,6 @@ import net.sergeych.toread.storage.ReadingStateRecord
|
|||||||
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
|
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
|
||||||
import net.sergeych.toread.storage.jdbc.LibraryScanner
|
import net.sergeych.toread.storage.jdbc.LibraryScanner
|
||||||
import org.jetbrains.skia.Image
|
import org.jetbrains.skia.Image
|
||||||
import java.awt.Desktop
|
|
||||||
import java.awt.Toolkit
|
import java.awt.Toolkit
|
||||||
import java.awt.datatransfer.DataFlavor
|
import java.awt.datatransfer.DataFlavor
|
||||||
import java.awt.datatransfer.Transferable
|
import java.awt.datatransfer.Transferable
|
||||||
@ -178,45 +174,6 @@ actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun refreshLibraryItemFromParsedBook(fileId: String, book: Fb2Book): LibraryItem? = withContext(Dispatchers.IO) {
|
|
||||||
openLibraryDatabase().useLibrary { db ->
|
|
||||||
db.refreshBookCardFromParsedBook(fileId, book).item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun rescanAllLibraryBooks(): LibraryRescanReport = withContext(Dispatchers.IO) {
|
|
||||||
appendLibraryLog("rescan all library requested")
|
|
||||||
openLibraryDatabase().useLibrary { db ->
|
|
||||||
var scanned = 0
|
|
||||||
var updated = 0
|
|
||||||
var failed = 0
|
|
||||||
db.files.list(Int.MAX_VALUE, 0).forEach { file ->
|
|
||||||
val bytes = file.storageUri?.let { File(it) }?.takeIf { it.isFile }?.readBytes()
|
|
||||||
if (bytes == null) {
|
|
||||||
failed += 1
|
|
||||||
appendLibraryLog("rescan missing fileId=${file.id} uri=${file.storageUri}")
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
scanned += 1
|
|
||||||
val parsed = runCatching {
|
|
||||||
Fb2Format.parse(bytes, file.originalFilename ?: file.storageUri ?: file.id)
|
|
||||||
}
|
|
||||||
parsed
|
|
||||||
.onSuccess {
|
|
||||||
if (db.refreshBookCardFromParsedBook(file.id, it).updated) {
|
|
||||||
updated += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onFailure {
|
|
||||||
failed += 1
|
|
||||||
appendLibraryLog("rescan failed fileId=${file.id} error=${it.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
appendLibraryLog("rescan all library finished scanned=$scanned updated=$updated failed=$failed")
|
|
||||||
LibraryRescanReport(scannedFiles = scanned, updatedFiles = updated, failedFiles = failed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
|
actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
appendLibraryLog("delete fileId=$fileId")
|
appendLibraryLog("delete fileId=$fileId")
|
||||||
openLibraryDatabase().useLibrary { db ->
|
openLibraryDatabase().useLibrary { db ->
|
||||||
@ -261,24 +218,6 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
|
|
||||||
|
|
||||||
actual suspend fun viewLibraryBookFile(fileId: 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
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
|
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
|
||||||
openLibraryDatabase().useLibrary { db ->
|
openLibraryDatabase().useLibrary { db ->
|
||||||
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
|
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
|
||||||
@ -475,58 +414,6 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
|
|||||||
importedAt = importedAt,
|
importedAt = importedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ParsedBookCover(
|
|
||||||
val bytes: ByteArray,
|
|
||||||
val mimeType: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class BookCardRefresh(
|
|
||||||
val updated: Boolean,
|
|
||||||
val item: LibraryItem?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun H2LibraryDatabase.refreshBookCardFromParsedBook(fileId: String, book: Fb2Book): BookCardRefresh {
|
|
||||||
val file = files.get(fileId) ?: return BookCardRefresh(updated = false, item = null)
|
|
||||||
val bookId = file.bookId ?: return BookCardRefresh(updated = false, item = null)
|
|
||||||
val stored = books.get(bookId) ?: return BookCardRefresh(updated = false, item = null)
|
|
||||||
val cover = book.libraryCardCover()
|
|
||||||
val next = stored.copy(
|
|
||||||
title = book.title.ifBlank { file.originalFilename?.substringBeforeLast('.') ?: stored.title.orEmpty() },
|
|
||||||
authors = book.authors.mapNotNull { it.displayName.takeIf(String::isNotBlank) },
|
|
||||||
language = book.language,
|
|
||||||
date = book.date,
|
|
||||||
description = book.annotation,
|
|
||||||
keywords = book.keywords,
|
|
||||||
coverImage = cover?.bytes,
|
|
||||||
coverImageMimeType = cover?.mimeType,
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
val updated = !stored.hasSameCardMetadata(next)
|
|
||||||
if (updated) {
|
|
||||||
appendLibraryLog("refresh book card fileId=$fileId bookId=$bookId title=${next.title}")
|
|
||||||
books.upsert(next)
|
|
||||||
}
|
|
||||||
return BookCardRefresh(updated = updated, item = files.getLibraryFile(fileId)?.toLibraryItem())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Fb2Book.libraryCardCover(): ParsedBookCover? {
|
|
||||||
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
|
|
||||||
val binary = image?.let(::binaryFor) ?: return null
|
|
||||||
return runCatching {
|
|
||||||
ParsedBookCover(bytes = binary.imageBytes(), mimeType = binary.contentType)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun BookRecord.hasSameCardMetadata(other: BookRecord): Boolean =
|
|
||||||
title == other.title &&
|
|
||||||
authors == other.authors &&
|
|
||||||
language == other.language &&
|
|
||||||
date == other.date &&
|
|
||||||
description == other.description &&
|
|
||||||
keywords == other.keywords &&
|
|
||||||
coverImage.contentEquals(other.coverImage) &&
|
|
||||||
coverImageMimeType == other.coverImageMimeType
|
|
||||||
|
|
||||||
private fun libraryLogFile(): File =
|
private fun libraryLogFile(): File =
|
||||||
File(System.getProperty("user.home"), ".toread/toread.log")
|
File(System.getProperty("user.home"), ".toread/toread.log")
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
package net.sergeych.toread
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal actual fun PlatformBackHandler(
|
|
||||||
enabled: Boolean,
|
|
||||||
navigationDepth: Int,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) = Unit
|
|
||||||
@ -38,11 +38,6 @@ actual suspend fun scanLibrarySubtree(
|
|||||||
|
|
||||||
actual suspend fun openLibraryBook(fileId: String): ByteArray? = null
|
actual suspend fun openLibraryBook(fileId: String): ByteArray? = null
|
||||||
|
|
||||||
actual suspend fun refreshLibraryItemFromParsedBook(fileId: String, book: net.sergeych.toread.fb2.Fb2Book): LibraryItem? = null
|
|
||||||
|
|
||||||
actual suspend fun rescanAllLibraryBooks(): LibraryRescanReport =
|
|
||||||
LibraryRescanReport(scannedFiles = 0, updatedFiles = 0, failedFiles = 0)
|
|
||||||
|
|
||||||
actual suspend fun deleteLibraryItem(fileId: String): Boolean = false
|
actual suspend fun deleteLibraryItem(fileId: String): Boolean = false
|
||||||
|
|
||||||
actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? = null
|
actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? = null
|
||||||
@ -51,10 +46,6 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
|
|||||||
|
|
||||||
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false
|
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false
|
||||||
|
|
||||||
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
|
|
||||||
|
|
||||||
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false
|
|
||||||
|
|
||||||
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
|
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
|
||||||
|
|
||||||
actual suspend fun loadActiveReadingFileId(): String? = null
|
actual suspend fun loadActiveReadingFileId(): String? = null
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
package net.sergeych.toread
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import kotlin.js.ExperimentalWasmJsInterop
|
|
||||||
import kotlinx.browser.window
|
|
||||||
import org.w3c.dom.events.Event
|
|
||||||
|
|
||||||
private var currentBrowserNavigationDepth = 0
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@OptIn(ExperimentalWasmJsInterop::class)
|
|
||||||
internal actual fun PlatformBackHandler(
|
|
||||||
enabled: Boolean,
|
|
||||||
navigationDepth: Int,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val currentOnBack = rememberUpdatedState(onBack)
|
|
||||||
|
|
||||||
LaunchedEffect(navigationDepth) {
|
|
||||||
if (navigationDepth > currentBrowserNavigationDepth) {
|
|
||||||
repeat(navigationDepth - currentBrowserNavigationDepth) {
|
|
||||||
window.history.pushState(null, "", window.location.href)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentBrowserNavigationDepth = navigationDepth
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(enabled) {
|
|
||||||
if (!enabled) {
|
|
||||||
onDispose { }
|
|
||||||
} else {
|
|
||||||
val listener: (Event) -> Unit = {
|
|
||||||
currentOnBack.value()
|
|
||||||
}
|
|
||||||
window.addEventListener("popstate", listener)
|
|
||||||
onDispose {
|
|
||||||
window.removeEventListener("popstate", listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -20,7 +20,7 @@ The common API is `Fb2Format.parse(input: ByteArray, fileName: String? = null)`.
|
|||||||
Import detection:
|
Import detection:
|
||||||
|
|
||||||
- A file is treated as ZIP when its bytes start with the ZIP local-file signature `PK\003\004` or the provided filename ends with `.zip`.
|
- A file is treated as ZIP when its bytes start with the ZIP local-file signature `PK\003\004` or the provided filename ends with `.zip`.
|
||||||
- Otherwise bytes are decoded according to the XML declaration when supported. UTF-8 is the default, and unsupported or missing encodings fall back to UTF-8. `windows-1251` is supported for legacy FB2 files.
|
- Otherwise bytes are decoded as UTF-8 XML.
|
||||||
- In ZIP archives, the first entry ending with `.fb2` is used. If no such entry exists, the first non-directory entry is used.
|
- In ZIP archives, the first entry ending with `.fb2` is used. If no such entry exists, the first non-directory entry is used.
|
||||||
|
|
||||||
ZIP support:
|
ZIP support:
|
||||||
|
|||||||
@ -10,7 +10,7 @@ object Fb2Format {
|
|||||||
val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) {
|
val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) {
|
||||||
Fb2Zip.extractFb2Xml(input)
|
Fb2Zip.extractFb2Xml(input)
|
||||||
} else {
|
} else {
|
||||||
Fb2XmlEncoding.decodeXml(input)
|
input.decodeToString()
|
||||||
}
|
}
|
||||||
return parseXml(xml)
|
return parseXml(xml)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,103 +0,0 @@
|
|||||||
package net.sergeych.toread.fb2
|
|
||||||
|
|
||||||
internal object Fb2XmlEncoding {
|
|
||||||
private val EncodingPattern = Regex("""encoding\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
|
||||||
|
|
||||||
fun decodeXml(bytes: ByteArray): String =
|
|
||||||
when (declaredEncoding(bytes)?.lowercase()) {
|
|
||||||
"windows-1251" -> decodeWindows1251(bytes)
|
|
||||||
else -> bytes.decodeToString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun declaredEncoding(bytes: ByteArray): String? {
|
|
||||||
val probe = bytes
|
|
||||||
.copyOfRange(0, minOf(bytes.size, 256))
|
|
||||||
.map { byte ->
|
|
||||||
val value = byte.toInt() and 0xff
|
|
||||||
if (value in 0x20..0x7e || value == '\n'.code || value == '\r'.code || value == '\t'.code) {
|
|
||||||
value.toChar()
|
|
||||||
} else {
|
|
||||||
' '
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.joinToString("")
|
|
||||||
return EncodingPattern.find(probe)?.groupValues?.getOrNull(1)?.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeWindows1251(bytes: ByteArray): String = buildString(bytes.size) {
|
|
||||||
bytes.forEach { byte ->
|
|
||||||
append(windows1251Char(byte.toInt() and 0xff))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun windows1251Char(value: Int): Char =
|
|
||||||
when (value) {
|
|
||||||
in 0x00..0x7f -> value.toChar()
|
|
||||||
0x80 -> '\u0402'
|
|
||||||
0x81 -> '\u0403'
|
|
||||||
0x82 -> '\u201a'
|
|
||||||
0x83 -> '\u0453'
|
|
||||||
0x84 -> '\u201e'
|
|
||||||
0x85 -> '\u2026'
|
|
||||||
0x86 -> '\u2020'
|
|
||||||
0x87 -> '\u2021'
|
|
||||||
0x88 -> '\u20ac'
|
|
||||||
0x89 -> '\u2030'
|
|
||||||
0x8a -> '\u0409'
|
|
||||||
0x8b -> '\u2039'
|
|
||||||
0x8c -> '\u040a'
|
|
||||||
0x8d -> '\u040c'
|
|
||||||
0x8e -> '\u040b'
|
|
||||||
0x8f -> '\u040f'
|
|
||||||
0x90 -> '\u0452'
|
|
||||||
0x91 -> '\u2018'
|
|
||||||
0x92 -> '\u2019'
|
|
||||||
0x93 -> '\u201c'
|
|
||||||
0x94 -> '\u201d'
|
|
||||||
0x95 -> '\u2022'
|
|
||||||
0x96 -> '\u2013'
|
|
||||||
0x97 -> '\u2014'
|
|
||||||
0x98 -> '\uFFFD'
|
|
||||||
0x99 -> '\u2122'
|
|
||||||
0x9a -> '\u0459'
|
|
||||||
0x9b -> '\u203a'
|
|
||||||
0x9c -> '\u045a'
|
|
||||||
0x9d -> '\u045c'
|
|
||||||
0x9e -> '\u045b'
|
|
||||||
0x9f -> '\u045f'
|
|
||||||
0xa0 -> '\u00a0'
|
|
||||||
0xa1 -> '\u040e'
|
|
||||||
0xa2 -> '\u045e'
|
|
||||||
0xa3 -> '\u0408'
|
|
||||||
0xa4 -> '\u00a4'
|
|
||||||
0xa5 -> '\u0490'
|
|
||||||
0xa6 -> '\u00a6'
|
|
||||||
0xa7 -> '\u00a7'
|
|
||||||
0xa8 -> '\u0401'
|
|
||||||
0xa9 -> '\u00a9'
|
|
||||||
0xaa -> '\u0404'
|
|
||||||
0xab -> '\u00ab'
|
|
||||||
0xac -> '\u00ac'
|
|
||||||
0xad -> '\u00ad'
|
|
||||||
0xae -> '\u00ae'
|
|
||||||
0xaf -> '\u0407'
|
|
||||||
0xb0 -> '\u00b0'
|
|
||||||
0xb1 -> '\u00b1'
|
|
||||||
0xb2 -> '\u0406'
|
|
||||||
0xb3 -> '\u0456'
|
|
||||||
0xb4 -> '\u0491'
|
|
||||||
0xb5 -> '\u00b5'
|
|
||||||
0xb6 -> '\u00b6'
|
|
||||||
0xb7 -> '\u00b7'
|
|
||||||
0xb8 -> '\u0451'
|
|
||||||
0xb9 -> '\u2116'
|
|
||||||
0xba -> '\u0454'
|
|
||||||
0xbb -> '\u00bb'
|
|
||||||
0xbc -> '\u0458'
|
|
||||||
0xbd -> '\u0405'
|
|
||||||
0xbe -> '\u0455'
|
|
||||||
0xbf -> '\u0457'
|
|
||||||
in 0xc0..0xff -> (0x0410 + value - 0xc0).toChar()
|
|
||||||
else -> '\uFFFD'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,7 +12,7 @@ internal object Fb2Zip {
|
|||||||
val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) }
|
val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) }
|
||||||
?: entries.firstOrNull { !it.name.endsWith("/") }
|
?: entries.firstOrNull { !it.name.endsWith("/") }
|
||||||
?: throw Fb2ParseException("ZIP archive does not contain an FB2 entry")
|
?: throw Fb2ParseException("ZIP archive does not contain an FB2 entry")
|
||||||
return Fb2XmlEncoding.decodeXml(readEntry(zip, entry))
|
return readEntry(zip, entry).decodeToString()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createStoredZip(entryName: String, content: ByteArray): ByteArray {
|
fun createStoredZip(entryName: String, content: ByteArray): ByteArray {
|
||||||
|
|||||||
@ -43,31 +43,6 @@ class Fb2FormatTest {
|
|||||||
assertTrue(zip.copyOfRange(0, 4).contentEquals(byteArrayOf(0x50, 0x4b, 0x03, 0x04)))
|
assertTrue(zip.copyOfRange(0, 4).contentEquals(byteArrayOf(0x50, 0x4b, 0x03, 0x04)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsesWindows1251PlainXml() {
|
|
||||||
val book = Fb2Format.parse(windows1251Xml.encodeWindows1251(), "legacy.fb2")
|
|
||||||
|
|
||||||
assertEquals("Тестовая книга", book.title)
|
|
||||||
assertEquals("Привет, мир.", book.sections.single().paragraphs.single())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsesWindows1251StoredZip() {
|
|
||||||
val zip = Fb2Zip.createStoredZip("legacy.fb2", windows1251Xml.encodeWindows1251())
|
|
||||||
val book = Fb2Format.parse(zip, "legacy.fb2.zip")
|
|
||||||
|
|
||||||
assertEquals("Тестовая книга", book.title)
|
|
||||||
assertEquals("Привет, мир.", book.sections.single().paragraphs.single())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fallsBackToUtf8ForUnknownEncoding() {
|
|
||||||
val xml = sampleXml.replace("encoding=\"UTF-8\"", "encoding=\"KOI8-R\"")
|
|
||||||
val book = Fb2Format.parse(xml.encodeToByteArray(), "unknown.fb2")
|
|
||||||
|
|
||||||
assertEquals("The Test Book", book.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun preservesReadableBlocksAndInlineStyles() {
|
fun preservesReadableBlocksAndInlineStyles() {
|
||||||
val book = Fb2Format.parseXml(richXml)
|
val book = Fb2Format.parseXml(richXml)
|
||||||
@ -133,37 +108,4 @@ class Fb2FormatTest {
|
|||||||
</body>
|
</body>
|
||||||
</FictionBook>
|
</FictionBook>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
private val windows1251Xml = """
|
|
||||||
<?xml version="1.0" encoding="windows-1251"?>
|
|
||||||
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<description>
|
|
||||||
<title-info>
|
|
||||||
<author><nickname>Автор</nickname></author>
|
|
||||||
<book-title>Тестовая книга</book-title>
|
|
||||||
<lang>ru</lang>
|
|
||||||
</title-info>
|
|
||||||
<document-info>
|
|
||||||
<author><nickname>Toread</nickname></author>
|
|
||||||
<date>2026-05-12</date>
|
|
||||||
<id>legacy</id>
|
|
||||||
<version>1.0</version>
|
|
||||||
</document-info>
|
|
||||||
</description>
|
|
||||||
<body><section><p>Привет, мир.</p></section></body>
|
|
||||||
</FictionBook>
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
private fun String.encodeWindows1251(): ByteArray =
|
|
||||||
ByteArray(length) { index ->
|
|
||||||
val char = this[index]
|
|
||||||
val value = when {
|
|
||||||
char.code <= 0x7f -> char.code
|
|
||||||
char in 'А'..'я' -> 0xc0 + char.code - 'А'.code
|
|
||||||
char == 'Ё' -> 0xa8
|
|
||||||
char == 'ё' -> 0xb8
|
|
||||||
else -> error("Test character $char is not mapped to windows-1251")
|
|
||||||
}
|
|
||||||
value.toByte()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -698,11 +698,7 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
|||||||
LEFT JOIN books b ON b.id = f.book_id
|
LEFT JOIN books b ON b.id = f.book_id
|
||||||
WHERE f.duplicate_of_file_id IS NULL
|
WHERE f.duplicate_of_file_id IS NULL
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||||
WHEN f.reading_status = 'READING' THEN 0
|
|
||||||
WHEN f.reading_status = 'NOT_INTERESTED' THEN 2
|
|
||||||
ELSE 1
|
|
||||||
END,
|
|
||||||
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
|
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
|
||||||
LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)),
|
LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)),
|
||||||
f.id
|
f.id
|
||||||
|
|||||||
@ -158,8 +158,8 @@ class H2LibraryDatabaseTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun listsReadingBooksFirstNotInterestedLastThenSortsByTitle() {
|
fun listsReadingBooksFirstThenSortsByTitle() {
|
||||||
val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstNotInterestedLastThenSortsByTitle")
|
val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstThenSortsByTitle")
|
||||||
val now = 1_700_000_000_000L
|
val now = 1_700_000_000_000L
|
||||||
|
|
||||||
db.transaction {
|
db.transaction {
|
||||||
@ -168,7 +168,6 @@ class H2LibraryDatabaseTest {
|
|||||||
Triple("book-alpha", "Alpha", BookReadingStatus.READ),
|
Triple("book-alpha", "Alpha", BookReadingStatus.READ),
|
||||||
Triple("book-gamma", "Gamma", BookReadingStatus.READING),
|
Triple("book-gamma", "Gamma", BookReadingStatus.READING),
|
||||||
Triple("book-aardvark", "Aardvark", BookReadingStatus.READING),
|
Triple("book-aardvark", "Aardvark", BookReadingStatus.READING),
|
||||||
Triple("book-omega", "Omega", BookReadingStatus.NOT_INTERESTED),
|
|
||||||
).forEachIndexed { index, (bookId, title, status) ->
|
).forEachIndexed { index, (bookId, title, status) ->
|
||||||
books.upsert(
|
books.upsert(
|
||||||
BookRecord(
|
BookRecord(
|
||||||
@ -195,7 +194,7 @@ class H2LibraryDatabaseTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Aardvark", "Gamma", "Alpha", "Beta", "Omega"),
|
listOf("Aardvark", "Gamma", "Alpha", "Beta"),
|
||||||
db.files.listLibraryFiles().map { it.title },
|
db.files.listLibraryFiles().map { it.title },
|
||||||
)
|
)
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user