+image viewer, better dupes protection and background processing

This commit is contained in:
sergeych committed 2026-05-17 12:22:46 +03:00
1 parent 14c9863a83
commit ade4fb1896
18 files changed
+817 -127

No files matched your search

@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -19,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -92,25 +94,64 @@ private fun AppToast(message: String?, modifier: Modifier = Modifier) {
@Composable
private fun BookReaderApp(onThemeToggle: () -> Unit) {
var state by remember { mutableStateOf<AppState>(AppState.LoadingLibrary) }
var activeScan by remember { mutableStateOf<LibraryScanProgress?>(null) }
var scanJob by remember { mutableStateOf<Job?>(null) }
var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
state = loadStartupState()
}
fun startScan(path: String) {
if (scanJob?.isActive == true) return
activeScan = LibraryScanProgress(0, 0, 0, 0)
scanJob = scope.launch {
val report = runCatching {
scanLibrarySubtree(path) { progress ->
scope.launch {
if (scanJob?.isActive == true) activeScan = progress
}
}
}
val message = report.fold(
onSuccess = {
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
},
onFailure = { it.message ?: "Scan failed." },
)
scanJob = null
activeScan = null
state = when (val current = state) {
is AppState.Library, is AppState.Scan, AppState.LoadingLibrary -> loadLibraryState(message, path)
is AppState.Reader -> current.copy(message = message)
is AppState.BookInfo -> current.copy(message = message)
is AppState.Error -> current
}
}
}
when (val current = state) {
AppState.LoadingLibrary -> LoadingScreen("Opening library")
is AppState.Library -> LibraryScreen(
state = current,
activeScan = activeScan,
onStateChange = { state = it },
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
)
is AppState.Scan -> ScanScreen(
state = current,
activeScan = activeScan,
onStateChange = { state = it },
onStartScan = { path ->
startScan(path)
state = AppState.Library(current.items, path, "Scanning...")
},
)
is AppState.Reader -> BookView(
fileId = current.fileId,
book = current.book,
onImageOpen = { imageViewer = it },
onThemeToggle = onThemeToggle,
onBookInfo = {
state = AppState.BookInfo(
@@ -128,6 +169,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
is AppState.BookInfo -> BookInfoScreen(
fileId = current.fileId,
book = current.book,
onImageOpen = { imageViewer = it },
onBack = {
state = AppState.Reader(
fileId = current.fileId,
@@ -140,4 +182,18 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
)
is AppState.Error -> ErrorScreen(current.message, onBack = { state = AppState.LoadingLibrary })
}
imageViewer?.let { image ->
ImageViewer(
image = image,
onBack = { imageViewer = null },
)
}
}
internal data class ViewedBookImage(
val bitmap: ImageBitmap,
val bytes: ByteArray,
val mimeType: String,
val title: String,
)
@@ -39,6 +39,7 @@ import net.sergeych.toread.fb2.Fb2Book
internal fun BookInfoScreen(
fileId: String,
book: Fb2Book,
onImageOpen: (ViewedBookImage) -> Unit,
onBack: () -> Unit,
) {
val stats = remember(book) { BookStats.from(book) }
@@ -73,7 +74,7 @@ internal fun BookInfoScreen(
) {
item {
InfoSection("Title Info") {
CoverAndTitle(book)
CoverAndTitle(book, onImageOpen = onImageOpen)
DetailLine("Title", book.title)
DetailLine("Authors", book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" })
DetailLine("Language", book.language?.uppercase() ?: "Not specified")
@@ -0,0 +1,190 @@
package net.sergeych.toread
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ZoomIn
import androidx.compose.material.icons.filled.ZoomOut
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.focusable
import kotlinx.coroutines.launch
private const val MinImageScale = 1f
private const val MaxImageScale = 8f
@Composable
internal fun ImageViewer(
image: ViewedBookImage,
onBack: () -> Unit,
) {
var scale by remember(image) { mutableStateOf(MinImageScale) }
var offset by remember(image) { mutableStateOf(Offset.Zero) }
val focusRequester = remember { FocusRequester() }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
fun setScale(next: Float) {
scale = next.coerceIn(MinImageScale, MaxImageScale)
if (scale == MinImageScale) offset = Offset.Zero
}
fun panBy(delta: Offset) {
if (scale > MinImageScale) {
offset += delta
} else {
offset = Offset.Zero
}
}
val transformState = rememberTransformableState { zoomChange, panChange, _ ->
setScale(scale * zoomChange)
panBy(panChange)
}
fun copyImage() {
scope.launch {
val message = if (copyImageToClipboard(image.bytes, image.mimeType, image.title)) {
"Image copied"
} else {
"Copy failed"
}
snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short)
}
}
LaunchedEffect(image) {
focusRequester.requestFocus()
}
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
Surface(color = MaterialTheme.colorScheme.surface) {
Row(
modifier = Modifier.fillMaxWidth().height(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text(
image.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { setScale(scale * 1.25f) }) {
Icon(Icons.Filled.ZoomIn, contentDescription = "Zoom in")
}
IconButton(onClick = { setScale(scale / 1.25f) }, enabled = scale > MinImageScale) {
Icon(Icons.Filled.ZoomOut, contentDescription = "Zoom out")
}
IconButton(
onClick = ::copyImage,
) {
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy image")
}
}
}
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color.Black)
.focusRequester(focusRequester)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when {
event.key == Key.Escape -> {
onBack()
true
}
event.key == Key.Plus || event.key == Key.Equals -> {
setScale(scale * 1.25f)
true
}
event.key == Key.Minus -> {
setScale(scale / 1.25f)
true
}
event.key == Key.C && (event.isCtrlPressed || event.isMetaPressed) -> {
copyImage()
true
}
else -> false
}
}
.focusable()
.transformable(transformState)
.pointerInput(image, scale) {
detectDragGestures { change, dragAmount ->
if (change.positionChange() != Offset.Zero) change.consume()
panBy(dragAmount)
}
},
contentAlignment = Alignment.Center,
) {
Image(
bitmap = image.bitmap,
contentDescription = image.title,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
contentScale = ContentScale.Fit,
)
}
}
}
@@ -31,6 +31,7 @@ data class LibraryScanReport(
val importedFiles: Int,
val skippedFiles: Int,
val failedFiles: Int,
val totalFiles: Int? = null,
)
data class LibraryScanProgress(
@@ -39,6 +40,7 @@ data class LibraryScanProgress(
val skippedFiles: Int,
val failedFiles: Int,
val currentFile: String? = null,
val totalFiles: Int? = null,
)
data class PlatformOpenBookRequest(
@@ -54,12 +54,14 @@ import androidx.compose.ui.unit.dp
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.storage.BookReadingStatus
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalMaterial3Api::class)
internal fun LibraryScreen(
state: AppState.Library,
activeScan: LibraryScanProgress?,
onStateChange: (AppState) -> Unit,
onNavigateToScan: () -> Unit,
) {
@@ -70,6 +72,7 @@ internal fun LibraryScreen(
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) }
var loadingPage by remember(state.items) { mutableStateOf(false) }
var endReached by remember(state.items) { mutableStateOf(false) }
var wasScanning by remember { mutableStateOf(false) }
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
suspend fun loadPage(reset: Boolean = false) {
@@ -104,6 +107,19 @@ internal fun LibraryScreen(
if (items.isEmpty() && !endReached) loadPage(reset = true)
}
LaunchedEffect(activeScan != null) {
if (activeScan != null) {
wasScanning = true
while (true) {
delay(5_000)
loadPage(reset = true)
}
} else if (wasScanning) {
wasScanning = false
loadPage(reset = true)
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
@@ -140,7 +156,7 @@ internal fun LibraryScreen(
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(0.dp),
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val hasReadingNow = items.firstOrNull()?.readingStatus == BookReadingStatus.READING
@@ -264,6 +280,14 @@ internal fun LibraryScreen(
}
}
}
activeScan?.let { progress ->
LibraryScanStatusPanel(
progress = progress,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = if (wide) 24.dp else 14.dp, vertical = 14.dp),
)
}
}
}
}
@@ -291,6 +315,36 @@ private fun LibrarySectionHeader(text: String) {
)
}
@Composable
private fun LibraryScanStatusPanel(progress: LibraryScanProgress, modifier: Modifier = Modifier) {
Card(
shape = RoundedCornerShape(8.dp),
colors = quietCardColors(),
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.width(22.dp).height(22.dp), strokeWidth = 2.dp)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
progress.toCatalogScanMessage(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Text(
"Imported ${progress.importedFiles}, skipped ${progress.skippedFiles}, failed ${progress.failedFiles}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
}
@Composable
private fun LibraryRow(
item: LibraryItem,
@@ -454,4 +508,14 @@ private fun Long.formatBytes(): String =
else -> "$this B"
}
private fun LibraryScanProgress.toCatalogScanMessage(): String {
val total = totalFiles ?: return "Scanned $scannedFiles books"
val percent = if (total <= 0) {
100
} else {
((scannedFiles.toDouble() / total.toDouble()) * 100.0).roundToInt().coerceIn(0, 100)
}
return "Scanned $scannedFiles of $total, $percent% done"
}
private const val LibraryPageSize: Int = 50
@@ -15,3 +15,5 @@ expect fun loadDefaultBookBytes(): ByteArray?
expect fun decodeBookImage(binary: Fb2Binary): ImageBitmap?
expect fun decodeImageBytes(bytes: ByteArray): ImageBitmap?
expect suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean
@@ -2,6 +2,7 @@ package net.sergeych.toread
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -75,6 +76,7 @@ internal fun ContinuousBookReader(
stats: BookStats,
listState: LazyListState,
modifier: Modifier = Modifier,
onImageOpen: (ViewedBookImage) -> Unit = {},
) {
val hyphenation = remember { HyphenationRegistry() }
val contentPadding = if (isAndroidPlatform()) {
@@ -92,7 +94,7 @@ internal fun ContinuousBookReader(
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
CoverAndTitle(book)
CoverAndTitle(book, onImageOpen = onImageOpen)
MetadataCard(book)
StatsCard(stats)
}
@@ -107,6 +109,7 @@ internal fun ContinuousBookReader(
depth = 0,
keyPrefix = "section-$index",
hyphenation = hyphenation,
onImageOpen = onImageOpen,
)
}
item { Spacer(Modifier.height(22.dp)) }
@@ -119,6 +122,7 @@ private fun LazyListScope.sectionItems(
depth: Int,
keyPrefix: String,
hyphenation: HyphenationRegistry,
onImageOpen: (ViewedBookImage) -> Unit,
) {
if( section.title.isNullOrBlank() ) {
item {
@@ -153,6 +157,7 @@ private fun LazyListScope.sectionItems(
image = block.image,
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
contentScale = ContentScale.Fit,
onOpen = onImageOpen,
)
is Fb2Block.Paragraph -> ReaderText(
text = block.content,
@@ -181,6 +186,7 @@ private fun LazyListScope.sectionItems(
depth = depth + 1,
keyPrefix = "$keyPrefix-$index",
hyphenation = hyphenation,
onImageOpen = onImageOpen,
)
}
}
@@ -232,7 +238,7 @@ private fun DetailsPane(
}
@Composable
internal fun CoverAndTitle(book: Fb2Book) {
internal fun CoverAndTitle(book: Fb2Book, onImageOpen: (ViewedBookImage) -> Unit = {}) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -243,6 +249,7 @@ internal fun CoverAndTitle(book: Fb2Book) {
image = book.coverImages.firstOrNull() ?: book.bodyImages.firstOrNull(),
modifier = Modifier.width(112.dp).aspectRatio(0.68f),
contentScale = ContentScale.Crop,
onOpen = onImageOpen,
)
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) {
Text(book.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
@@ -428,20 +435,41 @@ private fun BookImage(
image: Fb2ImageRef?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
onOpen: (ViewedBookImage) -> Unit = {},
) {
val bitmap = remember(book, image) {
image?.let(book::binaryFor)?.let { decodeBookImage(it) }
val binary = remember(book, image) {
image?.let(book::binaryFor)
}
val bitmap = remember(binary) {
binary?.let { decodeBookImage(it) }
}
val imageTitle = image?.alt?.ifBlank { null } ?: book.title
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
.background(MaterialTheme.colorScheme.surfaceVariant)
.then(
if (bitmap != null && binary != null) {
Modifier.clickable {
onOpen(
ViewedBookImage(
bitmap = bitmap,
bytes = binary.imageBytes(),
mimeType = binary.contentType,
title = imageTitle,
),
)
}
} else {
Modifier
},
),
contentAlignment = Alignment.Center,
) {
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = image?.alt ?: book.title,
contentDescription = imageTitle,
modifier = Modifier.fillMaxWidth(),
contentScale = contentScale,
)
@@ -45,6 +45,7 @@ import kotlinx.coroutines.launch
internal fun BookView(
fileId: String,
book: Fb2Book,
onImageOpen: (ViewedBookImage) -> Unit,
onThemeToggle: () -> Unit,
onBookInfo: () -> Unit,
onBack: () -> Unit,
@@ -128,6 +129,7 @@ internal fun BookView(
stats = stats,
modifier = Modifier.fillMaxSize(),
listState = listState,
onImageOpen = onImageOpen,
)
}
}
@@ -44,13 +44,14 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
internal fun ScanScreen(
state: AppState.Scan,
activeScan: LibraryScanProgress?,
onStateChange: (AppState) -> Unit,
onStartScan: (String) -> Unit,
) {
val scope = rememberCoroutineScope()
var scanPath by remember(state.scanPath) { mutableStateOf(state.scanPath) }
var busy by remember { mutableStateOf(false) }
var message by remember(state.message) { mutableStateOf(state.message) }
var scanProgress by remember { mutableStateOf<LibraryScanProgress?>(null) }
val busy = activeScan != null
Scaffold(
topBar = {
@@ -92,28 +93,8 @@ internal fun ScanScreen(
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = {
scope.launch {
busy = true
scanProgress = null
try {
message = "Scanning..."
val report = runCatching {
scanLibrarySubtree(scanPath) { progress ->
scope.launch { scanProgress = progress }
}
}
val nextMessage = report.fold(
onSuccess = {
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
},
onFailure = { it.message ?: "Scan failed." },
)
onStateChange(loadLibraryState(nextMessage, scanPath))
} finally {
busy = false
scanProgress = null
}
}
message = "Scanning..."
onStartScan(scanPath)
},
enabled = !busy && scanPath.isNotBlank(),
) {
@@ -137,14 +118,12 @@ internal fun ScanScreen(
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
}
}
if (busy) {
scanProgress?.let { progress ->
Text(
progress.toScanMessage(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
if (activeScan != null) {
Text(
activeScan.toScanMessage(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
message?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)