+image viewer, better dupes protection and background processing
This commit is contained in:
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)
|
||||
|
||||
Reference in new issue
Block a user