Compare commits
6 Commits
1d3daf5c8c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 14dbd76477 | |||
| 1122e06437 | |||
| 3295816550 | |||
| 6e795bd7d1 | |||
| d73f54eeb4 | |||
| 9400ff5535 |
@@ -0,0 +1,79 @@
|
||||
# 📚 Epook - Modern EPUB Reader
|
||||
|
||||
Epook is a sleek, modern EPUB reader built for Android using Jetpack Compose. It provides a clean, intuitive interface for reading your favorite ebooks with powerful features and smooth animations.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 📖 Reading Experience
|
||||
- Fluid chapter navigation with swipe gestures
|
||||
- Progress tracking across reading sessions
|
||||
- Interactive chapter selection through table of contents
|
||||
- Smooth animations for UI elements
|
||||
- Full-screen reading mode with tap controls
|
||||
|
||||
### 📱 User Interface
|
||||
- Material Design 3 with dynamic theming
|
||||
- Animated controls overlay
|
||||
- Bottom navigation bar with chapter progress
|
||||
- Clean, minimalist book library view
|
||||
- Beautiful book cards with cover display
|
||||
|
||||
### ⚙️ Customization
|
||||
- Adjustable font size (12-24sp)
|
||||
- Customizable line height (1.0x-2.0x)
|
||||
- Margin control (8-32dp)
|
||||
- Multiple font family options:
|
||||
- Georgia
|
||||
- Roboto
|
||||
- Times New Roman
|
||||
- Arial
|
||||
- Verdana
|
||||
|
||||
### 📚 Library Management
|
||||
- Import EPUB files
|
||||
- Automatic cover image extraction
|
||||
- Book deletion with confirmation
|
||||
- Reading progress persistence
|
||||
- Organized book collection view
|
||||
|
||||
### ���� Technical Features
|
||||
- CSS stylesheet handling
|
||||
- HTML content processing
|
||||
- Efficient file management
|
||||
- WebView-based rendering
|
||||
- Resource caching
|
||||
|
||||
## 🛠️ Technical Stack
|
||||
|
||||
- **UI Framework**: Jetpack Compose
|
||||
- **Language**: Kotlin
|
||||
- **Architecture**: MVVM
|
||||
- **Storage**: Room Database
|
||||
- **EPUB Processing**: epublib
|
||||
- **HTML Processing**: JSoup
|
||||
- **Image Loading**: Coil
|
||||
- **Logging**: Timber
|
||||
|
||||
## 🎯 Upcoming Features
|
||||
|
||||
- [ ] Search functionality
|
||||
- [ ] Bookmarks
|
||||
- [ ] Highlights and notes
|
||||
- [ ] Dark mode support
|
||||
- [ ] Reading statistics
|
||||
- [ ] Custom themes
|
||||
- [ ] Cloud sync
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Feel free to submit issues and pull requests.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- [epublib](https://github.com/psiegman/epublib) for EPUB processing
|
||||
- [Jsoup](https://jsoup.org/) for HTML parsing
|
||||
- [Material Design 3](https://m3.material.io/) for design guidelines
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.MenuBook
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -41,6 +42,16 @@ import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import kotlinx.coroutines.flow.map
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.SmallTopAppBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -53,7 +64,6 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
val navController = rememberNavController()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
EpookTheme {
|
||||
NavHost(
|
||||
@@ -64,8 +74,7 @@ class MainActivity : ComponentActivity() {
|
||||
MainScreen(
|
||||
navController = navController,
|
||||
bookStore = bookStore,
|
||||
snackbarHostState = snackbarHostState,
|
||||
scope = scope
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,16 +99,18 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MainScreen(
|
||||
fun MainScreen(
|
||||
navController: NavController,
|
||||
bookStore: BookStore,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
scope: CoroutineScope
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val books by bookStore.getAllBooks().collectAsState(initial = emptyList())
|
||||
var bookToDelete by remember { mutableStateOf<Book?>(null) }
|
||||
|
||||
fun importBook(uri: Uri, scope: CoroutineScope) {
|
||||
fun importBook(uri: Uri) {
|
||||
scope.launch {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
@@ -134,15 +145,65 @@ private fun MainScreen(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
importBook(uri, scope)
|
||||
importBook(uri)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
bookToDelete?.let { book ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { bookToDelete = null },
|
||||
title = { Text("Delete Book") },
|
||||
text = {
|
||||
Text("Are you sure you want to delete \"${book.title}\"?")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
bookStore.deleteBook(book.id)
|
||||
bookToDelete = null
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { bookToDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SmallTopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = "My Library",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Text(
|
||||
text = "${books.size} book${if (books.size != 1) "s" else ""}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.smallTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { launcher.launch("application/epub+zip") }
|
||||
onClick = { launcher.launch("application/epub+zip") },
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
@@ -151,26 +212,62 @@ private fun MainScreen(
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
contentPadding = padding,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(books) { book ->
|
||||
BookCard(
|
||||
book = book,
|
||||
onClick = { navController.navigate("reader/${book.id}") },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
bookStore.deleteBook(book.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (books.isEmpty()) {
|
||||
EmptyLibraryMessage(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
)
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 140.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
items(books) { book ->
|
||||
BookCard(
|
||||
book = book,
|
||||
onClick = { navController.navigate("reader/${book.id}") },
|
||||
onLongClick = { bookToDelete = book }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLibraryMessage(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MenuBook,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Your library is empty",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Tap + to add your first book",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun BookCard(
|
||||
@@ -182,53 +279,73 @@ private fun BookCard(
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
ElevatedCard(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(0.7f)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { onClick() },
|
||||
onLongPress = { onLongClick() }
|
||||
)
|
||||
},
|
||||
elevation = CardDefaults.elevatedCardElevation(
|
||||
defaultElevation = 4.dp,
|
||||
pressedElevation = if (isPressed) 8.dp else 4.dp
|
||||
)
|
||||
Column(
|
||||
modifier = modifier.width(140.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AsyncImage(
|
||||
model = book.coverPath ?: R.drawable.ic_launcher_background,
|
||||
contentDescription = book.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = book.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = book.author,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(0.67f) // Standard book cover ratio
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { onClick() },
|
||||
onLongPress = { onLongClick() }
|
||||
)
|
||||
}
|
||||
.shadow(
|
||||
elevation = if (isPressed) 8.dp else 4.dp,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Book cover
|
||||
AsyncImage(
|
||||
model = book.coverPath ?: R.drawable.ic_book_24dp,
|
||||
contentDescription = book.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
|
||||
// Gradient overlay
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Book info below the card
|
||||
Text(
|
||||
text = book.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = book.author,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -30,8 +31,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import inhale.rip.epook.data.BookStore
|
||||
import inhale.rip.epook.data.Settings
|
||||
import inhale.rip.epook.data.SettingsStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jsoup.Jsoup
|
||||
@@ -49,6 +48,7 @@ import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebSettings
|
||||
import android.view.View
|
||||
|
||||
// Move the Chapter data class outside the composable
|
||||
private data class Chapter(
|
||||
@@ -57,6 +57,9 @@ private data class Chapter(
|
||||
val resource: Resource
|
||||
)
|
||||
|
||||
// First, add a constant for the swipe area height
|
||||
private const val SWIPE_AREA_HEIGHT = 80 // in dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReaderScreen(
|
||||
@@ -65,9 +68,7 @@ fun ReaderScreen(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val bookStore = remember { BookStore(context) }
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var currentSettings by remember { mutableStateOf(Settings()) }
|
||||
|
||||
var currentChapterIndex by remember { mutableStateOf(0) }
|
||||
var chapters by remember { mutableStateOf<List<Chapter>>(emptyList()) }
|
||||
@@ -75,19 +76,11 @@ fun ReaderScreen(
|
||||
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
var showChapterList by remember { mutableStateOf(false) }
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
|
||||
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
|
||||
val textColor = MaterialTheme.colorScheme.onBackground.toArgb()
|
||||
val primaryColor = MaterialTheme.colorScheme.primary.toArgb()
|
||||
var currentX by remember { mutableStateOf(0f) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
currentSettings = settingsStore.getSettings()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error loading settings")
|
||||
}
|
||||
}
|
||||
var extractedPath by remember { mutableStateOf<String?>(null) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
LaunchedEffect(bookId) {
|
||||
try {
|
||||
@@ -147,230 +140,228 @@ fun ReaderScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AnimatedVisibility(
|
||||
visible = showControls,
|
||||
enter = fadeIn() + slideInVertically(),
|
||||
exit = fadeOut() + slideOutVertically()
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = book?.title ?: "Reader",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Chapter ${currentChapterIndex + 1} of ${chapters.size}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
// WebView without swipe gesture
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?
|
||||
) {
|
||||
Timber.e("WebView error loading ${request?.url}: ${error?.description}")
|
||||
super.onReceivedError(view, request, error)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showChapterList = true }) {
|
||||
Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Chapters")
|
||||
}
|
||||
IconButton(onClick = { showSettings = true }) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest
|
||||
): WebResourceResponse? {
|
||||
val url = request.url.toString()
|
||||
Timber.d("Loading resource: $url")
|
||||
|
||||
try {
|
||||
if (url.endsWith(".css") && extractedPath != null) {
|
||||
val possiblePaths = listOf(
|
||||
url.substringAfterLast("/"),
|
||||
"Styles/${url.substringAfterLast("/")}",
|
||||
url.substringAfter("/extracted/")
|
||||
)
|
||||
|
||||
for (cssPath in possiblePaths) {
|
||||
val cssFile = File(extractedPath!!, cssPath)
|
||||
if (cssFile.exists()) {
|
||||
Timber.d("Found CSS file at: ${cssFile.absolutePath}")
|
||||
return WebResourceResponse(
|
||||
"text/css",
|
||||
"UTF-8",
|
||||
cssFile.inputStream()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error intercepting resource request")
|
||||
}
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
allowFileAccess = true
|
||||
@Suppress("DEPRECATION")
|
||||
allowFileAccessFromFileURLs = true
|
||||
@Suppress("DEPRECATION")
|
||||
allowUniversalAccessFromFileURLs = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
domStorageEnabled = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
|
||||
// Add these specific scrolling optimizations
|
||||
@Suppress("DEPRECATION")
|
||||
setRenderPriority(WebSettings.RenderPriority.HIGH)
|
||||
|
||||
// Disable features that might cause stuttering
|
||||
loadsImagesAutomatically = true
|
||||
blockNetworkImage = true // Since we're reading locally
|
||||
blockNetworkLoads = true // Since we're reading locally
|
||||
|
||||
// Enable hardware acceleration
|
||||
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
}
|
||||
|
||||
// Set better scrolling properties
|
||||
overScrollMode = View.OVER_SCROLL_NEVER
|
||||
isVerticalScrollBarEnabled = false // Hide scrollbar for smoother scrolling
|
||||
|
||||
// Set scroll sensitivity
|
||||
@Suppress("DEPRECATION")
|
||||
setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY)
|
||||
|
||||
// Enable smooth scrolling
|
||||
isScrollContainer = true
|
||||
|
||||
// Set better touch handling
|
||||
isNestedScrollingEnabled = true
|
||||
|
||||
}.also { webView = it }
|
||||
},
|
||||
update = { view ->
|
||||
val chapter = chapters.getOrNull(currentChapterIndex)?.resource
|
||||
chapter?.let { resource ->
|
||||
scope.launch {
|
||||
try {
|
||||
extractedPath = bookStore.getBook(bookId)?.extractedPath
|
||||
?: throw IllegalStateException("Book not properly extracted")
|
||||
|
||||
val baseUrl = "file://$extractedPath/"
|
||||
val fullUrl = baseUrl + resource.href.replace("../", "")
|
||||
|
||||
Timber.d("Loading chapter from: $fullUrl")
|
||||
Timber.d("Base URL: $baseUrl")
|
||||
|
||||
view.loadUrl(fullUrl)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error loading chapter")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
if (chapters.isNotEmpty()) {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { showControls = !showControls }
|
||||
)
|
||||
},
|
||||
factory = { context ->
|
||||
var extractedPath: String? = null
|
||||
|
||||
WebView(context).apply {
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?
|
||||
) {
|
||||
Timber.e("WebView error loading ${request?.url}: ${error?.description}")
|
||||
super.onReceivedError(view, request, error)
|
||||
}
|
||||
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest
|
||||
): WebResourceResponse? {
|
||||
val url = request.url.toString()
|
||||
Timber.d("Loading resource: $url")
|
||||
|
||||
// Try to load the resource from the extracted directory
|
||||
try {
|
||||
if (url.endsWith(".css") && extractedPath != null) {
|
||||
// Try multiple possible CSS file locations
|
||||
val possiblePaths = listOf(
|
||||
url.substringAfterLast("/"), // stylesheet.css
|
||||
"Styles/${url.substringAfterLast("/")}", // Styles/stylesheet.css
|
||||
url.substringAfter("/extracted/") // Text/Styles/stylesheet.css
|
||||
)
|
||||
|
||||
for (cssPath in possiblePaths) {
|
||||
val cssFile = File(File(extractedPath!!), cssPath)
|
||||
if (cssFile.exists()) {
|
||||
Timber.d("Found CSS file at: ${cssFile.absolutePath}")
|
||||
return WebResourceResponse(
|
||||
"text/css",
|
||||
"UTF-8",
|
||||
cssFile.inputStream()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Timber.e("CSS file not found. Tried paths: ${possiblePaths.joinToString()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error intercepting resource request")
|
||||
}
|
||||
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
}
|
||||
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
allowFileAccess = true
|
||||
allowFileAccessFromFileURLs = true
|
||||
allowUniversalAccessFromFileURLs = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
domStorageEnabled = true
|
||||
|
||||
// Remove deprecated calls
|
||||
// setAppCacheEnabled and setAppCachePath are deprecated
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
}
|
||||
}.also { webView ->
|
||||
// Update extractedPath when loading URLs
|
||||
scope.launch {
|
||||
try {
|
||||
extractedPath = bookStore.getBook(bookId)?.extractedPath
|
||||
?: throw IllegalStateException("Book not properly extracted")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error getting extracted path")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
val chapter = chapters.getOrNull(currentChapterIndex)?.resource
|
||||
chapter?.let { resource ->
|
||||
scope.launch {
|
||||
try {
|
||||
val extractedPath = bookStore.getBook(bookId)?.extractedPath
|
||||
?: throw IllegalStateException("Book not properly extracted")
|
||||
|
||||
val baseUrl = "file://$extractedPath/"
|
||||
val fullUrl = baseUrl + resource.href.replace("../", "")
|
||||
|
||||
Timber.d("Loading chapter from: $fullUrl")
|
||||
Timber.d("Base URL: $baseUrl")
|
||||
|
||||
webView.loadUrl(fullUrl)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error loading chapter")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(bottom = if (showControls) SWIPE_AREA_HEIGHT.dp else 0.dp)
|
||||
)
|
||||
|
||||
// Navigation controls
|
||||
AnimatedVisibility(
|
||||
visible = showControls,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
enter = fadeIn() + slideInVertically { it },
|
||||
exit = fadeOut() + slideOutVertically { it }
|
||||
// Add swipe area at the bottom with tap gesture
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(SWIPE_AREA_HEIGHT.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { showControls = !showControls }
|
||||
)
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
var initialX = 0f
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { offset ->
|
||||
initialX = offset.x
|
||||
},
|
||||
onDragEnd = {
|
||||
val dragThreshold = size.width * 0.2f // 20% of screen width
|
||||
val dragDistance = initialX - currentX
|
||||
|
||||
when {
|
||||
dragDistance > dragThreshold && currentChapterIndex < chapters.size - 1 -> {
|
||||
// Swiped left - next chapter
|
||||
currentChapterIndex++
|
||||
}
|
||||
dragDistance < -dragThreshold && currentChapterIndex > 0 -> {
|
||||
// Swiped right - previous chapter
|
||||
currentChapterIndex--
|
||||
}
|
||||
}
|
||||
currentX = 0f
|
||||
}
|
||||
) { change, dragAmount ->
|
||||
currentX = change.position.x
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Controls overlay
|
||||
AnimatedVisibility(
|
||||
visible = showControls,
|
||||
enter = slideInVertically(initialOffsetY = { it }),
|
||||
exit = slideOutVertically(targetOffsetY = { it }),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
||||
tonalElevation = 3.dp,
|
||||
shape = MaterialTheme.shapes.large
|
||||
) {
|
||||
Column {
|
||||
// Chapter progress
|
||||
LinearProgressIndicator(
|
||||
progress = { currentChapterIndex.toFloat() / (chapters.size - 1).coerceAtLeast(1).toFloat() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
|
||||
// Controls
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Previous Chapter Button
|
||||
FilledTonalButton(
|
||||
// Previous chapter
|
||||
IconButton(
|
||||
onClick = { if (currentChapterIndex > 0) currentChapterIndex-- },
|
||||
enabled = currentChapterIndex > 0,
|
||||
modifier = Modifier.weight(1f)
|
||||
enabled = currentChapterIndex > 0
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateBefore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Back")
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.NavigateBefore,
|
||||
contentDescription = "Previous Chapter"
|
||||
)
|
||||
}
|
||||
|
||||
// Chapter Counter
|
||||
Text(
|
||||
text = "${currentChapterIndex + 1} / ${chapters.size}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
// Next Chapter Button
|
||||
FilledTonalButton(
|
||||
onClick = { if (currentChapterIndex < chapters.size - 1) currentChapterIndex++ },
|
||||
enabled = currentChapterIndex < chapters.size - 1,
|
||||
modifier = Modifier.weight(1f)
|
||||
// Chapter selector
|
||||
TextButton(
|
||||
onClick = { showChapterList = true }
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Next")
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateNext,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Chapter ${currentChapterIndex + 1}/${chapters.size}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
// Next chapter
|
||||
IconButton(
|
||||
onClick = { if (currentChapterIndex < chapters.size - 1) currentChapterIndex++ },
|
||||
enabled = currentChapterIndex < chapters.size - 1
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.NavigateNext,
|
||||
contentDescription = "Next Chapter"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,164 +417,4 @@ fun ReaderScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Add Settings Dialog
|
||||
if (showSettings) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSettings = false },
|
||||
title = {
|
||||
Text(
|
||||
"Reader Settings",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Font Size Slider
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Font Size",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
"${currentSettings.fontSize.toInt()}sp",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = currentSettings.fontSize,
|
||||
onValueChange = { newSize ->
|
||||
scope.launch {
|
||||
val newSettings = currentSettings.copy(fontSize = newSize)
|
||||
settingsStore.saveSettings(newSettings)
|
||||
currentSettings = newSettings
|
||||
}
|
||||
},
|
||||
valueRange = 12f..24f,
|
||||
steps = 11
|
||||
)
|
||||
}
|
||||
|
||||
// Line Height Slider
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Line Height",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
"%.1fx".format(currentSettings.lineHeight),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = currentSettings.lineHeight,
|
||||
onValueChange = { newHeight ->
|
||||
scope.launch {
|
||||
val newSettings = currentSettings.copy(lineHeight = newHeight)
|
||||
settingsStore.saveSettings(newSettings)
|
||||
currentSettings = newSettings
|
||||
}
|
||||
},
|
||||
valueRange = 1f..2f,
|
||||
steps = 9
|
||||
)
|
||||
}
|
||||
|
||||
// Padding Slider
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Margin",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
"${currentSettings.padding.toInt()}dp",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = currentSettings.padding,
|
||||
onValueChange = { newPadding ->
|
||||
scope.launch {
|
||||
val newSettings = currentSettings.copy(padding = newPadding)
|
||||
settingsStore.saveSettings(newSettings)
|
||||
currentSettings = newSettings
|
||||
}
|
||||
},
|
||||
valueRange = 8f..32f,
|
||||
steps = 11
|
||||
)
|
||||
}
|
||||
|
||||
// Font Family Dropdown
|
||||
Column {
|
||||
Text(
|
||||
"Font Family",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
val fontFamilies = listOf("Georgia", "Roboto", "Times New Roman", "Arial", "Verdana")
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = currentSettings.fontFamily,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
fontFamilies.forEach { font ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(font) },
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val newSettings = currentSettings.copy(fontFamily = font)
|
||||
settingsStore.saveSettings(newSettings)
|
||||
currentSettings = newSettings
|
||||
}
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showSettings = false }) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -88,35 +88,55 @@ class BookStore(private val context: Context) {
|
||||
|
||||
private fun extractEpub(book: EpubBook, baseDirectory: File) {
|
||||
try {
|
||||
// First extract CSS files
|
||||
// First extract fonts and CSS files
|
||||
book.resources.all
|
||||
.filter { resource ->
|
||||
resource.mediaType?.toString()?.contains("css") ?: false
|
||||
val mediaType = resource.mediaType?.toString() ?: ""
|
||||
mediaType.contains("css") ||
|
||||
mediaType.contains("font") ||
|
||||
resource.href.endsWith(".otf") ||
|
||||
resource.href.endsWith(".ttf") ||
|
||||
resource.href.endsWith(".woff") ||
|
||||
resource.href.endsWith(".woff2")
|
||||
}
|
||||
.forEach { resource ->
|
||||
val cssPath = resource.href
|
||||
.replace("../", "")
|
||||
.replace("./", "")
|
||||
// Extract to both original location and root fonts directory
|
||||
val paths = listOf(
|
||||
resource.href.replace("../", "").replace("./", ""),
|
||||
"Fonts/${resource.href.substringAfterLast("/")}",
|
||||
"Text/Fonts/${resource.href.substringAfterLast("/")}"
|
||||
).distinct()
|
||||
|
||||
val resourceFile = File(baseDirectory, cssPath)
|
||||
resourceFile.parentFile?.mkdirs()
|
||||
resourceFile.writeBytes(resource.data)
|
||||
|
||||
Timber.d("""
|
||||
CSS File Extracted:
|
||||
- Original href: ${resource.href}
|
||||
- Final path: $cssPath
|
||||
- Full path: ${resourceFile.absolutePath}
|
||||
- Size: ${resourceFile.length()}
|
||||
- Exists: ${resourceFile.exists()}
|
||||
- Content sample: ${String(resource.data.take(100).toByteArray())}
|
||||
""".trimIndent())
|
||||
paths.forEach { path ->
|
||||
val resourceFile = File(baseDirectory, path)
|
||||
resourceFile.parentFile?.mkdirs()
|
||||
resourceFile.writeBytes(resource.data)
|
||||
|
||||
// Log font file extraction
|
||||
if (resource.href.matches(Regex(".+\\.(otf|ttf|woff|woff2)$"))) {
|
||||
Timber.d("""
|
||||
Font File Extracted:
|
||||
- Original href: ${resource.href}
|
||||
- Path: $path
|
||||
- Full path: ${resourceFile.absolutePath}
|
||||
- Size: ${resourceFile.length()}
|
||||
- Exists: ${resourceFile.exists()}
|
||||
- Media Type: ${resource.mediaType}
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then extract HTML files
|
||||
// Then extract remaining files
|
||||
book.resources.all
|
||||
.filter { resource ->
|
||||
!(resource.mediaType?.toString()?.contains("css") ?: false)
|
||||
val mediaType = resource.mediaType?.toString() ?: ""
|
||||
!(mediaType.contains("css") ||
|
||||
mediaType.contains("font") ||
|
||||
resource.href.endsWith(".otf") ||
|
||||
resource.href.endsWith(".ttf") ||
|
||||
resource.href.endsWith(".woff") ||
|
||||
resource.href.endsWith(".woff2"))
|
||||
}
|
||||
.forEach { resource ->
|
||||
val resourcePath = resource.href.replace("../", "").replace("./", "")
|
||||
@@ -133,26 +153,34 @@ class BookStore(private val context: Context) {
|
||||
.escapeMode(org.jsoup.nodes.Entities.EscapeMode.xhtml)
|
||||
.prettyPrint(false)
|
||||
|
||||
// Ensure head section exists
|
||||
var head = doc.head()
|
||||
if (head == null) {
|
||||
head = doc.createElement("head")
|
||||
doc.prependChild(head)
|
||||
}
|
||||
|
||||
// Process CSS links
|
||||
doc.select("link[rel=stylesheet]").forEach { link ->
|
||||
val cssHref = link.attr("href")
|
||||
.replace("../", "")
|
||||
.replace("./", "")
|
||||
|
||||
// Create a properly formatted self-closing link tag
|
||||
link.tagName("link")
|
||||
.attr("rel", "stylesheet")
|
||||
.attr("type", "text/css")
|
||||
.attr("href", cssHref)
|
||||
}
|
||||
|
||||
// Update font references in CSS and style tags
|
||||
doc.select("style").forEach { style ->
|
||||
val cssContent = style.html()
|
||||
val updatedCss = cssContent.replace(
|
||||
Regex("url\\(['\"]?(.*?)['\"]?\\)"),
|
||||
{ matchResult ->
|
||||
val fontPath = matchResult.groupValues[1]
|
||||
.replace("../", "")
|
||||
.replace("./", "")
|
||||
.substringAfterLast("/")
|
||||
"url('Fonts/$fontPath')"
|
||||
}
|
||||
)
|
||||
style.html(updatedCss)
|
||||
}
|
||||
|
||||
// Write properly formatted XHTML
|
||||
val xhtml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
||||
@@ -170,6 +198,9 @@ class BookStore(private val context: Context) {
|
||||
// Set permissions
|
||||
baseDirectory.walk().forEach { file ->
|
||||
file.setReadable(true, false)
|
||||
if (file.isDirectory) {
|
||||
file.setExecutable(true, false)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error extracting EPUB: ${e.message}")
|
||||
@@ -192,6 +223,27 @@ class BookStore(private val context: Context) {
|
||||
// Extract EPUB contents
|
||||
val extractedDirectory = File(bookDirectory, "extracted")
|
||||
extractedDirectory.mkdirs()
|
||||
|
||||
// Extract and save cover image
|
||||
var coverPath: String? = null
|
||||
|
||||
// Try to get cover from the book's resources
|
||||
epubBook.resources.all
|
||||
.firstOrNull { resource ->
|
||||
resource.mediaType?.toString()?.contains("image") == true &&
|
||||
(resource.href.contains("cover") || resource.id?.contains("cover") == true)
|
||||
}?.let { coverResource ->
|
||||
val coverFile = File(bookDirectory, "cover.jpg")
|
||||
coverFile.writeBytes(coverResource.data)
|
||||
coverPath = "file://${coverFile.absolutePath}"
|
||||
Timber.d("Saved cover image from resources to: $coverPath")
|
||||
} ?: epubBook.coverImage?.let { cover ->
|
||||
val coverFile = File(bookDirectory, "cover.jpg")
|
||||
coverFile.writeBytes(cover.data)
|
||||
coverPath = "file://${coverFile.absolutePath}"
|
||||
Timber.d("Saved direct cover image to: $coverPath")
|
||||
}
|
||||
|
||||
extractEpub(epubBook, extractedDirectory)
|
||||
|
||||
// Save book metadata
|
||||
@@ -201,7 +253,7 @@ class BookStore(private val context: Context) {
|
||||
author = epubBook.metadata.authors.firstOrNull()?.toString() ?: "Unknown",
|
||||
path = epubFile.absolutePath,
|
||||
extractedPath = extractedDirectory.absolutePath,
|
||||
coverPath = null // Handle cover separately if needed
|
||||
coverPath = coverPath
|
||||
)
|
||||
|
||||
bookDao.insert(book.toEntity())
|
||||
|
||||
Reference in New Issue
Block a user