+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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user