General improvements (#134)
* Add visual options and seamless transitions to the EPUB reader * Add support for DOCX file format * Refine padding and status bar inset handling in Epub pagination mode * Add file size parameter for recent files and sorting * Refactor book data cleanup logic into a centralized method * Bump version to 1.0.40(41)
This commit is contained in:
parent
15264a31ae
commit
6fd6dd5609
20 changed files with 623 additions and 134 deletions
|
|
@ -30,8 +30,8 @@ android {
|
||||||
applicationId = "com.aryan.reader"
|
applicationId = "com.aryan.reader"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 40
|
versionCode = 41
|
||||||
versionName = "1.0.39"
|
versionName = "1.0.40"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
|
|
@ -217,6 +217,7 @@ dependencies {
|
||||||
implementation("androidx.browser:browser:1.8.0")
|
implementation("androidx.browser:browser:1.8.0")
|
||||||
|
|
||||||
implementation("io.legere:pdfiumandroid:2.0.0")
|
implementation("io.legere:pdfiumandroid:2.0.0")
|
||||||
|
implementation("org.zwobble.mammoth:mammoth:1.4.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
spotless {
|
spotless {
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,28 @@
|
||||||
<data android:mimeType="application/x-cb7" />
|
<data android:mimeType="application/x-cb7" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- DOCX -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="content" />
|
||||||
|
<data android:scheme="file" />
|
||||||
|
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="file" />
|
||||||
|
<data android:host="*" />
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
<data android:pathPattern=".*\\.docx" />
|
||||||
|
<data android:pathPattern=".*\\..*\\.docx" />
|
||||||
|
<data android:pathPattern=".*\\..*\\..*\\.docx" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- pathPattern fallback for file:// URIs (comic books) -->
|
<!-- pathPattern fallback for file:// URIs (comic books) -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ fun AppNavigation(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2 -> {
|
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX -> {
|
||||||
if (uiState.selectedEpubBook != null) {
|
if (uiState.selectedEpubBook != null) {
|
||||||
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
|
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
|
||||||
navController.navigate(AppDestinations.EPUB_READER_ROUTE) {
|
navController.navigate(AppDestinations.EPUB_READER_ROUTE) {
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,7 @@ class FolderSyncWorker(
|
||||||
return when {
|
return when {
|
||||||
mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF
|
mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF
|
||||||
mimeType == "application/epub+zip" || name.endsWith(".epub", true) -> FileType.EPUB
|
mimeType == "application/epub+zip" || name.endsWith(".epub", true) -> FileType.EPUB
|
||||||
|
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || name.endsWith(".docx", true) -> FileType.DOCX
|
||||||
name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI
|
name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI
|
||||||
name.endsWith(".md", true) -> FileType.MD
|
name.endsWith(".md", true) -> FileType.MD
|
||||||
name.endsWith(".txt", true) -> FileType.TXT
|
name.endsWith(".txt", true) -> FileType.TXT
|
||||||
|
|
|
||||||
|
|
@ -608,7 +608,7 @@ fun RecentFileCard(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val placeholder = when (item.type) {
|
val placeholder = when (item.type) {
|
||||||
FileType.PDF -> R.drawable.pdf_placeholder
|
FileType.PDF -> R.drawable.pdf_placeholder
|
||||||
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7 -> R.drawable.epub_placeholder
|
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX -> R.drawable.epub_placeholder
|
||||||
}
|
}
|
||||||
val imageModel = remember(item.coverImagePath) {
|
val imageModel = remember(item.coverImagePath) {
|
||||||
item.coverImagePath?.let { File(it) } ?: placeholder
|
item.coverImagePath?.let { File(it) } ?: placeholder
|
||||||
|
|
|
||||||
|
|
@ -1297,7 +1297,7 @@ private fun LibraryListItem(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val placeholder = when (item.type) {
|
val placeholder = when (item.type) {
|
||||||
FileType.PDF -> R.drawable.pdf_placeholder
|
FileType.PDF -> R.drawable.pdf_placeholder
|
||||||
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7 -> R.drawable.epub_placeholder
|
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX -> R.drawable.epub_placeholder
|
||||||
}
|
}
|
||||||
val imageModel = remember(item.coverImagePath) {
|
val imageModel = remember(item.coverImagePath) {
|
||||||
item.coverImagePath?.let { File(it) } ?: placeholder
|
item.coverImagePath?.let { File(it) } ?: placeholder
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ enum class AddBooksSource(val displayName: String) {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class FileType {
|
enum class FileType {
|
||||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7
|
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class RenderMode {
|
enum class RenderMode {
|
||||||
|
|
@ -161,9 +161,13 @@ data class Shelf(val name: String, val books: List<RecentFileItem>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class SortOrder(val displayName: String) {
|
enum class SortOrder(val displayName: String) {
|
||||||
RECENT("Recent"), TITLE_ASC("Title A-Z"), AUTHOR_ASC("Author A-Z"), PERCENT_ASC("Percent complete 0-100"), PERCENT_DESC(
|
RECENT("Recent"),
|
||||||
"Percent complete 100-0"
|
TITLE_ASC("Title A-Z"),
|
||||||
)
|
AUTHOR_ASC("Author A-Z"),
|
||||||
|
PERCENT_ASC("Percent complete 0-100"),
|
||||||
|
PERCENT_DESC("Percent complete 100-0"),
|
||||||
|
SIZE_ASC("Size (Smallest)"),
|
||||||
|
SIZE_DESC("Size (Biggest)")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ReadStatusFilter(val displayName: String) {
|
enum class ReadStatusFilter(val displayName: String) {
|
||||||
|
|
@ -372,6 +376,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
||||||
SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f }
|
SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f }
|
||||||
SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f }
|
SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f }
|
||||||
|
SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize }
|
||||||
|
SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -478,8 +484,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
if (filesToDelete.isNotEmpty()) {
|
if (filesToDelete.isNotEmpty()) {
|
||||||
val ids = filesToDelete.map { it.bookId }
|
val ids = filesToDelete.map { it.bookId }
|
||||||
ids.forEach { bookId ->
|
ids.forEach { bookId ->
|
||||||
pdfTextRepository.clearBookText(bookId)
|
cleanupBookDataLocally(bookId)
|
||||||
clearImportedFileCache(bookId)
|
|
||||||
try {
|
try {
|
||||||
val cacheDir = File(appContext.cacheDir, "opds_stream_${bookId.hashCode()}")
|
val cacheDir = File(appContext.cacheDir, "opds_stream_${bookId.hashCode()}")
|
||||||
if (cacheDir.exists()) cacheDir.deleteRecursively()
|
if (cacheDir.exists()) cacheDir.deleteRecursively()
|
||||||
|
|
@ -838,14 +843,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
Timber.d("Deleting book permanently from reader: $bookId")
|
Timber.d("Deleting book permanently from reader: $bookId")
|
||||||
|
|
||||||
pdfTextRepository.clearBookText(bookId)
|
cleanupBookDataLocally(bookId)
|
||||||
try {
|
|
||||||
val cacheDir = File(appContext.cacheDir, "imported_file_$bookId")
|
|
||||||
if (cacheDir.exists()) cacheDir.deleteRecursively()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to clear cache for $bookId")
|
|
||||||
}
|
|
||||||
|
|
||||||
recentFilesRepository.deleteFilePermanently(listOf(bookId))
|
recentFilesRepository.deleteFilePermanently(listOf(bookId))
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -1013,6 +1011,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
private fun getFastFileId(context: Context, uri: Uri): String {
|
private fun getFastFileId(context: Context, uri: Uri): String {
|
||||||
var result = uri.toString()
|
var result = uri.toString()
|
||||||
try {
|
try {
|
||||||
|
if (uri.scheme == "file") {
|
||||||
|
uri.path?.let {
|
||||||
|
val file = File(it)
|
||||||
|
result = "${file.name}_${file.length()}"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
if (cursor.moveToFirst()) {
|
if (cursor.moveToFirst()) {
|
||||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
|
@ -1022,6 +1026,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
result = "${name}_${size}"
|
result = "${name}_${size}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to generate fast file ID")
|
Timber.e(e, "Failed to generate fast file ID")
|
||||||
}
|
}
|
||||||
|
|
@ -1560,7 +1565,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
_internalState.update { it.copy(syncedFolders = currentFolders) }
|
_internalState.update { it.copy(syncedFolders = currentFolders) }
|
||||||
|
|
||||||
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
|
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
|
||||||
filesToRemove.forEach { pdfTextRepository.clearBookText(it.bookId) }
|
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
|
||||||
|
|
||||||
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
||||||
try {
|
try {
|
||||||
|
|
@ -1671,8 +1676,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val idsToRemove = filesToRemove.map { it.bookId }
|
val idsToRemove = filesToRemove.map { it.bookId }
|
||||||
|
|
||||||
idsToRemove.forEach { bookId ->
|
idsToRemove.forEach { bookId ->
|
||||||
pdfTextRepository.clearBookText(bookId)
|
cleanupBookDataLocally(bookId)
|
||||||
clearImportedFileCache(bookId)
|
|
||||||
}
|
}
|
||||||
recentFilesRepository.deleteFilePermanently(idsToRemove)
|
recentFilesRepository.deleteFilePermanently(idsToRemove)
|
||||||
}
|
}
|
||||||
|
|
@ -1689,7 +1693,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val folders = _internalState.value.syncedFolders
|
val folders = _internalState.value.syncedFolders
|
||||||
folders.forEach { folder ->
|
folders.forEach { folder ->
|
||||||
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
|
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
|
||||||
filesToRemove.forEach { pdfTextRepository.clearBookText(it.bookId) }
|
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
|
||||||
|
|
||||||
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
||||||
try {
|
try {
|
||||||
|
|
@ -1902,6 +1906,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
recentFilesRepository.clearAllLocalData()
|
recentFilesRepository.clearAllLocalData()
|
||||||
|
clearBookCache()
|
||||||
pdfTextRepository.clearAllText()
|
pdfTextRepository.clearAllText()
|
||||||
pdfTextBoxRepository.clearAll()
|
pdfTextBoxRepository.clearAll()
|
||||||
prefs.edit { remove(KEY_LAST_SYNC_TIMESTAMP) }
|
prefs.edit { remove(KEY_LAST_SYNC_TIMESTAMP) }
|
||||||
|
|
@ -2378,6 +2383,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
recentFilesRepository.getFileByBookId(bookId) == null
|
recentFilesRepository.getFileByBookId(bookId) == null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val fileSize = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
if (uri.scheme == "file") {
|
||||||
|
uri.path?.let { File(it).length() } ?: 0L
|
||||||
|
} else {
|
||||||
|
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
|
||||||
|
} else 0L
|
||||||
|
} ?: 0L
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to get file size for $uri")
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val existingItem = recentFilesRepository.getFileByBookId(bookId)
|
val existingItem = recentFilesRepository.getFileByBookId(bookId)
|
||||||
val displayName = customDisplayName ?: existingItem?.displayName ?: getFileNameFromUri(
|
val displayName = customDisplayName ?: existingItem?.displayName ?: getFileNameFromUri(
|
||||||
uri, appContext
|
uri, appContext
|
||||||
|
|
@ -2388,7 +2411,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
var author: String? = null
|
var author: String? = null
|
||||||
var bookForMetadata = epubBook
|
var bookForMetadata = epubBook
|
||||||
|
|
||||||
if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) {
|
if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX)) {
|
||||||
Timber.d("Parsing downloaded book for cover/metadata: $displayName")
|
Timber.d("Parsing downloaded book for cover/metadata: $displayName")
|
||||||
Timber.tag("FileOpenPerf")
|
Timber.tag("FileOpenPerf")
|
||||||
.d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)")
|
.d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)")
|
||||||
|
|
@ -2450,7 +2473,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
val finalBookMetadata = bookForMetadata
|
val finalBookMetadata = bookForMetadata
|
||||||
|
|
||||||
if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) && finalBookMetadata != null) {
|
if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX) && finalBookMetadata != null) {
|
||||||
title =
|
title =
|
||||||
finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName
|
finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName
|
||||||
|
|
||||||
|
|
@ -2529,7 +2552,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
lastModifiedTimestamp = newLastModifiedTimestamp,
|
lastModifiedTimestamp = newLastModifiedTimestamp,
|
||||||
isDeleted = false,
|
isDeleted = false,
|
||||||
isRecent = isRecent,
|
isRecent = isRecent,
|
||||||
sourceFolderUri = sourceFolderUri
|
sourceFolderUri = sourceFolderUri,
|
||||||
|
fileSize = fileSize
|
||||||
)
|
)
|
||||||
recentFilesRepository.addRecentFile(newItem)
|
recentFilesRepository.addRecentFile(newItem)
|
||||||
Timber.i("Added/Updated $displayName ($type) to recent files via repository.")
|
Timber.i("Added/Updated $displayName ($type) to recent files via repository.")
|
||||||
|
|
@ -2871,6 +2895,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun cleanupBookDataLocally(bookId: String) {
|
||||||
|
pdfTextRepository.clearBookText(bookId)
|
||||||
|
clearImportedFileCache(bookId)
|
||||||
|
bookCacheDao.deleteEntireBookCache(bookId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearImportedFileCache(bookId: String) {
|
private fun clearImportedFileCache(bookId: String) {
|
||||||
try {
|
try {
|
||||||
val cacheDir = File(appContext.cacheDir, "imported_file_$bookId")
|
val cacheDir = File(appContext.cacheDir, "imported_file_$bookId")
|
||||||
|
|
@ -2892,6 +2922,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
if (uri.scheme != "opds-pse") {
|
if (uri.scheme != "opds-pse") {
|
||||||
try {
|
try {
|
||||||
|
if (uri.scheme == "file") {
|
||||||
|
uri.path?.let {
|
||||||
|
val file = File(it)
|
||||||
|
val size = file.length()
|
||||||
|
val name = file.name
|
||||||
|
Timber.tag("FileOpenPerf").d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
val cursor = appContext.contentResolver.query(uri, null, null, null, null)
|
val cursor = appContext.contentResolver.query(uri, null, null, null, null)
|
||||||
cursor?.use {
|
cursor?.use {
|
||||||
if (it.moveToFirst()) {
|
if (it.moveToFirst()) {
|
||||||
|
|
@ -2903,6 +2941,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
.d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}")
|
.d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details")
|
Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details")
|
||||||
}
|
}
|
||||||
|
|
@ -2952,7 +2991,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
sourceFolderUri = null
|
sourceFolderUri = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) {
|
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
||||||
if (recentItem?.sourceFolderUri != null) {
|
if (recentItem?.sourceFolderUri != null) {
|
||||||
|
|
@ -3110,6 +3149,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName")
|
Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName")
|
||||||
|
|
||||||
return when (mimeType) {
|
return when (mimeType) {
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
|
||||||
"application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
|
"application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
|
||||||
if (fileName?.endsWith(".cbz", ignoreCase = true) == true) FileType.CBZ else null
|
if (fileName?.endsWith(".cbz", ignoreCase = true) == true) FileType.CBZ else null
|
||||||
}
|
}
|
||||||
|
|
@ -3181,6 +3221,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
".htm",
|
".htm",
|
||||||
ignoreCase = true
|
ignoreCase = true
|
||||||
) == true -> FileType.HTML
|
) == true -> FileType.HTML
|
||||||
|
fileName?.endsWith(".docx", ignoreCase = true) == true -> FileType.DOCX
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
@ -3846,7 +3887,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
folderBooks.forEach { item ->
|
folderBooks.forEach { item ->
|
||||||
idsToDeleteLocally.add(item.bookId)
|
idsToDeleteLocally.add(item.bookId)
|
||||||
pdfTextRepository.clearBookText(item.bookId)
|
cleanupBookDataLocally(item.bookId)
|
||||||
|
|
||||||
clearImportedFileCache(item.bookId)
|
clearImportedFileCache(item.bookId)
|
||||||
|
|
||||||
|
|
@ -3912,8 +3953,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
for (item in managedBooks) {
|
for (item in managedBooks) {
|
||||||
recentFilesRepository.markAsDeleted(listOf(item.bookId))
|
recentFilesRepository.markAsDeleted(listOf(item.bookId))
|
||||||
pdfTextRepository.clearBookText(item.bookId)
|
cleanupBookDataLocally(item.bookId)
|
||||||
clearImportedFileCache(item.bookId)
|
|
||||||
|
|
||||||
firestoreRepository.syncBookMetadata(
|
firestoreRepository.syncBookMetadata(
|
||||||
currentUser.uid,
|
currentUser.uid,
|
||||||
|
|
@ -3941,8 +3981,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
Timber.e(e, "Error during permanent deletion")
|
Timber.e(e, "Error during permanent deletion")
|
||||||
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
|
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
|
||||||
managedBooks.forEach { item ->
|
managedBooks.forEach { item ->
|
||||||
clearImportedFileCache(item.bookId)
|
cleanupBookDataLocally(item.bookId)
|
||||||
pdfTextRepository.clearBookText(item.bookId)
|
|
||||||
}
|
}
|
||||||
_internalState.update {
|
_internalState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -3954,8 +3993,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
} else {
|
} else {
|
||||||
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
|
recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId })
|
||||||
managedBooks.forEach { item ->
|
managedBooks.forEach { item ->
|
||||||
clearImportedFileCache(item.bookId)
|
cleanupBookDataLocally(item.bookId)
|
||||||
pdfTextRepository.clearBookText(item.bookId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4073,8 +4111,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val reflowBookIds = reflowBooks.map { it.bookId }
|
val reflowBookIds = reflowBooks.map { it.bookId }
|
||||||
|
|
||||||
reflowBookIds.forEach { bookId ->
|
reflowBookIds.forEach { bookId ->
|
||||||
clearImportedFileCache(bookId)
|
cleanupBookDataLocally(bookId)
|
||||||
pdfTextRepository.clearBookText(bookId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recentFilesRepository.deleteFilePermanently(reflowBookIds)
|
recentFilesRepository.deleteFilePermanently(reflowBookIds)
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,22 @@ class MetadataExtractionWorker(
|
||||||
var title: String? = null
|
var title: String? = null
|
||||||
var author: String? = null
|
var author: String? = null
|
||||||
|
|
||||||
|
val fileSize = try {
|
||||||
|
if (uri.scheme == "file") {
|
||||||
|
uri.path?.let { File(it).length() } ?: 0L
|
||||||
|
} else {
|
||||||
|
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
||||||
|
if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
|
||||||
|
} else 0L
|
||||||
|
} ?: 0L
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("MetadataWorker").e(e, "Failed to get file size for ${item.displayName}")
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
|
||||||
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
|
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
when (type) {
|
when (type) {
|
||||||
FileType.EPUB -> {
|
FileType.EPUB -> {
|
||||||
|
|
@ -99,15 +115,15 @@ class MetadataExtractionWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coverPath != null || title != null || author != null) {
|
if (coverPath != null || title != null || author != null || fileSize > 0L) {
|
||||||
val updatedItem = item.copy(
|
val updatedItem = item.copy(
|
||||||
coverImagePath = coverPath ?: item.coverImagePath,
|
coverImagePath = coverPath ?: item.coverImagePath,
|
||||||
title = title ?: item.title ?: item.displayName,
|
title = title ?: item.title ?: item.displayName,
|
||||||
author = author ?: item.author
|
author = author ?: item.author,
|
||||||
|
fileSize = if (fileSize > 0L) fileSize else item.fileSize
|
||||||
)
|
)
|
||||||
recentFilesRepository.addRecentFile(updatedItem)
|
recentFilesRepository.addRecentFile(updatedItem)
|
||||||
Timber.tag("MetadataWorker").d("Updated local metadata for: ${item.displayName}")
|
Timber.tag("MetadataWorker").d("Updated local metadata/size for: ${item.displayName} ($fileSize bytes)")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ import timber.log.Timber
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
|
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
|
||||||
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
|
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
|
||||||
|
|
@ -123,6 +125,13 @@ class CustomTabUriHandler(private val context: Context) : UriHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun formatFileSize(bytes: Long): String {
|
||||||
|
if (bytes <= 0) return "Unknown"
|
||||||
|
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
||||||
|
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
|
||||||
|
return String.format(Locale.US, "%.2f %s", bytes / 1024.0.pow(digitGroups.toDouble()), units[digitGroups])
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LegalText(
|
fun LegalText(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -446,6 +455,7 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
|
||||||
InfoRowDetailed("Author", it)
|
InfoRowDetailed("Author", it)
|
||||||
}
|
}
|
||||||
InfoRowDetailed("Format", item.type.name)
|
InfoRowDetailed("Format", item.type.name)
|
||||||
|
InfoRowDetailed("Size", formatFileSize(item.fileSize))
|
||||||
InfoRowDetailed("Added", formattedDate)
|
InfoRowDetailed("Added", formattedDate)
|
||||||
InfoRowDetailed(
|
InfoRowDetailed(
|
||||||
label = "Location",
|
label = "Location",
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import androidx.room.TypeConverters
|
||||||
import androidx.room.migration.Migration
|
import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
@Database(entities =[RecentFileEntity::class, CustomFontEntity::class], version = 15, exportSchema = false)
|
@Database(entities =[RecentFileEntity::class, CustomFontEntity::class], version = 16, exportSchema = false)
|
||||||
@TypeConverters(FileTypeConverter::class)
|
@TypeConverters(FileTypeConverter::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun recentFileDao(): RecentFileDao
|
abstract fun recentFileDao(): RecentFileDao
|
||||||
|
|
@ -185,6 +185,12 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_15_16 = object : Migration(15, 16) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE recent_files ADD COLUMN fileSize INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getDatabase(context: Context): AppDatabase {
|
fun getDatabase(context: Context): AppDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
val instance = Room.databaseBuilder(
|
val instance = Room.databaseBuilder(
|
||||||
|
|
@ -196,7 +202,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
|
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
|
||||||
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
|
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
|
||||||
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
||||||
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15
|
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration(false)
|
.fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
|
|
|
||||||
|
|
@ -50,5 +50,6 @@ data class RecentFileEntity(
|
||||||
@ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?,
|
@ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?,
|
||||||
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
|
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
|
||||||
@ColumnInfo(defaultValue = "NULL") val customName: String?,
|
@ColumnInfo(defaultValue = "NULL") val customName: String?,
|
||||||
@ColumnInfo(defaultValue = "NULL") val highlights: String?
|
@ColumnInfo(defaultValue = "NULL") val highlights: String?,
|
||||||
|
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long
|
||||||
)
|
)
|
||||||
|
|
@ -46,7 +46,8 @@ data class RecentFileItem(
|
||||||
val sourceFolderUri: String? = null,
|
val sourceFolderUri: String? = null,
|
||||||
val isReflowPreferred: Boolean = false,
|
val isReflowPreferred: Boolean = false,
|
||||||
val customName: String? = null,
|
val customName: String? = null,
|
||||||
val highlightsJson: String? = null
|
val highlightsJson: String? = null,
|
||||||
|
val fileSize: Long = 0L
|
||||||
) {
|
) {
|
||||||
fun getUri(): Uri? = uriString?.toUri()
|
fun getUri(): Uri? = uriString?.toUri()
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +76,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
||||||
sourceFolderUri = this.sourceFolderUri,
|
sourceFolderUri = this.sourceFolderUri,
|
||||||
isReflowPreferred = this.isReflowPreferred,
|
isReflowPreferred = this.isReflowPreferred,
|
||||||
customName = this.customName,
|
customName = this.customName,
|
||||||
highlightsJson = this.highlights
|
highlightsJson = this.highlights,
|
||||||
|
fileSize = this.fileSize
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,7 +105,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
|
||||||
sourceFolderUri = this.sourceFolderUri,
|
sourceFolderUri = this.sourceFolderUri,
|
||||||
isReflowPreferred = this.isReflowPreferred,
|
isReflowPreferred = this.isReflowPreferred,
|
||||||
customName = this.customName,
|
customName = this.customName,
|
||||||
highlights = this.highlightsJson
|
highlights = this.highlightsJson,
|
||||||
|
fileSize = this.fileSize
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,8 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
isRecent = item.isRecent,
|
isRecent = item.isRecent,
|
||||||
isDeleted = item.isDeleted,
|
isDeleted = item.isDeleted,
|
||||||
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
|
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
|
||||||
highlights = item.highlightsJson ?: existingItem.highlights
|
highlights = item.highlightsJson ?: existingItem.highlights,
|
||||||
|
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
item.toRecentFileEntity()
|
item.toRecentFileEntity()
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
|
import org.zwobble.mammoth.DocumentConverter
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
|
@ -55,6 +56,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
FileType.MD -> parseMarkdown(inputStream, originalBookNameHint, bookId, parseContent)
|
FileType.MD -> parseMarkdown(inputStream, originalBookNameHint, bookId, parseContent)
|
||||||
FileType.TXT -> parsePlainText(inputStream, originalBookNameHint, bookId, parseContent)
|
FileType.TXT -> parsePlainText(inputStream, originalBookNameHint, bookId, parseContent)
|
||||||
FileType.HTML -> parseHtml(inputStream, originalBookNameHint, bookId, parseContent)
|
FileType.HTML -> parseHtml(inputStream, originalBookNameHint, bookId, parseContent)
|
||||||
|
FileType.DOCX -> parseDocx(inputStream, originalBookNameHint, bookId, parseContent)
|
||||||
else -> parsePlainText(inputStream, originalBookNameHint, bookId, parseContent)
|
else -> parsePlainText(inputStream, originalBookNameHint, bookId, parseContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -548,6 +550,68 @@ class SingleFileImporter(private val context: Context) {
|
||||||
return@withContext book
|
return@withContext book
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun parseDocx(
|
||||||
|
inputStream: InputStream,
|
||||||
|
originalBookNameHint: String,
|
||||||
|
bookId: String,
|
||||||
|
parseContent: Boolean
|
||||||
|
): EpubBook = withContext(Dispatchers.IO) {
|
||||||
|
if (!parseContent) {
|
||||||
|
return@withContext EpubBook(
|
||||||
|
fileName = originalBookNameHint,
|
||||||
|
title = originalBookNameHint.substringBeforeLast("."),
|
||||||
|
author = "Unknown",
|
||||||
|
language = "en",
|
||||||
|
coverImage = null,
|
||||||
|
chapters = emptyList(),
|
||||||
|
chaptersForPagination = emptyList(),
|
||||||
|
images = emptyList(),
|
||||||
|
pageList = emptyList(),
|
||||||
|
extractionBasePath = "",
|
||||||
|
css = emptyMap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||||
|
if (!exists()) mkdirs()
|
||||||
|
}
|
||||||
|
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||||
|
|
||||||
|
if (metadataFile.exists()) {
|
||||||
|
try {
|
||||||
|
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
||||||
|
Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
|
||||||
|
return@withContext cachedBook
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to load cached DOCX, parsing again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val parseStart = System.currentTimeMillis()
|
||||||
|
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint")
|
||||||
|
|
||||||
|
val converter = DocumentConverter()
|
||||||
|
val result = converter.convertToHtml(inputStream)
|
||||||
|
val htmlContent = result.value ?: ""
|
||||||
|
|
||||||
|
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms")
|
||||||
|
|
||||||
|
val fullHtml = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>${originalBookNameHint.substringBeforeLast(".")}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
$htmlContent
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
// 4. Delegate to the already built HTML caching and chunking mechanisms!
|
||||||
|
return@withContext parseHtml(fullHtml.byteInputStream(), originalBookNameHint, bookId, parseContent)
|
||||||
|
}
|
||||||
|
|
||||||
private fun writeHtmlChapter(
|
private fun writeHtmlChapter(
|
||||||
extractionDir: File,
|
extractionDir: File,
|
||||||
bookId: String,
|
bookId: String,
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Remove
|
import androidx.compose.material.icons.filled.Remove
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.SwapHoriz
|
import androidx.compose.material.icons.filled.SwapHoriz
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
|
@ -155,6 +156,7 @@ fun EpubReaderTopBar(
|
||||||
onOpenDeviceVoiceSettings: () -> Unit,
|
onOpenDeviceVoiceSettings: () -> Unit,
|
||||||
onOpenDictionarySettings: () -> Unit,
|
onOpenDictionarySettings: () -> Unit,
|
||||||
onOpenThemeSettings: () -> Unit,
|
onOpenThemeSettings: () -> Unit,
|
||||||
|
onOpenVisualOptions: () -> Unit,
|
||||||
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
|
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onToggleReflow: (() -> Unit)? = null,
|
onToggleReflow: (() -> Unit)? = null,
|
||||||
|
|
@ -346,6 +348,18 @@ fun EpubReaderTopBar(
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Visual Options") },
|
||||||
|
onClick = {
|
||||||
|
showMoreMenu = false
|
||||||
|
onOpenVisualOptions()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Auto Scroll") },
|
text = { Text("Auto Scroll") },
|
||||||
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
|
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
|
||||||
|
|
@ -357,7 +371,6 @@ fun EpubReaderTopBar(
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
// *** ADDITION START ***
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("TTS Voice Settings") },
|
text = { Text("TTS Voice Settings") },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
|
||||||
|
|
@ -460,6 +460,7 @@ fun EpubReaderHost(
|
||||||
val searchFocusRequester = remember { FocusRequester() }
|
val searchFocusRequester = remember { FocusRequester() }
|
||||||
val containerFocusRequester = remember { FocusRequester() }
|
val containerFocusRequester = remember { FocusRequester() }
|
||||||
var isNavigatingToPosition by remember { mutableStateOf(false) }
|
var isNavigatingToPosition by remember { mutableStateOf(false) }
|
||||||
|
var isSeamlessTransitioning by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var isPageSliderVisible by remember { mutableStateOf(false) }
|
var isPageSliderVisible by remember { mutableStateOf(false) }
|
||||||
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
|
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
@ -476,6 +477,11 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
|
||||||
|
var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
|
||||||
|
var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
|
||||||
|
var showVisualOptionsSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var volumeScrollEnabled by remember {
|
var volumeScrollEnabled by remember {
|
||||||
mutableStateOf(loadVolumeScrollSetting(context))
|
mutableStateOf(loadVolumeScrollSetting(context))
|
||||||
}
|
}
|
||||||
|
|
@ -1354,7 +1360,8 @@ fun EpubReaderHost(
|
||||||
showBars = showBars,
|
showBars = showBars,
|
||||||
initialIsAppearanceLightStatusBars = initialIsAppearanceLightStatusBars,
|
initialIsAppearanceLightStatusBars = initialIsAppearanceLightStatusBars,
|
||||||
initialSystemBarsBehavior = initialSystemBarsBehavior,
|
initialSystemBarsBehavior = initialSystemBarsBehavior,
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme,
|
||||||
|
systemUiMode = systemUiMode
|
||||||
)
|
)
|
||||||
|
|
||||||
var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) }
|
var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) }
|
||||||
|
|
@ -1433,6 +1440,17 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val pageInfoBottomPadding by androidx.compose.animation.core.animateDpAsState(
|
||||||
|
targetValue = if (showBars && pageInfoMode == PageInfoMode.SYNC) 45.dp else 0.dp,
|
||||||
|
label = "PageInfoBottomPadding"
|
||||||
|
)
|
||||||
|
|
||||||
|
val isPageInfoVisible = when (pageInfoMode) {
|
||||||
|
PageInfoMode.DEFAULT -> !showBars
|
||||||
|
PageInfoMode.SYNC -> showBars
|
||||||
|
PageInfoMode.HIDDEN -> false
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(bookmarks, paginator) {
|
LaunchedEffect(bookmarks, paginator) {
|
||||||
paginator ?: return@LaunchedEffect
|
paginator ?: return@LaunchedEffect
|
||||||
val bookPaginator = paginator as? BookPaginator
|
val bookPaginator = paginator as? BookPaginator
|
||||||
|
|
@ -1974,11 +1992,37 @@ fun EpubReaderHost(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
contentWindowInsets = WindowInsets.statusBars,
|
||||||
) { scaffoldPaddingValues ->
|
) { scaffoldPaddingValues ->
|
||||||
|
val currentTopPadding = scaffoldPaddingValues.calculateTopPadding()
|
||||||
|
var stableTopPadding by remember { mutableStateOf(0.dp) }
|
||||||
|
if (currentTopPadding > stableTopPadding) {
|
||||||
|
stableTopPadding = currentTopPadding
|
||||||
|
}
|
||||||
|
|
||||||
|
val effectiveTopPadding = if (currentRenderMode == RenderMode.PAGINATED) {
|
||||||
|
if (systemUiMode == SystemUiMode.HIDDEN) {
|
||||||
|
0.dp
|
||||||
|
} else {
|
||||||
|
val insets = androidx.core.view.ViewCompat.getRootWindowInsets(view)
|
||||||
|
val ignoringVisibilityTopPx = insets?.getInsetsIgnoringVisibility(androidx.core.view.WindowInsetsCompat.Type.statusBars())?.top ?: 0
|
||||||
|
val ignoringVisibilityTop = with(density) { ignoringVisibilityTopPx.toDp() }
|
||||||
|
|
||||||
|
if (ignoringVisibilityTop > 0.dp) {
|
||||||
|
ignoringVisibilityTop
|
||||||
|
} else if (stableTopPadding > 0.dp) {
|
||||||
|
stableTopPadding
|
||||||
|
} else {
|
||||||
|
24.dp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentTopPadding
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(effectiveBg)
|
.background(effectiveBg)
|
||||||
.padding(scaffoldPaddingValues)
|
.padding(top = effectiveTopPadding)
|
||||||
.focusRequester(containerFocusRequester)
|
.focusRequester(containerFocusRequester)
|
||||||
.focusable()
|
.focusable()
|
||||||
.volumeScrollHandler(
|
.volumeScrollHandler(
|
||||||
|
|
@ -2031,10 +2075,16 @@ fun EpubReaderHost(
|
||||||
) {
|
) {
|
||||||
when (currentRenderMode) {
|
when (currentRenderMode) {
|
||||||
RenderMode.VERTICAL_SCROLL -> {
|
RenderMode.VERTICAL_SCROLL -> {
|
||||||
|
val contentBottomPadding = if (showBars || showFormatAdjustmentBars) {
|
||||||
|
0.dp
|
||||||
|
} else {
|
||||||
|
if (pageInfoMode == PageInfoMode.DEFAULT) PAGE_INFO_BAR_HEIGHT else 0.dp
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(bottom = if (showBars || showFormatAdjustmentBars) 0.dp else PAGE_INFO_BAR_HEIGHT)
|
.padding(bottom = contentBottomPadding)
|
||||||
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
|
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
|
||||||
.testTag("ReaderContainer")
|
.testTag("ReaderContainer")
|
||||||
) {
|
) {
|
||||||
|
|
@ -2049,6 +2099,9 @@ fun EpubReaderHost(
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = currentChapterIndex,
|
targetState = currentChapterIndex,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
|
if (!pullToTurnEnabled) {
|
||||||
|
fadeIn(animationSpec = tween(150)) togetherWith fadeOut(animationSpec = tween(150))
|
||||||
|
} else {
|
||||||
if (targetState > initialState) {
|
if (targetState > initialState) {
|
||||||
(slideInVertically { height -> height } + fadeIn())
|
(slideInVertically { height -> height } + fadeIn())
|
||||||
.togetherWith(slideOutVertically { height -> -height } + fadeOut())
|
.togetherWith(slideOutVertically { height -> -height } + fadeOut())
|
||||||
|
|
@ -2056,6 +2109,7 @@ fun EpubReaderHost(
|
||||||
(slideInVertically { height -> -height } + fadeIn())
|
(slideInVertically { height -> -height } + fadeIn())
|
||||||
.togetherWith(slideOutVertically { height -> height } + fadeOut())
|
.togetherWith(slideOutVertically { height -> height } + fadeOut())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
label = "ChapterChangeAnimation",
|
label = "ChapterChangeAnimation",
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
|
|
@ -2159,9 +2213,7 @@ fun EpubReaderHost(
|
||||||
.mapNotNull { it.fragmentId }
|
.mapNotNull { it.fragmentId }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("KotlinConstantConditions",
|
@Suppress("ControlFlowWithEmptyBody")
|
||||||
"ControlFlowWithEmptyBody"
|
|
||||||
)
|
|
||||||
ChapterWebView(
|
ChapterWebView(
|
||||||
key = chapterKeyForWebView,
|
key = chapterKeyForWebView,
|
||||||
chapterTitle = chapterToRender.title,
|
chapterTitle = chapterToRender.title,
|
||||||
|
|
@ -2292,19 +2344,48 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOverScrollTop = { dragAmount ->
|
onOverScrollTop = { dragAmount ->
|
||||||
|
if (pullToTurnEnabled) {
|
||||||
if (targetChapterIndex > 0) {
|
if (targetChapterIndex > 0) {
|
||||||
pullToPrevProgress =
|
pullToPrevProgress = dragAmount / dragThresholdPx
|
||||||
dragAmount / dragThresholdPx
|
}
|
||||||
|
} else {
|
||||||
|
if (targetChapterIndex > 0 && dragAmount > 20f && !isSeamlessTransitioning) {
|
||||||
|
isSeamlessTransitioning = true
|
||||||
|
webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null)
|
||||||
|
scope.launch {
|
||||||
|
delay(20)
|
||||||
|
initialScrollTargetForChapter = ChapterScrollPosition.END
|
||||||
|
currentChapterIndex--
|
||||||
|
if (showBars) showBars = false
|
||||||
|
delay(300)
|
||||||
|
isSeamlessTransitioning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOverScrollBottom = { dragAmount ->
|
onOverScrollBottom = { dragAmount ->
|
||||||
|
if (pullToTurnEnabled) {
|
||||||
if (targetChapterIndex < chapters.size - 1) {
|
if (targetChapterIndex < chapters.size - 1) {
|
||||||
pullToNextProgress =
|
pullToNextProgress = dragAmount / dragThresholdPx
|
||||||
dragAmount / dragThresholdPx
|
}
|
||||||
|
} else {
|
||||||
|
if (targetChapterIndex < chapters.size - 1 && dragAmount > 20f && !isSeamlessTransitioning) {
|
||||||
|
isSeamlessTransitioning = true
|
||||||
|
webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null)
|
||||||
|
scope.launch {
|
||||||
|
delay(20)
|
||||||
|
initialScrollTargetForChapter = ChapterScrollPosition.START
|
||||||
|
currentScrollYPosition = 0
|
||||||
|
currentChapterIndex++
|
||||||
|
if (showBars) showBars = false
|
||||||
|
delay(300)
|
||||||
|
isSeamlessTransitioning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onReleaseOverScrollTop = {
|
onReleaseOverScrollTop = {
|
||||||
if (targetChapterIndex > 0 && pullToPrevProgress >= 1.0f) {
|
if (pullToTurnEnabled && targetChapterIndex > 0 && pullToPrevProgress >= 1.0f) {
|
||||||
Timber.d("Swipe-up triggered. Saving position before changing to previous chapter."
|
Timber.d("Swipe-up triggered. Saving position before changing to previous chapter."
|
||||||
)
|
)
|
||||||
webViewRefForTts?.evaluateJavascript(
|
webViewRefForTts?.evaluateJavascript(
|
||||||
|
|
@ -2324,7 +2405,7 @@ fun EpubReaderHost(
|
||||||
pullToPrevProgress = 0f
|
pullToPrevProgress = 0f
|
||||||
},
|
},
|
||||||
onReleaseOverScrollBottom = {
|
onReleaseOverScrollBottom = {
|
||||||
if (targetChapterIndex < chapters.size - 1 && pullToNextProgress >= 1.0f) {
|
if (pullToTurnEnabled && targetChapterIndex < chapters.size - 1 && pullToNextProgress >= 1.0f) {
|
||||||
Timber.d("Swipe-down triggered. Saving position before changing to next chapter."
|
Timber.d("Swipe-down triggered. Saving position before changing to next chapter."
|
||||||
)
|
)
|
||||||
webViewRefForTts?.evaluateJavascript(
|
webViewRefForTts?.evaluateJavascript(
|
||||||
|
|
@ -2672,7 +2753,7 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentChapterIndex > 0) {
|
if (pullToTurnEnabled && currentChapterIndex > 0) {
|
||||||
ChapterChangeIndicator(
|
ChapterChangeIndicator(
|
||||||
text = "Release for Previous Chapter",
|
text = "Release for Previous Chapter",
|
||||||
progress = pullToPrevProgress,
|
progress = pullToPrevProgress,
|
||||||
|
|
@ -2683,7 +2764,7 @@ fun EpubReaderHost(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentChapterIndex < chapters.size - 1) {
|
if (pullToTurnEnabled && currentChapterIndex < chapters.size - 1) {
|
||||||
ChapterChangeIndicator(
|
ChapterChangeIndicator(
|
||||||
text = "Release for Next Chapter",
|
text = "Release for Next Chapter",
|
||||||
progress = pullToNextProgress,
|
progress = pullToNextProgress,
|
||||||
|
|
@ -2698,13 +2779,14 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderMode.PAGINATED -> {
|
RenderMode.PAGINATED -> {
|
||||||
|
val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp
|
||||||
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(bottom = PAGE_INFO_BAR_HEIGHT)
|
.padding(bottom = contentBottomPadding)
|
||||||
.testTag("ReaderContainer")
|
.testTag("ReaderContainer")
|
||||||
) {
|
) {
|
||||||
@Suppress("KotlinConstantConditions")
|
|
||||||
PaginatedReaderScreen(
|
PaginatedReaderScreen(
|
||||||
book = epubBook,
|
book = epubBook,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
|
@ -2992,7 +3074,7 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
// Page Info Bar (Vertical)
|
// Page Info Bar (Vertical)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = renderMode == RenderMode.VERTICAL_SCROLL && !showBars,
|
visible = renderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
|
||||||
enter = fadeIn(animationSpec = tween(200)),
|
enter = fadeIn(animationSpec = tween(200)),
|
||||||
exit = fadeOut(animationSpec = tween(200)),
|
exit = fadeOut(animationSpec = tween(200)),
|
||||||
modifier = Modifier.align(Alignment.BottomCenter)
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
|
|
@ -3002,7 +3084,7 @@ fun EpubReaderHost(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(PAGE_INFO_BAR_HEIGHT)
|
.height(PAGE_INFO_BAR_HEIGHT)
|
||||||
.background(infoBarBgColor)
|
.background(infoBarBgColor)
|
||||||
.padding(bottom = bottomPadding)
|
.padding(bottom = bottomPadding + pageInfoBottomPadding)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
|
@ -3035,7 +3117,7 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
// Page Info Bar (Paginated)
|
// Page Info Bar (Paginated)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = renderMode == RenderMode.PAGINATED && paginator != null && !showBars && paginatedPagerState.pageCount > 0,
|
visible = renderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0,
|
||||||
enter = fadeIn(animationSpec = tween(200)),
|
enter = fadeIn(animationSpec = tween(200)),
|
||||||
exit = fadeOut(animationSpec = tween(200)),
|
exit = fadeOut(animationSpec = tween(200)),
|
||||||
modifier = Modifier.align(Alignment.BottomCenter)
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
|
|
@ -3045,7 +3127,7 @@ fun EpubReaderHost(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(PAGE_INFO_BAR_HEIGHT)
|
.height(PAGE_INFO_BAR_HEIGHT)
|
||||||
.background(infoBarBgColor)
|
.background(infoBarBgColor)
|
||||||
.padding(bottom = bottomPadding)
|
.padding(bottom = bottomPadding + pageInfoBottomPadding)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
|
@ -3400,6 +3482,7 @@ fun EpubReaderHost(
|
||||||
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||||
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
|
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
|
||||||
onOpenThemeSettings = { showThemePanel = true },
|
onOpenThemeSettings = { showThemePanel = true },
|
||||||
|
onOpenVisualOptions = { showVisualOptionsSheet = true },
|
||||||
onToggleReflow = if (onToggleReflow != null) {
|
onToggleReflow = if (onToggleReflow != null) {
|
||||||
{
|
{
|
||||||
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
|
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
|
||||||
|
|
@ -3950,6 +4033,27 @@ fun EpubReaderHost(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showVisualOptionsSheet) {
|
||||||
|
VisualOptionsSheet(
|
||||||
|
systemUiMode = systemUiMode,
|
||||||
|
onSystemUiModeChange = {
|
||||||
|
systemUiMode = it
|
||||||
|
saveSystemUiMode(context, it)
|
||||||
|
},
|
||||||
|
pageInfoMode = pageInfoMode,
|
||||||
|
onPageInfoModeChange = {
|
||||||
|
pageInfoMode = it
|
||||||
|
savePageInfoMode(context, it)
|
||||||
|
},
|
||||||
|
pullToTurnEnabled = pullToTurnEnabled,
|
||||||
|
onPullToTurnChange = {
|
||||||
|
pullToTurnEnabled = it
|
||||||
|
savePullToTurn(context, it)
|
||||||
|
},
|
||||||
|
onDismiss = { showVisualOptionsSheet = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showFontSelectionSheet) {
|
if (showFontSelectionSheet) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { showFontSelectionSheet = false },
|
onDismissRequest = { showFontSelectionSheet = false },
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,12 @@ import androidx.core.content.edit
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.data.CustomFontEntity
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
|
||||||
const val SETTINGS_PREFS_NAME = "epub_reader_settings"
|
const val SETTINGS_PREFS_NAME = "epub_reader_settings"
|
||||||
private const val TEXT_ALIGN_KEY = "reader_text_align"
|
private const val TEXT_ALIGN_KEY = "reader_text_align"
|
||||||
|
|
@ -100,6 +106,9 @@ private const val AUTO_SCROLL_SPEED_KEY = "reader_auto_scroll_speed"
|
||||||
private const val FONT_FAMILY_KEY = "reader_font_family"
|
private const val FONT_FAMILY_KEY = "reader_font_family"
|
||||||
private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
|
private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
|
||||||
private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled"
|
private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled"
|
||||||
|
private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode"
|
||||||
|
private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
|
||||||
|
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
|
||||||
|
|
||||||
const val DEFAULT_FONT_SIZE_VAL = 1.0f
|
const val DEFAULT_FONT_SIZE_VAL = 1.0f
|
||||||
const val DEFAULT_LINE_HEIGHT_VAL = 1.6f
|
const val DEFAULT_LINE_HEIGHT_VAL = 1.6f
|
||||||
|
|
@ -119,6 +128,18 @@ enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId:
|
||||||
JUSTIFY("justify", "justify", R.drawable.format_align_justify, "Justify")
|
JUSTIFY("justify", "justify", R.drawable.format_align_justify, "Justify")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class SystemUiMode(val id: Int, val title: String) {
|
||||||
|
DEFAULT(0, "Always Show"),
|
||||||
|
SYNC(1, "Sync with Menus"),
|
||||||
|
HIDDEN(2, "Always Hide")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class PageInfoMode(val id: Int, val title: String) {
|
||||||
|
DEFAULT(0, "Always Show"),
|
||||||
|
SYNC(1, "Sync with Menus"),
|
||||||
|
HIDDEN(2, "Always Hide")
|
||||||
|
}
|
||||||
|
|
||||||
data class FormatSettings(
|
data class FormatSettings(
|
||||||
val fontSize: Float,
|
val fontSize: Float,
|
||||||
val lineHeight: Float,
|
val lineHeight: Float,
|
||||||
|
|
@ -165,6 +186,38 @@ fun saveLocalReaderSettings(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveSystemUiMode(context: Context, mode: SystemUiMode) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putInt(SYSTEM_UI_MODE_KEY, mode.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSystemUiMode(context: Context): SystemUiMode {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val id = prefs.getInt(SYSTEM_UI_MODE_KEY, SystemUiMode.DEFAULT.id)
|
||||||
|
return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.DEFAULT
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePageInfoMode(context: Context, mode: PageInfoMode) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putInt(PAGE_INFO_MODE_KEY, mode.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPageInfoMode(context: Context): PageInfoMode {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val id = prefs.getInt(PAGE_INFO_MODE_KEY, PageInfoMode.DEFAULT.id)
|
||||||
|
return PageInfoMode.entries.find { it.id == id } ?: PageInfoMode.DEFAULT
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePullToTurn(context: Context, enabled: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putBoolean(PULL_TO_TURN_ENABLED_KEY, enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPullToTurn(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true)
|
||||||
|
}
|
||||||
|
|
||||||
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
|
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
|
@ -592,3 +645,127 @@ fun FontSelectionSheetContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun VisualOptionsSheet(
|
||||||
|
systemUiMode: SystemUiMode,
|
||||||
|
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
||||||
|
pageInfoMode: PageInfoMode,
|
||||||
|
onPageInfoModeChange: (PageInfoMode) -> Unit,
|
||||||
|
pullToTurnEnabled: Boolean,
|
||||||
|
onPullToTurnChange: (Boolean) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentWindowInsets = { WindowInsets.navigationBars }
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Visual Options", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// System UI
|
||||||
|
Text("System UI (Status & Navigation Bars)", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text("Control the visibility of the device's system bars.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
OptionSegmentedControl(
|
||||||
|
options = SystemUiMode.entries,
|
||||||
|
selectedOption = systemUiMode,
|
||||||
|
onOptionSelected = onSystemUiModeChange,
|
||||||
|
getLabel = { it.title }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Progress Bar
|
||||||
|
Text("Progress Bar", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text("The reading progress and chapter indicator at the bottom of the screen.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
OptionSegmentedControl(
|
||||||
|
options = PageInfoMode.entries,
|
||||||
|
selectedOption = pageInfoMode,
|
||||||
|
onOptionSelected = onPageInfoModeChange,
|
||||||
|
getLabel = { it.title }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Pull to change chapter
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onPullToTurnChange(!pullToTurnEnabled) }
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Seamless Chapter Transition", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text("Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Switch(checked = !pullToTurnEnabled, onCheckedChange = { onPullToTurnChange(!it) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> OptionSegmentedControl(
|
||||||
|
options: List<T>,
|
||||||
|
selectedOption: T,
|
||||||
|
onOptionSelected: (T) -> Unit,
|
||||||
|
getLabel: (T) -> String
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
|
||||||
|
.padding(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
options.forEach { option ->
|
||||||
|
val isSelected = option == selectedOption
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||||
|
.clickable { onOptionSelected(option) }
|
||||||
|
.padding(vertical = 10.dp, horizontal = 4.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = getLabel(option),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,7 +42,8 @@ fun EpubReaderSystemUiController(
|
||||||
showBars: Boolean,
|
showBars: Boolean,
|
||||||
initialIsAppearanceLightStatusBars: Boolean,
|
initialIsAppearanceLightStatusBars: Boolean,
|
||||||
initialSystemBarsBehavior: Int,
|
initialSystemBarsBehavior: Int,
|
||||||
isDarkTheme: Boolean
|
isDarkTheme: Boolean,
|
||||||
|
systemUiMode: SystemUiMode
|
||||||
) {
|
) {
|
||||||
DisposableEffect(window, view, initialIsAppearanceLightStatusBars, initialSystemBarsBehavior) {
|
DisposableEffect(window, view, initialIsAppearanceLightStatusBars, initialSystemBarsBehavior) {
|
||||||
if (window == null) {
|
if (window == null) {
|
||||||
|
|
@ -53,13 +54,12 @@ fun EpubReaderSystemUiController(
|
||||||
Timber.d("Applying immersive mode.")
|
Timber.d("Applying immersive mode.")
|
||||||
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
|
|
||||||
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
Timber.d("Restoring system UI.")
|
Timber.d("Restoring system UI.")
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||||
insetsController.show(WindowInsetsCompat.Type.navigationBars())
|
insetsController.show(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.statusBars())
|
||||||
insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars
|
insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars
|
||||||
insetsController.systemBarsBehavior = initialSystemBarsBehavior
|
insetsController.systemBarsBehavior = initialSystemBarsBehavior
|
||||||
}
|
}
|
||||||
|
|
@ -72,13 +72,25 @@ fun EpubReaderSystemUiController(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(showBars, window, view) {
|
LaunchedEffect(showBars, systemUiMode, window, view) {
|
||||||
if (window != null) {
|
if (window != null) {
|
||||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||||
|
when (systemUiMode) {
|
||||||
|
SystemUiMode.DEFAULT -> {
|
||||||
|
insetsController.show(WindowInsetsCompat.Type.statusBars())
|
||||||
|
if (showBars) insetsController.show(WindowInsetsCompat.Type.navigationBars())
|
||||||
|
else insetsController.hide(WindowInsetsCompat.Type.navigationBars())
|
||||||
|
}
|
||||||
|
SystemUiMode.SYNC -> {
|
||||||
if (showBars) {
|
if (showBars) {
|
||||||
insetsController.show(WindowInsetsCompat.Type.navigationBars())
|
insetsController.show(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars())
|
||||||
} else {
|
} else {
|
||||||
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
|
insetsController.hide(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SystemUiMode.HIDDEN -> {
|
||||||
|
insetsController.hide(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,10 +148,29 @@ abstract class BookCacheDao {
|
||||||
@Query("DELETE FROM processed_chapter_metadata")
|
@Query("DELETE FROM processed_chapter_metadata")
|
||||||
protected abstract suspend fun deleteAllChapterMetadata()
|
protected abstract suspend fun deleteAllChapterMetadata()
|
||||||
|
|
||||||
|
@Query("DELETE FROM configuration_cache WHERE bookId = :bookId")
|
||||||
|
abstract suspend fun deleteConfigurationCacheForBook(bookId: String)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open suspend fun deleteEntireBookCache(bookId: String) {
|
||||||
|
deleteBook(bookId)
|
||||||
|
deleteChaptersForBook(bookId)
|
||||||
|
deleteAnchorsForBook(bookId)
|
||||||
|
deleteConfigurationCacheForBook(bookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("DELETE FROM anchor_index")
|
||||||
|
abstract suspend fun clearAnchors()
|
||||||
|
|
||||||
|
@Query("DELETE FROM configuration_cache")
|
||||||
|
abstract suspend fun clearConfigurationCache()
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open suspend fun clearAllCache() {
|
open suspend fun clearAllCache() {
|
||||||
clearProcessedBooks()
|
clearProcessedBooks()
|
||||||
clearProcessedChapters()
|
clearProcessedChapters()
|
||||||
|
clearAnchors()
|
||||||
|
clearConfigurationCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query("SELECT * FROM configuration_cache WHERE bookId = :bookId AND configHash = :configHash")
|
@Query("SELECT * FROM configuration_cache WHERE bookId = :bookId AND configHash = :configHash")
|
||||||
|
|
|
||||||
|
|
@ -5970,6 +5970,7 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI feat
|
// AI feat
|
||||||
|
if (BuildConfig.FLAVOR != "oss") {
|
||||||
Box {
|
Box {
|
||||||
var showAiFeaturesMenu by remember { mutableStateOf(false) }
|
var showAiFeaturesMenu by remember { mutableStateOf(false) }
|
||||||
TooltipIconButton(
|
TooltipIconButton(
|
||||||
|
|
@ -5993,12 +5994,12 @@ fun PdfViewerScreen(
|
||||||
if (isProUser) {
|
if (isProUser) {
|
||||||
showSummarizationPopup = true
|
showSummarizationPopup = true
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
isSummarizationLoading = true
|
isAiDefinitionLoading = true
|
||||||
summarizationResult = null
|
summarizationResult = null
|
||||||
summarizeCurrentPage(onUpdate = { result ->
|
summarizeCurrentPage(onUpdate = { result ->
|
||||||
summarizationResult = result
|
summarizationResult = result
|
||||||
}, onFinish = {
|
}, onFinish = {
|
||||||
isSummarizationLoading = false
|
isAiDefinitionLoading = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -6008,6 +6009,7 @@ fun PdfViewerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Edit Button
|
// Edit Button
|
||||||
TooltipIconButton(
|
TooltipIconButton(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue