6 Commits

Author SHA1 Message Date
inhale 14dbd76477 Update README.md 2024-12-13 15:56:17 +01:00
inhale-dir 1122e06437 fixes & removed settings 2024-12-13 15:43:16 +01:00
inhale-dir 3295816550 bug fixes after changes 2024-12-13 15:30:29 +01:00
inhale-dir 6e795bd7d1 first official release 2024-12-13 15:14:13 +01:00
inhale-dir d73f54eeb4 book cover fixed 2024-12-13 15:01:09 +01:00
inhale-dir 9400ff5535 fixed font rendering 2024-12-13 14:49:56 +01:00
4 changed files with 556 additions and 477 deletions
+79
View File
@@ -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.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MenuBook
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState 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.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.ui.input.pointer.pointerInput 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) @OptIn(ExperimentalMaterial3Api::class)
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -53,7 +64,6 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
val navController = rememberNavController() val navController = rememberNavController()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
EpookTheme { EpookTheme {
NavHost( NavHost(
@@ -64,8 +74,7 @@ class MainActivity : ComponentActivity() {
MainScreen( MainScreen(
navController = navController, navController = navController,
bookStore = bookStore, bookStore = bookStore,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState
scope = scope
) )
} }
@@ -90,16 +99,18 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun MainScreen( fun MainScreen(
navController: NavController, navController: NavController,
bookStore: BookStore, bookStore: BookStore,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
scope: CoroutineScope modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
val books by bookStore.getAllBooks().collectAsState(initial = emptyList()) 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 { scope.launch {
try { try {
val inputStream = context.contentResolver.openInputStream(uri) val inputStream = context.contentResolver.openInputStream(uri)
@@ -134,15 +145,65 @@ private fun MainScreen(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetContent()
) { uri -> ) { uri ->
if (uri != null) { 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( 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) }, snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { launcher.launch("application/epub+zip") } onClick = { launcher.launch("application/epub+zip") },
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) { ) {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
@@ -151,25 +212,61 @@ private fun MainScreen(
} }
} }
) { padding -> ) { padding ->
if (books.isEmpty()) {
EmptyLibraryMessage(
modifier = Modifier
.fillMaxSize()
.padding(padding)
)
} else {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp), columns = GridCells.Adaptive(minSize = 140.dp),
contentPadding = padding, contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize() horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier
.fillMaxSize()
.padding(padding)
) { ) {
items(books) { book -> items(books) { book ->
BookCard( BookCard(
book = book, book = book,
onClick = { navController.navigate("reader/${book.id}") }, onClick = { navController.navigate("reader/${book.id}") },
onLongClick = { onLongClick = { bookToDelete = book }
scope.launch {
bookStore.deleteBook(book.id)
}
}
) )
} }
} }
} }
} }
}
@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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -182,53 +279,73 @@ private fun BookCard(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
Column(
modifier = modifier.width(140.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ElevatedCard( ElevatedCard(
modifier = modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(0.7f) .aspectRatio(0.67f) // Standard book cover ratio
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onTap = { onClick() }, onTap = { onClick() },
onLongPress = { onLongClick() } onLongPress = { onLongClick() }
) )
}, }
elevation = CardDefaults.elevatedCardElevation( .shadow(
defaultElevation = 4.dp, elevation = if (isPressed) 8.dp else 4.dp,
pressedElevation = 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()) { Box(modifier = Modifier.fillMaxSize()) {
// Book cover
AsyncImage( AsyncImage(
model = book.coverPath ?: R.drawable.ic_launcher_background, model = book.coverPath ?: R.drawable.ic_book_24dp,
contentDescription = book.title, contentDescription = book.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
) )
Surface( // Gradient overlay
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter), .height(80.dp)
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f) .align(Alignment.BottomCenter)
) { .background(
Column( brush = Brush.verticalGradient(
modifier = Modifier.padding(12.dp) colors = listOf(
) { Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
)
)
)
}
}
// Book info below the card
Text( Text(
text = book.title, text = book.title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleSmall,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp)
) )
Text( Text(
text = book.author, text = book.author,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 2.dp)
) )
} }
} }
}
}
}
+132 -301
View File
@@ -7,6 +7,7 @@ import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import inhale.rip.epook.data.BookStore 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.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jsoup.Jsoup import org.jsoup.Jsoup
@@ -49,6 +48,7 @@ import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse import android.webkit.WebResourceResponse
import android.webkit.WebResourceError import android.webkit.WebResourceError
import android.webkit.WebSettings import android.webkit.WebSettings
import android.view.View
// Move the Chapter data class outside the composable // Move the Chapter data class outside the composable
private data class Chapter( private data class Chapter(
@@ -57,6 +57,9 @@ private data class Chapter(
val resource: Resource val resource: Resource
) )
// First, add a constant for the swipe area height
private const val SWIPE_AREA_HEIGHT = 80 // in dp
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ReaderScreen( fun ReaderScreen(
@@ -65,9 +68,7 @@ fun ReaderScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val bookStore = remember { BookStore(context) } val bookStore = remember { BookStore(context) }
val settingsStore = remember { SettingsStore(context) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var currentSettings by remember { mutableStateOf(Settings()) }
var currentChapterIndex by remember { mutableStateOf(0) } var currentChapterIndex by remember { mutableStateOf(0) }
var chapters by remember { mutableStateOf<List<Chapter>>(emptyList()) } var chapters by remember { mutableStateOf<List<Chapter>>(emptyList()) }
@@ -75,19 +76,11 @@ fun ReaderScreen(
var showControls by remember { mutableStateOf(true) } var showControls by remember { mutableStateOf(true) }
var showChapterList by remember { mutableStateOf(false) } var showChapterList by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb() var currentX by remember { mutableStateOf(0f) }
val textColor = MaterialTheme.colorScheme.onBackground.toArgb()
val primaryColor = MaterialTheme.colorScheme.primary.toArgb()
LaunchedEffect(Unit) { var extractedPath by remember { mutableStateOf<String?>(null) }
try { var webView by remember { mutableStateOf<WebView?>(null) }
currentSettings = settingsStore.getSettings()
} catch (e: Exception) {
Timber.e(e, "Error loading settings")
}
}
LaunchedEffect(bookId) { LaunchedEffect(bookId) {
try { try {
@@ -147,63 +140,14 @@ 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)
)
}
},
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")
}
}
)
}
}
) { padding ->
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .background(MaterialTheme.colorScheme.background)
) { ) {
if (chapters.isNotEmpty()) { // WebView without swipe gesture
AndroidView( AndroidView(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onTap = { showControls = !showControls }
)
},
factory = { context -> factory = { context ->
var extractedPath: String? = null
WebView(context).apply { WebView(context).apply {
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
override fun onReceivedError( override fun onReceivedError(
@@ -222,18 +166,16 @@ fun ReaderScreen(
val url = request.url.toString() val url = request.url.toString()
Timber.d("Loading resource: $url") Timber.d("Loading resource: $url")
// Try to load the resource from the extracted directory
try { try {
if (url.endsWith(".css") && extractedPath != null) { if (url.endsWith(".css") && extractedPath != null) {
// Try multiple possible CSS file locations
val possiblePaths = listOf( val possiblePaths = listOf(
url.substringAfterLast("/"), // stylesheet.css url.substringAfterLast("/"),
"Styles/${url.substringAfterLast("/")}", // Styles/stylesheet.css "Styles/${url.substringAfterLast("/")}",
url.substringAfter("/extracted/") // Text/Styles/stylesheet.css url.substringAfter("/extracted/")
) )
for (cssPath in possiblePaths) { for (cssPath in possiblePaths) {
val cssFile = File(File(extractedPath!!), cssPath) val cssFile = File(extractedPath!!, cssPath)
if (cssFile.exists()) { if (cssFile.exists()) {
Timber.d("Found CSS file at: ${cssFile.absolutePath}") Timber.d("Found CSS file at: ${cssFile.absolutePath}")
return WebResourceResponse( return WebResourceResponse(
@@ -243,13 +185,10 @@ fun ReaderScreen(
) )
} }
} }
Timber.e("CSS file not found. Tried paths: ${possiblePaths.joinToString()}")
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error intercepting resource request") Timber.e(e, "Error intercepting resource request")
} }
return super.shouldInterceptRequest(view, request) return super.shouldInterceptRequest(view, request)
} }
} }
@@ -259,33 +198,49 @@ fun ReaderScreen(
builtInZoomControls = true builtInZoomControls = true
displayZoomControls = false displayZoomControls = false
allowFileAccess = true allowFileAccess = true
@Suppress("DEPRECATION")
allowFileAccessFromFileURLs = true allowFileAccessFromFileURLs = true
@Suppress("DEPRECATION")
allowUniversalAccessFromFileURLs = true allowUniversalAccessFromFileURLs = true
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
domStorageEnabled = true domStorageEnabled = true
// Remove deprecated calls
// setAppCacheEnabled and setAppCachePath are deprecated
cacheMode = WebSettings.LOAD_DEFAULT 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)
} }
}.also { webView ->
// Update extractedPath when loading URLs // Set better scrolling properties
scope.launch { overScrollMode = View.OVER_SCROLL_NEVER
try { isVerticalScrollBarEnabled = false // Hide scrollbar for smoother scrolling
extractedPath = bookStore.getBook(bookId)?.extractedPath
?: throw IllegalStateException("Book not properly extracted") // Set scroll sensitivity
} catch (e: Exception) { @Suppress("DEPRECATION")
Timber.e(e, "Error getting extracted path") setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY)
}
} // Enable smooth scrolling
} isScrollContainer = true
// Set better touch handling
isNestedScrollingEnabled = true
}.also { webView = it }
}, },
update = { webView -> update = { view ->
val chapter = chapters.getOrNull(currentChapterIndex)?.resource val chapter = chapters.getOrNull(currentChapterIndex)?.resource
chapter?.let { resource -> chapter?.let { resource ->
scope.launch { scope.launch {
try { try {
val extractedPath = bookStore.getBook(bookId)?.extractedPath extractedPath = bookStore.getBook(bookId)?.extractedPath
?: throw IllegalStateException("Book not properly extracted") ?: throw IllegalStateException("Book not properly extracted")
val baseUrl = "file://$extractedPath/" val baseUrl = "file://$extractedPath/"
@@ -294,83 +249,119 @@ fun ReaderScreen(
Timber.d("Loading chapter from: $fullUrl") Timber.d("Loading chapter from: $fullUrl")
Timber.d("Base URL: $baseUrl") Timber.d("Base URL: $baseUrl")
webView.loadUrl(fullUrl) view.loadUrl(fullUrl)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error loading chapter") Timber.e(e, "Error loading chapter")
} }
} }
} }
} },
modifier = Modifier
.fillMaxSize()
.padding(bottom = if (showControls) SWIPE_AREA_HEIGHT.dp else 0.dp)
)
// 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
// Navigation controls 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( AnimatedVisibility(
visible = showControls, visible = showControls,
modifier = Modifier.align(Alignment.BottomCenter), enter = slideInVertically(initialOffsetY = { it }),
enter = fadeIn() + slideInVertically { it }, exit = slideOutVertically(targetOffsetY = { it }),
exit = fadeOut() + slideOutVertically { it } modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
) { ) {
Surface( Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
tonalElevation = 8.dp,
shadowElevation = 8.dp
) {
Column {
// Chapter progress
LinearProgressIndicator(
progress = { currentChapterIndex.toFloat() / (chapters.size - 1).coerceAtLeast(1).toFloat() },
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
)
// Controls
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
tonalElevation = 3.dp,
shape = MaterialTheme.shapes.large
) {
Row(
modifier = Modifier
.padding(horizontal = 24.dp, vertical = 16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Previous Chapter Button // Previous chapter
FilledTonalButton( IconButton(
onClick = { if (currentChapterIndex > 0) currentChapterIndex-- }, onClick = { if (currentChapterIndex > 0) currentChapterIndex-- },
enabled = currentChapterIndex > 0, enabled = currentChapterIndex > 0
modifier = Modifier.weight(1f)
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
Icons.AutoMirrored.Filled.NavigateBefore, imageVector = Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = null, contentDescription = "Previous Chapter"
modifier = Modifier.size(20.dp)
) )
Spacer(Modifier.width(4.dp))
Text("Back")
}
} }
// Chapter Counter // Chapter selector
TextButton(
onClick = { showChapterList = true }
) {
Text( Text(
text = "${currentChapterIndex + 1} / ${chapters.size}", text = "Chapter ${currentChapterIndex + 1}/${chapters.size}",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodyMedium
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)
) {
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)
) )
} }
// 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) { private fun extractEpub(book: EpubBook, baseDirectory: File) {
try { try {
// First extract CSS files // First extract fonts and CSS files
book.resources.all book.resources.all
.filter { resource -> .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 -> .forEach { resource ->
val cssPath = resource.href // Extract to both original location and root fonts directory
.replace("../", "") val paths = listOf(
.replace("./", "") resource.href.replace("../", "").replace("./", ""),
"Fonts/${resource.href.substringAfterLast("/")}",
"Text/Fonts/${resource.href.substringAfterLast("/")}"
).distinct()
val resourceFile = File(baseDirectory, cssPath) paths.forEach { path ->
val resourceFile = File(baseDirectory, path)
resourceFile.parentFile?.mkdirs() resourceFile.parentFile?.mkdirs()
resourceFile.writeBytes(resource.data) resourceFile.writeBytes(resource.data)
// Log font file extraction
if (resource.href.matches(Regex(".+\\.(otf|ttf|woff|woff2)$"))) {
Timber.d(""" Timber.d("""
CSS File Extracted: Font File Extracted:
- Original href: ${resource.href} - Original href: ${resource.href}
- Final path: $cssPath - Path: $path
- Full path: ${resourceFile.absolutePath} - Full path: ${resourceFile.absolutePath}
- Size: ${resourceFile.length()} - Size: ${resourceFile.length()}
- Exists: ${resourceFile.exists()} - Exists: ${resourceFile.exists()}
- Content sample: ${String(resource.data.take(100).toByteArray())} - Media Type: ${resource.mediaType}
""".trimIndent()) """.trimIndent())
} }
}
}
// Then extract HTML files // Then extract remaining files
book.resources.all book.resources.all
.filter { resource -> .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 -> .forEach { resource ->
val resourcePath = resource.href.replace("../", "").replace("./", "") val resourcePath = resource.href.replace("../", "").replace("./", "")
@@ -133,26 +153,34 @@ class BookStore(private val context: Context) {
.escapeMode(org.jsoup.nodes.Entities.EscapeMode.xhtml) .escapeMode(org.jsoup.nodes.Entities.EscapeMode.xhtml)
.prettyPrint(false) .prettyPrint(false)
// Ensure head section exists
var head = doc.head()
if (head == null) {
head = doc.createElement("head")
doc.prependChild(head)
}
// Process CSS links // Process CSS links
doc.select("link[rel=stylesheet]").forEach { link -> doc.select("link[rel=stylesheet]").forEach { link ->
val cssHref = link.attr("href") val cssHref = link.attr("href")
.replace("../", "") .replace("../", "")
.replace("./", "") .replace("./", "")
// Create a properly formatted self-closing link tag
link.tagName("link") link.tagName("link")
.attr("rel", "stylesheet") .attr("rel", "stylesheet")
.attr("type", "text/css") .attr("type", "text/css")
.attr("href", cssHref) .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 // Write properly formatted XHTML
val xhtml = """<?xml version="1.0" encoding="UTF-8"?> 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"> <!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 // Set permissions
baseDirectory.walk().forEach { file -> baseDirectory.walk().forEach { file ->
file.setReadable(true, false) file.setReadable(true, false)
if (file.isDirectory) {
file.setExecutable(true, false)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error extracting EPUB: ${e.message}") Timber.e(e, "Error extracting EPUB: ${e.message}")
@@ -192,6 +223,27 @@ class BookStore(private val context: Context) {
// Extract EPUB contents // Extract EPUB contents
val extractedDirectory = File(bookDirectory, "extracted") val extractedDirectory = File(bookDirectory, "extracted")
extractedDirectory.mkdirs() 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) extractEpub(epubBook, extractedDirectory)
// Save book metadata // Save book metadata
@@ -201,7 +253,7 @@ class BookStore(private val context: Context) {
author = epubBook.metadata.authors.firstOrNull()?.toString() ?: "Unknown", author = epubBook.metadata.authors.firstOrNull()?.toString() ?: "Unknown",
path = epubFile.absolutePath, path = epubFile.absolutePath,
extractedPath = extractedDirectory.absolutePath, extractedPath = extractedDirectory.absolutePath,
coverPath = null // Handle cover separately if needed coverPath = coverPath
) )
bookDao.insert(book.toEntity()) bookDao.insert(book.toEntity())