library UI + reader mode

This commit is contained in:
sergeych committed 2026-05-17 00:53:18 +03:00
commit 8f8b466e89
81 files changed
+7502

No files matched your search

@@ -0,0 +1,311 @@
package net.sergeych.toread
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.ReadingStateRecord
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner
import org.jetbrains.skia.Image
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.TimeUnit
import javax.swing.JFileChooser
import kotlin.concurrent.thread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
actual fun loadDefaultBookBytes(): ByteArray? {
var current = File(System.getProperty("user.dir")).absoluteFile
while (true) {
val candidate = File(current, "test_books/$DefaultBookFileName")
if (candidate.isFile) return candidate.readBytes()
current = current.parentFile ?: break
}
return null
}
actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
runCatching { Image.makeFromEncoded(binary.imageBytes()).toComposeImageBitmap() }.getOrNull()
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
runCatching { Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull()
actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatchers.IO) {
val chooser = JFileChooser(defaultLibraryScanPath())
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.dialogTitle = "Choose library folder"
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
chooser.selectedFile.absolutePath
} else {
null
}
}
actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) {
appendLibraryLog("load library items")
openLibraryDatabase().useLibrary { db ->
val items = db.files.list().map { file ->
val book = file.bookId?.let(db.books::get)
val parsed = file.storageUri?.let { uri ->
runCatching {
File(uri).takeIf { it.isFile }?.readBytes()?.let { Fb2Format.parse(it, uri) }
}.getOrNull()
}
LibraryItem(
fileId = file.id,
bookId = file.bookId,
title = parsed?.title ?: book?.title ?: file.originalFilename ?: file.id,
authors = parsed?.authors?.mapNotNull { it.displayName.takeIf(String::isNotBlank) }.orEmpty(),
language = parsed?.language ?: book?.language,
date = parsed?.date,
format = file.format,
sizeBytes = file.sizeBytes,
storageUri = file.storageUri,
lastSeenAt = file.lastSeenAt,
coverImage = book?.coverImage ?: parsed?.libraryCoverBinary()?.imageBytes(),
coverImageMimeType = book?.coverImageMimeType ?: parsed?.libraryCoverBinary()?.contentType,
)
}
appendLibraryLog("loaded library items count=${items.size}")
items
}
}
actual suspend fun scanLibrarySubtree(
path: String,
onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport = withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path")
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
onProgress(
LibraryScanProgress(
scannedFiles = it.scannedFiles,
importedFiles = it.importedFiles,
skippedFiles = it.skippedFiles,
failedFiles = it.failedFiles,
currentFile = it.currentFile,
)
)
}
LibraryScanReport(
scannedFiles = summary.scannedFiles,
importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles,
)
}
}
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {
appendLibraryLog("open book fileId=$fileId")
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary null
val bytes = file.storageUri?.let { File(it) }?.takeIf { it.isFile }?.readBytes()
appendLibraryLog("open book fileId=$fileId bytes=${bytes?.size ?: 0} uri=${file.storageUri}")
bytes
}
}
actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
appendLibraryLog("delete fileId=$fileId")
openLibraryDatabase().useLibrary { db ->
val deleted = db.files.delete(fileId)
appendLibraryLog("delete fileId=$fileId deleted=$deleted")
deleted
}
}
actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary null
val clusterId = file.bodyClusterId ?: return@useLibrary null
db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
}
}
actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary
val clusterId = file.bodyClusterId ?: return@useLibrary
db.readingStates.upsert(
ReadingStateRecord(
id = "state-$clusterId",
bodyClusterId = clusterId,
bodyId = file.bodyId,
anchor = ContentAnchor(
progress = position.itemIndex.toDouble(),
formatHintsJson = position.toFormatHintsJson(),
),
updatedAt = System.currentTimeMillis(),
),
)
}
}
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras()
val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
BookInfoExtras(
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
BookmarkInfo(
title = it.title,
selectedText = it.selectedTextSnapshot,
progress = it.anchor.progress,
updatedAt = it.updatedAt,
)
},
notes = db.notes.listForBodyCluster(clusterId).map {
NoteInfo(
text = it.text,
progress = it.anchor.progress,
updatedAt = it.updatedAt,
)
},
lastReadingPosition = readingPosition,
)
}
}
actual suspend fun loadActiveReadingFileId(): String? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ActiveReadingFileIdFlag)?.takeIf { it.isNotBlank() }
}
}
actual suspend fun saveActiveReadingFileId(fileId: String?) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ActiveReadingFileIdFlag, fileId?.takeIf { it.isNotBlank() })
}
}
actual suspend fun loadThemeMode(): ThemeMode = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ThemeModeFlag)?.let { value ->
runCatching { ThemeMode.valueOf(value) }.getOrNull()
} ?: ThemeMode.SYSTEM
}
}
actual suspend fun saveThemeMode(mode: ThemeMode) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ThemeModeFlag, mode.name)
}
}
actual fun isPlatformDarkTheme(): Boolean {
val osName = System.getProperty("os.name").lowercase()
if (!osName.contains("linux")) return false
val gtkTheme = System.getenv("GTK_THEME").orEmpty()
if (gtkTheme.contains("dark", ignoreCase = true)) return true
val colorScheme = runCommand("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
if (colorScheme?.contains("prefer-dark", ignoreCase = true) == true) return true
val themeName = runCommand("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme")
if (themeName?.contains("dark", ignoreCase = true) == true) return true
val kdeGlobals = File(System.getProperty("user.home"), ".config/kdeglobals")
return runCatching {
kdeGlobals.takeIf { it.isFile }
?.readText()
?.lineSequence()
?.any { line ->
line.startsWith("ColorScheme=", ignoreCase = true) &&
line.contains("dark", ignoreCase = true)
} == true
}.getOrDefault(false)
}
actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
val osName = System.getProperty("os.name").lowercase()
if (!osName.contains("linux")) return {}
val running = AtomicBoolean(true)
val worker = thread(name = "toread-theme-watch", isDaemon = true) {
var last = isPlatformDarkTheme()
while (running.get()) {
try {
Thread.sleep(1500)
val current = isPlatformDarkTheme()
if (current != last) {
last = current
onChange(current)
}
} catch (_: InterruptedException) {
return@thread
}
}
}
return {
running.set(false)
worker.interrupt()
}
}
actual fun libraryLogPath(): String? = libraryLogFile().absolutePath
private fun openLibraryDatabase(): H2LibraryDatabase {
val libraryDir = File(System.getProperty("user.home"), ".toread/library")
val dbDir = File(libraryDir, "db")
dbDir.mkdirs()
return H2LibraryDatabase.openFile(File(dbDir, "library").absolutePath)
}
private fun <T> H2LibraryDatabase.useLibrary(block: (H2LibraryDatabase) -> T): T =
try {
block(this)
} finally {
close()
}
private fun findProjectRoot(): File? {
var current = File(System.getProperty("user.dir")).absoluteFile
while (true) {
if (File(current, "test_books").isDirectory) return current
current = current.parentFile ?: break
}
return null
}
private fun appendLibraryLog(message: String) {
val file = libraryLogFile()
file.parentFile?.mkdirs()
file.appendText("${System.currentTimeMillis()} $message\n")
}
private fun libraryLogFile(): File =
File(System.getProperty("user.home"), ".toread/toread.log")
private fun ReadingPosition.toFormatHintsJson(): String =
"""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset}"""
private fun String.toReadingPosition(): ReadingPosition? {
val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
return if (index != null && offset != null) ReadingPosition(index, offset) else null
}
private fun runCommand(vararg command: String): String? =
runCatching {
val process = ProcessBuilder(*command).redirectErrorStream(true).start()
if (!process.waitFor(500, TimeUnit.MILLISECONDS)) {
process.destroy()
null
} else {
process.inputStream.bufferedReader().readText().trim()
}
}.getOrNull()
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
@@ -0,0 +1,13 @@
package net.sergeych.toread
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "toread",
) {
App()
}
}