+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

@@ -43,6 +43,15 @@
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.imageviewer.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/image_clipboard_paths"/>
</provider>
</application>
</manifest>
@@ -1,5 +1,7 @@
package net.sergeych.toread
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver
import android.content.ComponentCallbacks
import android.content.Context
@@ -12,6 +14,7 @@ import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.content.FileProvider
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.ContentAnchor
@@ -24,7 +27,12 @@ import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private lateinit var appContext: Context
@@ -54,6 +62,34 @@ actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull()
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
val imageDir = File(appContext.cacheDir, "clipboard-images").also { it.mkdirs() }
val extension = when (mimeType.lowercase()) {
"image/png" -> "png"
"image/gif" -> "gif"
"image/webp" -> "webp"
else -> "jpg"
}
val imageFile = File(imageDir, "book-image.$extension")
imageFile.writeBytes(bytes)
val uri = FileProvider.getUriForFile(
appContext,
"${appContext.packageName}.imageviewer.fileprovider",
imageFile,
)
val clipboard = appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newUri(appContext.contentResolver, label, uri).apply {
description.extras = android.os.PersistableBundle().apply {
putString("mime_type", mimeType)
}
}
clipboard.setPrimaryClip(clip)
true
}.getOrDefault(false)
}
actual fun defaultLibraryScanPath(): String? =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
?: appContext.getExternalFilesDir(null)?.absolutePath
@@ -105,26 +141,68 @@ actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withCon
actual suspend fun scanLibrarySubtree(
path: String,
onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport = withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path")
if (path.isContentUri()) {
scanLibraryContentTree(Uri.parse(path), onProgress)
} else {
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
error("All files access is required to scan $path.")
}
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
onProgress(it.toLibraryScanProgress())
): LibraryScanReport = coroutineScope {
withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path")
}
val totalFiles = AtomicInteger(-1)
val latestProgress = AtomicReference<LibraryScanProgress?>(null)
fun emitProgress(progress: LibraryScanProgress) {
val enriched = totalFiles.get().takeIf { it >= 0 }?.let { progress.copy(totalFiles = it) } ?: progress
latestProgress.set(enriched)
onProgress(enriched)
}
val totalJob = async(Dispatchers.IO) {
runCatching {
if (path.isContentUri()) {
countContentTree(Uri.parse(path))
} else {
LibraryScanner.countSupportedBookFiles(File(path))
}
}.getOrNull()
}
val totalPublisher = launch(Dispatchers.IO) {
val total = totalJob.await() ?: return@launch
totalFiles.set(total)
emitProgress(latestProgress.get()?.copy(totalFiles = total) ?: LibraryScanProgress(0, 0, 0, 0, totalFiles = total))
}
val report = withContext(Dispatchers.IO) {
if (path.isContentUri()) {
scanLibraryContentTree(Uri.parse(path), ::emitProgress)
} else {
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
error("All files access is required to scan $path.")
}
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
emitProgress(it.toLibraryScanProgress())
}
LibraryScanReport(
scannedFiles = summary.scannedFiles,
importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles,
totalFiles = totalFiles.get().takeIf { it >= 0 },
)
}
LibraryScanReport(
scannedFiles = summary.scannedFiles,
importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles,
)
}
}
totalPublisher.join()
report.copy(totalFiles = totalFiles.get().takeIf { it >= 0 } ?: report.totalFiles)
}
private fun countContentTree(rootUri: Uri): Int {
var count = 0
walkContentTree(
rootUri = rootUri,
onVisited = {},
) {
count += 1
}
return count
}
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="clipboard_images" path="clipboard-images/"/>
</paths>
@@ -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)
@@ -10,15 +10,26 @@ 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.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.io.File
import java.io.ByteArrayInputStream
import javax.imageio.ImageIO
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.TimeUnit
import javax.swing.JFileChooser
import kotlin.concurrent.thread
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
actual fun loadDefaultBookBytes(): ByteArray? {
@@ -37,6 +48,22 @@ actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
runCatching { Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull()
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
val image = ImageIO.read(ByteArrayInputStream(bytes)) ?: return@withContext false
val transferable = object : Transferable {
override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(DataFlavor.imageFlavor)
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.imageFlavor
override fun getTransferData(flavor: DataFlavor): Any {
if (!isDataFlavorSupported(flavor)) throw UnsupportedFlavorException(flavor)
return image
}
}
Toolkit.getDefaultToolkit().systemClipboard.setContents(transferable, null)
true
}.getOrDefault(false)
}
actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
@@ -79,27 +106,53 @@ actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withCon
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 = coroutineScope {
withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path")
}
val root = File(path)
val totalFiles = AtomicInteger(-1)
val latestProgress = AtomicReference<LibraryScanProgress?>(null)
fun emitProgress(progress: LibraryScanProgress) {
val enriched = totalFiles.get().takeIf { it >= 0 }?.let { progress.copy(totalFiles = it) } ?: progress
latestProgress.set(enriched)
onProgress(enriched)
}
val totalJob = async(Dispatchers.IO) {
runCatching { LibraryScanner.countSupportedBookFiles(root) }.getOrNull()
}
val totalPublisher = launch(Dispatchers.IO) {
val total = totalJob.await() ?: return@launch
totalFiles.set(total)
emitProgress(latestProgress.get()?.copy(totalFiles = total) ?: LibraryScanProgress(0, 0, 0, 0, totalFiles = total))
}
val report = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(root) {
emitProgress(
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,
totalFiles = totalFiles.get().takeIf { it >= 0 },
)
}
LibraryScanReport(
scannedFiles = summary.scannedFiles,
importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles,
)
}
totalPublisher.join()
report.copy(totalFiles = totalFiles.get().takeIf { it >= 0 } ?: report.totalFiles)
}
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {
@@ -12,6 +12,8 @@ actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? = null
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? = null
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = false
actual fun defaultLibraryScanPath(): String? = null
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null