Compare commits
7 Commits
0b1b327b23
...
1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1122e06437 | |||
| 3295816550 | |||
| 6e795bd7d1 | |||
| d73f54eeb4 | |||
| 9400ff5535 | |||
| 1d3daf5c8c | |||
| adcee47523 |
@@ -0,0 +1,106 @@
|
|||||||
|
# 📚 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
|
||||||
|
|
||||||
|
## 🎨 App Icon Design
|
||||||
|
|
||||||
|
### Primary Design
|
||||||
|
A minimalist, modern book icon featuring:
|
||||||
|
- A stylized open book in Material Design style
|
||||||
|
- Primary color: Deep Purple (#6750A4) with white pages
|
||||||
|
- Subtle gradient background from lighter to darker purple
|
||||||
|
- Rounded corners following Material Design 3 guidelines
|
||||||
|
- Clean, simple lines with minimal detail
|
||||||
|
|
||||||
|
### Specifications
|
||||||
|
- Size: 512x512px (Play Store master icon)
|
||||||
|
- Adaptive Icon Layers:
|
||||||
|
- Foreground: Book icon in white/light purple
|
||||||
|
- Background: Gradient from #6750A4 to #4F378B
|
||||||
|
- Safe zone: 384x384px centered
|
||||||
|
- Corner radius: 100dp (Material 3 spec)
|
||||||
|
|
||||||
|
### Alternative Sizes
|
||||||
|
- 48x48dp (mdpi)
|
||||||
|
- 72x72dp (hdpi)
|
||||||
|
- 96x96dp (xhdpi)
|
||||||
|
- 144x144dp (xxhdpi)
|
||||||
|
- 192x192dp (xxxhdpi)
|
||||||
|
|
||||||
|
### Design Elements
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,7 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
composeOptions {
|
||||||
@@ -148,4 +149,8 @@ dependencies {
|
|||||||
implementation("com.google.android.material:material:1.11.0")
|
implementation("com.google.android.material:material:1.11.0")
|
||||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||||
implementation("androidx.legacy:legacy-support-v4:1.0.0")
|
implementation("androidx.legacy:legacy-support-v4:1.0.0")
|
||||||
|
|
||||||
|
// Add these dependencies
|
||||||
|
implementation("org.json:json:20231013")
|
||||||
|
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Epook">
|
android:theme="@style/Theme.Epook"
|
||||||
|
android:requestLegacyExternalStorage="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -5,240 +5,172 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
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.ArrowBack
|
import androidx.compose.material.icons.filled.MenuBook
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.automirrored.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.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import androidx.navigation.NavController
|
||||||
import inhale.rip.epook.data.Book
|
|
||||||
import inhale.rip.epook.ui.theme.EpookTheme
|
|
||||||
import nl.siegmann.epublib.epub.EpubReader
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import inhale.rip.epook.data.BookStore
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import androidx.navigation.NavType
|
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.navigation.NavType
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import inhale.rip.epook.data.Book
|
||||||
|
import inhale.rip.epook.data.BookStore
|
||||||
|
import inhale.rip.epook.ui.theme.EpookTheme
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
|
||||||
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.runtime.remember
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import kotlinx.coroutines.flow.map
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
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.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.FloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LargeTopAppBar
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shadow
|
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.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
import androidx.compose.material3.SmallTopAppBar
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
// import androidx.navigation.compose.NavHostController
|
|
||||||
import inhale.rip.epook.data.SettingsStore
|
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
|
||||||
|
|
||||||
// Add these imports at the top
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
|
||||||
// import androidx.compose.ui.text.style.TextAlign
|
|
||||||
|
|
||||||
// Add sealed class for navigation routes
|
|
||||||
sealed class Screen(val route: String) {
|
|
||||||
object BookList : Screen("bookList")
|
|
||||||
object Settings : Screen("settings")
|
|
||||||
object Reader : Screen("reader/{bookId}") {
|
|
||||||
fun createRoute(bookId: String) = "reader/$bookId"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private lateinit var bookStore: BookStore
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
bookStore = BookStore(this)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun App() {
|
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val context = LocalContext.current
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val settingsStore = remember(context) { SettingsStore(context) }
|
|
||||||
|
|
||||||
EpookTheme {
|
EpookTheme {
|
||||||
NavHost(navController = navController, startDestination = Screen.BookList.route) {
|
NavHost(
|
||||||
composable(Screen.BookList.route) {
|
navController = navController,
|
||||||
BookshelfScreen(
|
startDestination = "main"
|
||||||
onBookClick = { bookId ->
|
) {
|
||||||
navController.navigate(Screen.Reader.createRoute(bookId))
|
composable("main") {
|
||||||
}
|
MainScreen(
|
||||||
|
navController = navController,
|
||||||
|
bookStore = bookStore,
|
||||||
|
snackbarHostState = snackbarHostState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = Screen.Reader.route,
|
route = "reader/{bookId}",
|
||||||
arguments = listOf(navArgument("bookId") { type = NavType.StringType })
|
arguments = listOf(
|
||||||
|
navArgument("bookId") { type = NavType.StringType }
|
||||||
|
)
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val bookId = backStackEntry.arguments?.getString("bookId") ?: ""
|
val bookId = backStackEntry.arguments?.getString("bookId")
|
||||||
|
?: return@composable
|
||||||
ReaderScreen(
|
ReaderScreen(
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
onNavigateBack = { navController.navigateUp() }
|
onNavigateBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Settings.route) {
|
}
|
||||||
SettingsScreen(
|
|
||||||
onNavigateBack = { navController.navigateUp() },
|
|
||||||
settingsStore = settingsStore
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BookshelfScreen(onBookClick: (String) -> Unit) {
|
fun MainScreen(
|
||||||
|
navController: NavController,
|
||||||
|
bookStore: BookStore,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val bookStore = remember { BookStore(context) }
|
|
||||||
val books by bookStore.getAllBooks().collectAsState(initial = emptyList())
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val books by bookStore.getAllBooks().collectAsState(initial = emptyList())
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
|
||||||
var bookToDelete by remember { mutableStateOf<Book?>(null) }
|
var bookToDelete by remember { mutableStateOf<Book?>(null) }
|
||||||
var showEmptyState by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
fun importBook(uri: Uri) {
|
||||||
contract = ActivityResultContracts.GetContent()
|
scope.launch {
|
||||||
) { uri: Uri? ->
|
|
||||||
uri?.let { selectedUri ->
|
|
||||||
try {
|
try {
|
||||||
val inputStream = context.contentResolver.openInputStream(selectedUri)
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
val book = EpubReader().readEpub(inputStream)
|
?: throw IOException("Failed to open input stream")
|
||||||
|
|
||||||
val bookId = java.util.UUID.randomUUID().toString()
|
val tempFile = File(context.filesDir, "temp_${System.currentTimeMillis()}.epub")
|
||||||
|
inputStream.use { input ->
|
||||||
val epubFile = File(context.filesDir, "book_$bookId.epub")
|
tempFile.outputStream().use { output ->
|
||||||
context.contentResolver.openInputStream(selectedUri)?.use { input ->
|
|
||||||
FileOutputStream(epubFile).use { output ->
|
|
||||||
input.copyTo(output)
|
input.copyTo(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val coverFile = book.coverImage?.let { coverImage ->
|
try {
|
||||||
File(context.filesDir, "cover_$bookId.jpg").also { file ->
|
val bookId = bookStore.importBook(tempFile)
|
||||||
FileOutputStream(file).use { output ->
|
Timber.d("Successfully imported book with ID: $bookId")
|
||||||
output.write(coverImage.data)
|
navController.navigate("reader/$bookId")
|
||||||
}
|
} finally {
|
||||||
}
|
tempFile.delete()
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
bookStore.addBook(Book(
|
|
||||||
id = bookId,
|
|
||||||
title = book.title ?: "Unknown Title",
|
|
||||||
coverImageFile = coverFile,
|
|
||||||
filePath = epubFile.absolutePath
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Error saving book: ${e.message}")
|
Timber.e(e, "Error importing book")
|
||||||
e.printStackTrace()
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
message = "Failed to import book: ${e.localizedMessage}"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(books) {
|
val launcher = rememberLauncherForActivityResult(
|
||||||
showEmptyState = books.isEmpty()
|
contract = ActivityResultContracts.GetContent()
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
importBook(uri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDeleteDialog && bookToDelete != null) {
|
// Delete confirmation dialog
|
||||||
|
bookToDelete?.let { book ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showDeleteDialog = false },
|
onDismissRequest = { bookToDelete = null },
|
||||||
icon = {
|
title = { Text("Delete Book") },
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
"Delete Book",
|
|
||||||
style = MaterialTheme.typography.headlineSmall
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text("Are you sure you want to delete \"${book.title}\"?")
|
||||||
"Are you sure you want to delete '${bookToDelete?.title}'? This action cannot be undone.",
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Button(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
bookToDelete?.let { bookStore.deleteBook(it.id) }
|
bookStore.deleteBook(book.id)
|
||||||
showDeleteDialog = false
|
|
||||||
bookToDelete = null
|
bookToDelete = null
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text("Delete")
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
OutlinedButton(onClick = { showDeleteDialog = false }) {
|
TextButton(onClick = { bookToDelete = null }) {
|
||||||
Text("Cancel")
|
Text("Cancel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,102 +179,93 @@ fun BookshelfScreen(onBookClick: (String) -> Unit) {
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
LargeTopAppBar(
|
SmallTopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
"Library",
|
text = "My Library",
|
||||||
style = MaterialTheme.typography.headlineLarge
|
style = MaterialTheme.typography.titleLarge
|
||||||
)
|
)
|
||||||
AnimatedVisibility(
|
|
||||||
visible = !showEmptyState,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
"${books.size} book${if (books.size != 1) "s" else ""}",
|
text = "${books.size} book${if (books.size != 1) "s" else ""}",
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.largeTopAppBarColors(
|
colors = TopAppBarDefaults.smallTopAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { launcher.launch("application/epub+zip") },
|
onClick = { launcher.launch("application/epub+zip") },
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add book")
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = "Add Book"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(
|
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
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
) {
|
) {
|
||||||
if (showEmptyState) {
|
items(books) { book ->
|
||||||
|
BookCard(
|
||||||
|
book = book,
|
||||||
|
onClick = { navController.navigate("reader/${book.id}") },
|
||||||
|
onLongClick = { bookToDelete = book }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyLibraryMessage(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier,
|
||||||
.fillMaxSize()
|
|
||||||
.padding(32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.MenuBook,
|
imageVector = Icons.Default.MenuBook,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(96.dp),
|
modifier = Modifier.size(64.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
"Your library is empty",
|
text = "Your library is empty",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
"Tap the + button to add your first book",
|
text = "Tap + to add your first book",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
items(
|
|
||||||
items = books,
|
|
||||||
key = { it.id }
|
|
||||||
) { book ->
|
|
||||||
BookCard(
|
|
||||||
book = book,
|
|
||||||
onClick = { onBookClick(book.id) },
|
|
||||||
onLongClick = {
|
|
||||||
bookToDelete = book
|
|
||||||
showDeleteDialog = true
|
|
||||||
},
|
|
||||||
modifier = Modifier.animateItemPlacement()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -356,43 +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.coverImageFile ?: 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(
|
||||||
Text(
|
brush = Brush.verticalGradient(
|
||||||
text = book.title,
|
colors = listOf(
|
||||||
style = MaterialTheme.typography.titleMedium,
|
Color.Transparent,
|
||||||
modifier = Modifier.padding(12.dp),
|
Color.Black.copy(alpha = 0.7f)
|
||||||
maxLines = 2,
|
)
|
||||||
overflow = TextOverflow.Ellipsis
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.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
|
||||||
@@ -44,6 +43,12 @@ import java.io.FileInputStream
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import inhale.rip.epook.data.Book
|
import inhale.rip.epook.data.Book
|
||||||
import nl.siegmann.epublib.domain.Book as EpubBook
|
import nl.siegmann.epublib.domain.Book as EpubBook
|
||||||
|
import java.io.File
|
||||||
|
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
|
// Move the Chapter data class outside the composable
|
||||||
private data class Chapter(
|
private data class Chapter(
|
||||||
@@ -52,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(
|
||||||
@@ -60,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()) }
|
||||||
@@ -70,18 +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()
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -141,138 +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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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(
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.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) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onTap = { showControls = !showControls }
|
onTap = { showControls = !showControls }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
var initialX = 0f
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = { offset ->
|
||||||
|
initialX = offset.x
|
||||||
},
|
},
|
||||||
factory = { context ->
|
onDragEnd = {
|
||||||
WebView(context).apply {
|
val dragThreshold = size.width * 0.2f // 20% of screen width
|
||||||
webViewClient = WebViewClient()
|
val dragDistance = initialX - currentX
|
||||||
this.settings.apply {
|
|
||||||
javaScriptEnabled = true
|
when {
|
||||||
builtInZoomControls = true
|
dragDistance > dragThreshold && currentChapterIndex < chapters.size - 1 -> {
|
||||||
displayZoomControls = false
|
// Swiped left - next chapter
|
||||||
|
currentChapterIndex++
|
||||||
|
}
|
||||||
|
dragDistance < -dragThreshold && currentChapterIndex > 0 -> {
|
||||||
|
// Swiped right - previous chapter
|
||||||
|
currentChapterIndex--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
currentX = 0f
|
||||||
update = { webView ->
|
|
||||||
val chapter = chapters.getOrNull(currentChapterIndex)?.resource
|
|
||||||
chapter?.let { resource ->
|
|
||||||
val html = """
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: ${currentSettings.fontFamily};
|
|
||||||
font-size: ${currentSettings.fontSize}px;
|
|
||||||
line-height: ${currentSettings.lineHeight};
|
|
||||||
padding: ${currentSettings.padding}px;
|
|
||||||
margin: 0;
|
|
||||||
background-color: #${backgroundColor.toString(16)};
|
|
||||||
color: #${textColor.toString(16)};
|
|
||||||
}
|
}
|
||||||
</style>
|
) { change, dragAmount ->
|
||||||
</head>
|
currentX = change.position.x
|
||||||
<body>
|
change.consume()
|
||||||
${String(resource.data, Charset.defaultCharset())}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""".trimIndent()
|
|
||||||
webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation controls
|
// 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.9f),
|
|
||||||
tonalElevation = 3.dp
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(16.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
// Previous chapter
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { if (currentChapterIndex > 0) currentChapterIndex-- },
|
onClick = { if (currentChapterIndex > 0) currentChapterIndex-- },
|
||||||
enabled = currentChapterIndex > 0
|
enabled = currentChapterIndex > 0
|
||||||
) {
|
) {
|
||||||
Icon(Icons.AutoMirrored.Filled.NavigateBefore, "Previous chapter")
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.NavigateBefore,
|
||||||
|
contentDescription = "Previous Chapter"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next chapter
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { if (currentChapterIndex < chapters.size - 1) currentChapterIndex++ },
|
onClick = { if (currentChapterIndex < chapters.size - 1) currentChapterIndex++ },
|
||||||
enabled = currentChapterIndex < chapters.size - 1
|
enabled = currentChapterIndex < chapters.size - 1
|
||||||
) {
|
) {
|
||||||
Icon(Icons.AutoMirrored.Filled.NavigateNext, "Next chapter")
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.NavigateNext,
|
||||||
|
contentDescription = "Next Chapter"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
package inhale.rip.epook.data
|
package inhale.rip.epook.data
|
||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "books")
|
||||||
data class Book(
|
data class Book(
|
||||||
val id: String,
|
@PrimaryKey val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val coverImageFile: File? = null,
|
val author: String,
|
||||||
val filePath: String
|
val path: String,
|
||||||
|
val extractedPath: String,
|
||||||
|
val coverPath: String?
|
||||||
)
|
)
|
||||||
@@ -13,6 +13,9 @@ import nl.siegmann.epublib.domain.Book as EpubBook
|
|||||||
import nl.siegmann.epublib.epub.EpubReader
|
import nl.siegmann.epublib.epub.EpubReader
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "books")
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "books")
|
||||||
|
|
||||||
@@ -26,13 +29,7 @@ class BookStore(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addBook(book: Book) {
|
suspend fun addBook(book: Book) {
|
||||||
bookDao.insertBook(BookEntity(
|
bookDao.insert(book.toEntity())
|
||||||
id = book.id,
|
|
||||||
title = book.title,
|
|
||||||
coverImagePath = book.coverImageFile?.absolutePath,
|
|
||||||
filePath = book.filePath,
|
|
||||||
readingPosition = 0
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateReadingPosition(bookId: String, position: Int) {
|
suspend fun updateReadingPosition(bookId: String, position: Int) {
|
||||||
@@ -59,55 +56,207 @@ class BookStore(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteBook(bookId: String) {
|
suspend fun deleteBook(bookId: String) {
|
||||||
Log.e(TAG, "🔥 DELETE BOOK STARTED 🔥")
|
|
||||||
Log.e(TAG, "Attempting to delete book with ID: $bookId")
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val bookEntity = bookDao.getBook(bookId)
|
val bookEntity = bookDao.getBook(bookId)
|
||||||
Log.e(TAG, if (bookEntity != null) {
|
|
||||||
"Found book in database: ${bookEntity.title} (ID: ${bookEntity.id})"
|
|
||||||
} else {
|
|
||||||
"Book not found in database with ID: $bookId"
|
|
||||||
})
|
|
||||||
|
|
||||||
if (bookEntity != null) {
|
if (bookEntity != null) {
|
||||||
try {
|
// Delete the book directory containing the EPUB and extracted contents
|
||||||
|
val bookDirectory = File(context.filesDir, "books/$bookId")
|
||||||
|
if (bookDirectory.exists()) {
|
||||||
|
bookDirectory.deleteRecursively()
|
||||||
|
Timber.d("Deleted book directory: ${bookDirectory.absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
bookDao.deleteBook(bookEntity)
|
bookDao.deleteBook(bookEntity)
|
||||||
Log.e(TAG, "Successfully deleted book from Room database")
|
Timber.d("Deleted book from database: ${bookEntity.title}")
|
||||||
|
|
||||||
// Delete associated files
|
|
||||||
val bookFile = File(bookEntity.filePath)
|
|
||||||
if (bookFile.exists() && bookFile.delete()) {
|
|
||||||
Log.e(TAG, "Successfully deleted book file")
|
|
||||||
}
|
|
||||||
|
|
||||||
bookEntity.coverImagePath?.let { coverPath ->
|
|
||||||
val coverFile = File(coverPath)
|
|
||||||
if (coverFile.exists() && coverFile.delete()) {
|
|
||||||
Log.e(TAG, "Successfully deleted cover image")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error deleting book files", e)
|
Timber.e(e, "Error deleting book")
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.e(TAG, "🔥 DELETE BOOK COMPLETED SUCCESSFULLY 🔥")
|
suspend fun getBook(bookId: String): Book? {
|
||||||
} catch (e: Exception) {
|
return bookDao.getBook(bookId)?.toBook()
|
||||||
Log.e(TAG, "🔥 DELETE BOOK FAILED 🔥")
|
|
||||||
Log.e(TAG, "Error type: ${e.javaClass.simpleName}")
|
|
||||||
Log.e(TAG, "Error message: ${e.message}")
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getBook(bookId: String): EpubBook {
|
|
||||||
val bookEntity = bookDao.getBook(bookId) ?: throw IllegalArgumentException("Book not found")
|
|
||||||
return EpubReader().readEpub(FileInputStream(bookEntity.filePath))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getBookPath(bookId: String): String {
|
suspend fun getBookPath(bookId: String): String {
|
||||||
return bookDao.getBook(bookId)?.filePath
|
return bookDao.getBook(bookId)?.filePath
|
||||||
?: throw IllegalArgumentException("Book not found")
|
?: throw IllegalArgumentException("Book not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun extractEpub(book: EpubBook, baseDirectory: File) {
|
||||||
|
try {
|
||||||
|
// First extract fonts and CSS files
|
||||||
|
book.resources.all
|
||||||
|
.filter { resource ->
|
||||||
|
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 ->
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
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 remaining files
|
||||||
|
book.resources.all
|
||||||
|
.filter { resource ->
|
||||||
|
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("./", "")
|
||||||
|
val resourceFile = File(baseDirectory, resourcePath)
|
||||||
|
resourceFile.parentFile?.mkdirs()
|
||||||
|
|
||||||
|
if (resource.mediaType?.toString()?.contains("html") == true) {
|
||||||
|
val content = String(resource.data, Charset.defaultCharset())
|
||||||
|
|
||||||
|
// Parse with XML parser to maintain proper XHTML structure
|
||||||
|
val doc = Jsoup.parse(content, "", org.jsoup.parser.Parser.xmlParser())
|
||||||
|
doc.outputSettings()
|
||||||
|
.syntax(org.jsoup.nodes.Document.OutputSettings.Syntax.xml)
|
||||||
|
.escapeMode(org.jsoup.nodes.Entities.EscapeMode.xhtml)
|
||||||
|
.prettyPrint(false)
|
||||||
|
|
||||||
|
// Process CSS links
|
||||||
|
doc.select("link[rel=stylesheet]").forEach { link ->
|
||||||
|
val cssHref = link.attr("href")
|
||||||
|
.replace("../", "")
|
||||||
|
.replace("./", "")
|
||||||
|
|
||||||
|
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">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
${doc.head().outerHtml()}
|
||||||
|
${doc.body().outerHtml()}
|
||||||
|
</html>""".trimIndent()
|
||||||
|
|
||||||
|
resourceFile.writeText(xhtml)
|
||||||
|
} else {
|
||||||
|
resourceFile.writeBytes(resource.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importBook(sourceFile: File): String {
|
||||||
|
val bookId = UUID.randomUUID().toString()
|
||||||
|
val epubBook = EpubReader().readEpub(FileInputStream(sourceFile))
|
||||||
|
|
||||||
|
// Create book directory
|
||||||
|
val bookDirectory = File(context.filesDir, "books/$bookId")
|
||||||
|
bookDirectory.mkdirs()
|
||||||
|
|
||||||
|
// Copy original EPUB file
|
||||||
|
val epubFile = File(bookDirectory, "book.epub")
|
||||||
|
sourceFile.copyTo(epubFile)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
val book = Book(
|
||||||
|
id = bookId,
|
||||||
|
title = epubBook.title,
|
||||||
|
author = epubBook.metadata.authors.firstOrNull()?.toString() ?: "Unknown",
|
||||||
|
path = epubFile.absolutePath,
|
||||||
|
extractedPath = extractedDirectory.absolutePath,
|
||||||
|
coverPath = coverPath
|
||||||
|
)
|
||||||
|
|
||||||
|
bookDao.insert(book.toEntity())
|
||||||
|
return bookId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user