Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
d073110245
67 changed files with 9724 additions and 1890 deletions
|
|
@ -79,7 +79,7 @@
|
|||
}
|
||||
|
||||
img, svg, video, canvas {
|
||||
max-width: 100%; width: 100%; height: auto; display: block; margin-left: auto; margin-right: auto; background-color: transparent; object-fit: contain;
|
||||
max-width: 100%; width: auto; height: auto; display: block; margin-left: auto; margin-right: auto; background-color: transparent; object-fit: contain;
|
||||
}
|
||||
|
||||
figure img {
|
||||
|
|
@ -481,10 +481,18 @@
|
|||
if (anchor) {
|
||||
var href = anchor.getAttribute('href');
|
||||
var epubType = anchor.getAttribute('epub:type');
|
||||
var linkText = (anchor.textContent || '').trim().substring(0, 80);
|
||||
|
||||
console.log("LINK_NAV: [JS-CLICK] href='" + href + "', epub:type='" + epubType + "', label='" + linkText + "'");
|
||||
|
||||
if (window.LinkNavBridge && window.LinkNavBridge.onLinkClicked) {
|
||||
window.LinkNavBridge.onLinkClicked(href || '', epubType || '', linkText);
|
||||
}
|
||||
|
||||
console.log("FootnoteDiag: Link clicked. href: '" + href + "', epub:type: '" + epubType + "'");
|
||||
|
||||
if ((href && href.startsWith('#')) || epubType === 'noteref') {
|
||||
if ((href && href.startsWith('#')) || epubType === 'noteref') {
|
||||
console.log("LINK_NAV: [JS-CLASSIFY] type=FRAGMENT_OR_FOOTNOTE, href='" + href + "'");
|
||||
var targetId = href ? href.substring(1) : null;
|
||||
console.log("FootnoteDiag: Extracted targetId: '" + targetId + "'");
|
||||
|
||||
|
|
@ -504,10 +512,12 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("LINK_NAV: [JS-NO-ANCHOR] No <a> tag found in click target hierarchy");
|
||||
}
|
||||
}, true);
|
||||
|
||||
window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap) {
|
||||
window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin) {
|
||||
var logTag = "ReaderFontDiagnosis";
|
||||
console.log(
|
||||
logTag +
|
||||
|
|
@ -517,11 +527,15 @@
|
|||
lineHeight +
|
||||
", Font: '" +
|
||||
fontFamily +
|
||||
"', Align: '" +
|
||||
textAlign +
|
||||
"', Gap: " +
|
||||
paragraphGap
|
||||
);
|
||||
"', Align: '" +
|
||||
textAlign +
|
||||
"', Gap: " +
|
||||
paragraphGap +
|
||||
", ImageSize: " +
|
||||
imageSize +
|
||||
", HorizontalMargin: " +
|
||||
horizontalMargin
|
||||
);
|
||||
|
||||
var dynamicStyleId = "dynamicReaderStyles";
|
||||
var dynamicStyleElement = document.getElementById(dynamicStyleId);
|
||||
|
|
@ -535,10 +549,14 @@
|
|||
var newFontSize = parseFloat(fontSizeEm);
|
||||
var newLineHeight = parseFloat(lineHeight);
|
||||
var newGap = parseFloat(paragraphGap);
|
||||
var newImageSize = parseFloat(imageSize);
|
||||
var newHorizontalMargin = parseFloat(horizontalMargin);
|
||||
|
||||
if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0;
|
||||
if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0;
|
||||
if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0;
|
||||
if (isNaN(newImageSize) || newImageSize < 0.5 || newImageSize > 2.0) newImageSize = 1.0;
|
||||
if (isNaN(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0;
|
||||
|
||||
var fontCss = "";
|
||||
if (fontFamily && fontFamily !== "Original" && fontFamily !== "") {
|
||||
|
|
@ -590,7 +608,31 @@
|
|||
`;
|
||||
}
|
||||
|
||||
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss].join("\n");
|
||||
var horizontalPaddingPx = Math.max(0, 16 * newHorizontalMargin);
|
||||
var horizontalMarginCss = `
|
||||
body {
|
||||
box-sizing: border-box !important;
|
||||
padding-left: ${horizontalPaddingPx}px !important;
|
||||
padding-right: ${horizontalPaddingPx}px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
var imageCss = `
|
||||
:root {
|
||||
--reader-image-size: ${newImageSize};
|
||||
}
|
||||
body img,
|
||||
body svg,
|
||||
body video,
|
||||
body canvas,
|
||||
body image {
|
||||
width: min(100%, calc(100% * var(--reader-image-size))) !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
`;
|
||||
|
||||
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n");
|
||||
|
||||
setTimeout(
|
||||
function () {
|
||||
|
|
|
|||
2083
app/src/main/assets/google_fonts.json
Normal file
2083
app/src/main/assets/google_fonts.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -300,14 +300,18 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectCount(JNIEnv *env, jcl
|
|||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
||||
if (!init_pdfium() || !get_object_func || !get_object_type_func) return 0;
|
||||
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0;
|
||||
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
|
||||
if (index >= object_count) return 0;
|
||||
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
|
||||
return obj ? get_object_type_func(obj) : 0;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) {
|
||||
if (!init_pdfium() || !get_object_func || !get_object_bounds_func) return JNI_FALSE;
|
||||
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE;
|
||||
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
|
||||
if (index >= object_count) return JNI_FALSE;
|
||||
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
|
||||
if (!obj) return JNI_FALSE;
|
||||
|
||||
|
|
@ -322,7 +326,9 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *en
|
|||
|
||||
extern "C" JNIEXPORT jintArray JNICALL
|
||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) {
|
||||
if (!init_pdfium() || !get_object_func || !get_image_bitmap_func || !bitmap_get_buffer_func) return nullptr;
|
||||
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || !get_image_bitmap_func || !bitmap_get_buffer_func || pagePtr == 0 || index < 0 || dimens == nullptr) return nullptr;
|
||||
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
|
||||
if (index >= object_count) return nullptr;
|
||||
|
||||
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
|
||||
if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE
|
||||
|
|
@ -605,4 +611,4 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getLinkInfoAtPoint(JNIEnv *env, jcl
|
|||
|
||||
LOGI("PdfLinkDiagnostic: Link found but payload was empty or unsupported.");
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,16 @@ object AppDestinations {
|
|||
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
||||
}
|
||||
|
||||
private fun NavHostController.navigateSingleTopTo(route: String) {
|
||||
navigate(route) {
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
popUpTo(graph.startDestinationId) {
|
||||
saveState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
@Composable
|
||||
|
|
@ -77,25 +87,21 @@ fun AppNavigation(
|
|||
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
|
||||
if (uiState.selectedPdfUri != null) {
|
||||
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
|
||||
navController.navigate(AppDestinations.PDF_VIEWER_ROUTE) {
|
||||
popUpTo(AppDestinations.MAIN_ROUTE)
|
||||
}
|
||||
navController.navigateSingleTopTo(AppDestinations.PDF_VIEWER_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> {
|
||||
if (uiState.selectedEpubBook != null) {
|
||||
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
|
||||
navController.navigate(AppDestinations.EPUB_READER_ROUTE) {
|
||||
popUpTo(AppDestinations.MAIN_ROUTE)
|
||||
}
|
||||
navController.navigateSingleTopTo(AppDestinations.EPUB_READER_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
null -> {
|
||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||
if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) {
|
||||
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
|
||||
navController.navigateSingleTopTo(AppDestinations.MAIN_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -286,4 +292,4 @@ fun AppNavigation(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ import timber.log.Timber
|
|||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
|
@ -1284,6 +1285,21 @@ fun DeviceVoicesTab(
|
|||
.clickable(enabled = !isTtsActive && isBaseMode) {
|
||||
savedVoiceName = null
|
||||
saveNativeVoice(context, null)
|
||||
ttsEngine?.apply {
|
||||
try {
|
||||
val defaultLocale = Locale.getDefault()
|
||||
language = defaultLocale
|
||||
val fallbackVoice =
|
||||
defaultVoice ?: voices.firstOrNull { voice ->
|
||||
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
|
||||
} ?: voices.firstOrNull { voice ->
|
||||
voice.locale == defaultLocale
|
||||
}
|
||||
fallbackVoice?.let { voice = it }
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("TTS_DIAGNOSE").w(e, "Failed to reset preview engine to system default voice")
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
|
|
@ -1361,7 +1377,7 @@ fun DeviceVoicesTab(
|
|||
enabled = !isTtsActive,
|
||||
onClick = {
|
||||
ttsEngine?.apply {
|
||||
language = voice.locale
|
||||
this.voice = voice
|
||||
speak("This is a voice sample.", TextToSpeech.QUEUE_FLUSH, null, "sample_${voice.name}")
|
||||
}
|
||||
}
|
||||
|
|
@ -3004,4 +3020,4 @@ fun ManageCacheTab(bookTitle: String, summaryCacheManager: SummaryCacheManager,
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ import kotlinx.coroutines.sync.withLock
|
|||
import kotlinx.coroutines.withContext
|
||||
import androidx.core.content.edit
|
||||
import com.aryan.reader.data.LocalSyncUtils
|
||||
import com.aryan.reader.data.FolderBookMetadata
|
||||
import java.io.File
|
||||
import android.provider.DocumentsContract
|
||||
import java.security.MessageDigest
|
||||
|
||||
class FolderSyncWorker(
|
||||
private val appContext: Context,
|
||||
|
|
@ -138,17 +141,17 @@ class FolderSyncWorker(
|
|||
LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree)
|
||||
|
||||
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
|
||||
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri)
|
||||
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
|
||||
|
||||
Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...")
|
||||
val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree)
|
||||
val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree).toMutableMap()
|
||||
|
||||
folderMetadataMap.forEach { (bookId, remoteMeta) ->
|
||||
val existingItem = recentFilesRepository.getFileByBookId(bookId)
|
||||
|
||||
if (existingItem != null) {
|
||||
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
|
||||
Timber.tag("FolderSync").d("Applying remote update for $bookId (Progress: ${remoteMeta.progressPercentage}%)")
|
||||
Timber.tag("PdfPositionDebug").w("FolderSyncWorker applies remote progress for $bookId | Local Page: ${existingItem.lastPage} -> Remote Page: ${remoteMeta.lastPage}")
|
||||
val itemToUpdate = existingItem.copy(
|
||||
lastChapterIndex = remoteMeta.lastChapterIndex,
|
||||
lastPage = remoteMeta.lastPage,
|
||||
|
|
@ -164,6 +167,8 @@ class FolderSyncWorker(
|
|||
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
|
||||
)
|
||||
recentFilesRepository.addRecentFile(itemToUpdate)
|
||||
} else {
|
||||
Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -202,39 +207,39 @@ class FolderSyncWorker(
|
|||
val contentResolver = appContext.contentResolver
|
||||
val foundBookIds = mutableSetOf<String>()
|
||||
val newOrUpdatedItems = mutableListOf<RecentFileItem>()
|
||||
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }
|
||||
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
|
||||
|
||||
val rootDocId = android.provider.DocumentsContract.getTreeDocumentId(folderUri)
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
|
||||
val dirQueue = ArrayDeque<String>()
|
||||
dirQueue.add(rootDocId)
|
||||
|
||||
val projection = arrayOf(
|
||||
android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||
android.provider.DocumentsContract.Document.COLUMN_SIZE,
|
||||
android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||
DocumentsContract.Document.COLUMN_SIZE,
|
||||
DocumentsContract.Document.COLUMN_LAST_MODIFIED
|
||||
)
|
||||
|
||||
while (dirQueue.isNotEmpty()) {
|
||||
if (isStopped) break
|
||||
val currentDocId = dirQueue.removeFirst()
|
||||
val childrenUri = android.provider.DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
|
||||
|
||||
try {
|
||||
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
|
||||
val idCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||
val nameCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||
val mimeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||
val sizeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_SIZE)
|
||||
val modCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED)
|
||||
val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||
val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||
val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
|
||||
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
|
||||
|
||||
while (cursor.moveToNext() && !isStopped) {
|
||||
val docId = cursor.getString(idCol)
|
||||
val name = cursor.getString(nameCol) ?: ""
|
||||
val mimeType = cursor.getString(mimeCol)
|
||||
|
||||
if (mimeType == android.provider.DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
if (!name.startsWith(".") && name != "EpistemeSyncData") {
|
||||
dirQueue.add(docId)
|
||||
}
|
||||
|
|
@ -244,29 +249,49 @@ class FolderSyncWorker(
|
|||
|
||||
val type = getFileType(name, mimeType)
|
||||
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
|
||||
val stableId = "local_$name"
|
||||
val stableId = buildStableBookId(name, rootDocId, docId)
|
||||
foundBookIds.add(stableId)
|
||||
|
||||
val docUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
|
||||
var existingItem = existingItemsMap[stableId]
|
||||
|
||||
if (existingItem != null && existingItem.uriString != docUri.toString()) {
|
||||
val collidedItem = existingItem
|
||||
val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId)
|
||||
if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) {
|
||||
Timber.tag("FolderSync").i("Resolving folder ID collision for ${collidedItem.displayName}: ${collidedItem.bookId} -> $collidedStableId")
|
||||
migrateFolderBookId(
|
||||
folderUriString = folderUriString,
|
||||
oldId = collidedItem.bookId,
|
||||
newId = collidedStableId,
|
||||
folderMetadataMap = folderMetadataMap,
|
||||
preloadedSidecars = preloadedSidecars,
|
||||
existingItemsMap = existingItemsMap
|
||||
)
|
||||
existingItem = existingItemsMap[stableId]
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem == null) {
|
||||
val oldItem = existingItemsMap.values.find { it.bookId.startsWith("local_${name}_") && it.bookId != stableId }
|
||||
val oldItem = existingItemsMap.values.find {
|
||||
it.bookId != stableId && (
|
||||
it.uriString == docUri.toString() ||
|
||||
it.bookId.startsWith("local_${name}_")
|
||||
)
|
||||
}
|
||||
if (oldItem != null) {
|
||||
val oldId = oldItem.bookId
|
||||
Timber.tag("FolderSync").i("Migrating book ID for $name from $oldId to $stableId")
|
||||
|
||||
recentFilesRepository.migrateBookIdLocally(oldId, stableId)
|
||||
existingItem = recentFilesRepository.getFileByBookId(stableId)
|
||||
|
||||
try {
|
||||
val syncDir = documentTree.findFile("EpistemeSyncData")
|
||||
if (syncDir != null) {
|
||||
syncDir.findFile(".$oldId.json")?.delete()
|
||||
syncDir.findFile("$oldId.json")?.delete()
|
||||
syncDir.findFile(".$oldId" + "_annotations.json")?.delete()
|
||||
}
|
||||
} catch (_: Exception) { Timber.tag("FolderSync").e("Failed to clean up orphaned SAF sidecars.") }
|
||||
migrateFolderBookId(
|
||||
folderUriString = folderUriString,
|
||||
oldId = oldId,
|
||||
newId = stableId,
|
||||
folderMetadataMap = folderMetadataMap,
|
||||
preloadedSidecars = preloadedSidecars,
|
||||
existingItemsMap = existingItemsMap
|
||||
)
|
||||
existingItem = existingItemsMap[stableId]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -410,4 +435,78 @@ class FolderSyncWorker(
|
|||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
|
||||
val relativePath = buildRelativePath(rootDocId, docId, name)
|
||||
if (relativePath.equals(name, ignoreCase = true)) {
|
||||
return "local_$name"
|
||||
}
|
||||
return "local_${name}_${shortHash(relativePath.lowercase())}"
|
||||
}
|
||||
|
||||
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
|
||||
val rootPath = rootDocId.substringAfter(':', "")
|
||||
val docPath = docId.substringAfter(':', "")
|
||||
if (docPath.isBlank()) return fallbackName
|
||||
val relative = if (rootPath.isNotBlank() && docPath.startsWith(rootPath)) {
|
||||
docPath.removePrefix(rootPath).trimStart('/')
|
||||
} else {
|
||||
docPath.substringAfterLast('/', fallbackName)
|
||||
}
|
||||
return relative.ifBlank { fallbackName }
|
||||
}
|
||||
|
||||
private fun shortHash(value: String): String {
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }.take(12)
|
||||
}
|
||||
|
||||
private fun computeStableIdForStoredItem(item: RecentFileItem, rootDocId: String): String? {
|
||||
val uriString = item.uriString ?: return null
|
||||
return try {
|
||||
val docId = DocumentsContract.getDocumentId(uriString.toUri())
|
||||
buildStableBookId(item.displayName, rootDocId, docId)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrateFolderBookId(
|
||||
folderUriString: String,
|
||||
oldId: String,
|
||||
newId: String,
|
||||
folderMetadataMap: MutableMap<String, FolderBookMetadata>,
|
||||
preloadedSidecars: MutableMap<String, Pair<Long, String>>,
|
||||
existingItemsMap: MutableMap<String, RecentFileItem>
|
||||
) {
|
||||
if (oldId == newId) return
|
||||
|
||||
recentFilesRepository.migrateBookIdLocally(oldId, newId)
|
||||
|
||||
val oldMetadata = folderMetadataMap.remove(oldId)
|
||||
if (oldMetadata != null && newId !in folderMetadataMap) {
|
||||
val migratedMetadata = oldMetadata.copy(bookId = newId)
|
||||
LocalSyncUtils.saveMetadataToFolder(appContext, folderUriString.toUri(), migratedMetadata)
|
||||
folderMetadataMap[newId] = migratedMetadata
|
||||
}
|
||||
|
||||
val oldSidecar = preloadedSidecars.remove(oldId)
|
||||
if (oldSidecar != null && newId !in preloadedSidecars) {
|
||||
LocalSyncUtils.saveAnnotationSidecar(
|
||||
context = appContext,
|
||||
sourceFolderUri = folderUriString.toUri(),
|
||||
bookId = newId,
|
||||
jsonPayload = oldSidecar.second,
|
||||
timestamp = oldSidecar.first
|
||||
)
|
||||
preloadedSidecars[newId] = oldSidecar
|
||||
}
|
||||
|
||||
LocalSyncUtils.deleteBookSidecars(appContext, folderUriString.toUri(), oldId)
|
||||
|
||||
existingItemsMap.remove(oldId)
|
||||
recentFilesRepository.getFileByBookId(newId)?.let {
|
||||
existingItemsMap[newId] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,13 @@
|
|||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
// FontsScreen.kt
|
||||
@file:Suppress("KotlinConstantConditions")
|
||||
|
||||
package com.aryan.reader
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -34,24 +38,32 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -59,6 +71,8 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
|
|
@ -69,6 +83,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import com.aryan.reader.data.CustomFontEntity
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FontsScreen(
|
||||
viewModel: MainViewModel,
|
||||
|
|
@ -76,24 +91,21 @@ fun FontsScreen(
|
|||
) {
|
||||
val fonts: List<CustomFontEntity> by viewModel.customFonts.collectAsStateWithLifecycle()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
// Dialog state
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
|
||||
var showGoogleFontsSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val pickFontLauncher = rememberFilePickerLauncher { uris ->
|
||||
uris.firstOrNull()?.let { viewModel.importFont(it) }
|
||||
}
|
||||
|
||||
// Font mime types filter
|
||||
val fontMimeTypes = arrayOf(
|
||||
"font/ttf",
|
||||
"font/otf",
|
||||
"font/woff2",
|
||||
"application/x-font-ttf",
|
||||
"application/x-font-otf",
|
||||
"application/font-woff2",
|
||||
"application/vnd.ms-opentype",
|
||||
"font/ttf", "font/otf", "font/woff2",
|
||||
"application/x-font-ttf", "application/x-font-otf",
|
||||
"application/font-woff2", "application/vnd.ms-opentype",
|
||||
"application/x-font-opentype"
|
||||
)
|
||||
|
||||
|
|
@ -111,11 +123,24 @@ fun FontsScreen(
|
|||
},
|
||||
floatingActionButton = {
|
||||
if (fonts.isNotEmpty()) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { pickFontLauncher.launch(fontMimeTypes) },
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.import_font)) }
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showGoogleFontsSheet = true },
|
||||
icon = { Icon(Icons.Default.CloudDownload, contentDescription = null) },
|
||||
text = { Text("Google Fonts") },
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { pickFontLauncher.launch(fontMimeTypes) },
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.import_font)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
|
|
@ -125,7 +150,9 @@ fun FontsScreen(
|
|||
title = stringResource(R.string.no_custom_fonts),
|
||||
message = stringResource(R.string.import_fonts_desc),
|
||||
onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
secondaryButtonText = "Browse Google Fonts",
|
||||
onSecondaryClick = { showGoogleFontsSheet = true }
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
|
|
@ -155,7 +182,6 @@ fun FontsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
// Banner messages removed as requested
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,8 +199,172 @@ fun FontsScreen(
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showGoogleFontsSheet) {
|
||||
GoogleFontsBottomSheet(
|
||||
onDismiss = { showGoogleFontsSheet = false },
|
||||
existingFonts = fonts,
|
||||
getFullFontList = { viewModel.loadGoogleFontsList(context) },
|
||||
onDownloadFont = { fontName, onComplete ->
|
||||
viewModel.downloadGoogleFont(fontName, onComplete)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GoogleFontsBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
existingFonts: List<CustomFontEntity>,
|
||||
getFullFontList: () -> List<String>,
|
||||
onDownloadFont: (String, () -> Unit) -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var downloadingFontName by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Curated presets to show when search is empty
|
||||
val popularPresets = remember {
|
||||
listOf(
|
||||
"Merriweather", "Open Sans", "Playfair Display", "Montserrat", "Oswald", "Raleway", "Nunito",
|
||||
"Poppins", "Ubuntu", "Fira Sans", "Quicksand", "Crimson Text",
|
||||
"Literata", "EB Garamond", "Libre Baskerville", "Inter", "Work Sans"
|
||||
)
|
||||
}
|
||||
|
||||
// Lazy evaluation of the full list only when typing
|
||||
val displayList = remember(searchQuery) {
|
||||
if (searchQuery.isBlank()) {
|
||||
popularPresets
|
||||
} else {
|
||||
val allFonts = getFullFontList()
|
||||
allFonts.filter { it.contains(searchQuery, ignoreCase = true) }.take(50) // Limit to 50 for performance
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Browse Google Fonts",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Search 1900+ fonts...") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (searchQuery.isBlank()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Popular Choices",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
} else if (displayList.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No fonts found matching '$searchQuery'",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(displayList) { fontName ->
|
||||
val isDownloaded = remember(existingFonts, fontName) {
|
||||
existingFonts.any { it.displayName.equals(fontName, ignoreCase = true) }
|
||||
}
|
||||
val isDownloading = downloadingFontName == fontName
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(enabled = !isDownloaded && !isDownloading) {
|
||||
downloadingFontName = fontName
|
||||
onDownloadFont(fontName) {
|
||||
if (downloadingFontName == fontName) {
|
||||
downloadingFontName = null
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(
|
||||
if (isDownloaded) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
||||
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = fontName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (isDownloaded) FontWeight.Bold else FontWeight.Medium,
|
||||
color = if (isDownloaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.padding(start = 12.dp)) {
|
||||
when {
|
||||
isDownloaded -> {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Already Downloaded",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
isDownloading -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Icon(
|
||||
Icons.Default.CloudDownload,
|
||||
contentDescription = "Download",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Existing unchanged components
|
||||
@Composable
|
||||
fun FontListItem(
|
||||
font: CustomFontEntity,
|
||||
|
|
|
|||
|
|
@ -339,12 +339,17 @@ fun HomeScreen(
|
|||
},
|
||||
onAppThemeClick = { showAppThemePanel = true },
|
||||
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
|
||||
onLanguageClick = { showLanguageDialog = true }
|
||||
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
|
||||
onLanguageClick = { showLanguageDialog = true },
|
||||
onExportLogsClick = { viewModel.exportLogsToFile(context) }
|
||||
)
|
||||
} else {
|
||||
ContextualTopAppBar(
|
||||
selectedItemCount = selectedContextItems.size,
|
||||
onNavIconClick = { viewModel.clearContextualAction() },
|
||||
onTagClick = {
|
||||
viewModel.openTagSelection(selectedContextItems.map { it.bookId }.toSet())
|
||||
},
|
||||
onInfoClick = {
|
||||
if (selectedContextItems.size == 1) {
|
||||
itemForInfoDialog = selectedContextItems.first()
|
||||
|
|
@ -472,7 +477,8 @@ fun HomeScreen(
|
|||
},
|
||||
onUpdateName = { newName ->
|
||||
viewModel.updateCustomName(item.bookId, newName)
|
||||
}
|
||||
},
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -988,7 +994,9 @@ fun DefaultTopAppBar(
|
|||
onStrictFilterToggleClick: () -> Unit,
|
||||
onAppThemeClick: () -> Unit,
|
||||
onTestPanelDetectionClick: () -> Unit,
|
||||
onLanguageClick: () -> Unit
|
||||
onTestSpeechBubbleDetectionClick: () -> Unit,
|
||||
onLanguageClick: () -> Unit,
|
||||
onExportLogsClick: () -> Unit
|
||||
) {
|
||||
var showOptionsMenu by remember { mutableStateOf(false) }
|
||||
var showLimitMenu by remember { mutableStateOf(false) }
|
||||
|
|
@ -1094,6 +1102,16 @@ fun DefaultTopAppBar(
|
|||
onTestPanelDetectionClick()
|
||||
showOptionsMenu = false
|
||||
})
|
||||
|
||||
DropdownMenuItem(text = { Text("Test Speech Bubble ML Detection") }, onClick = {
|
||||
onTestSpeechBubbleDetectionClick()
|
||||
showOptionsMenu = false
|
||||
})
|
||||
|
||||
DropdownMenuItem(text = { Text("Export Logs (Last 5000 lines)") }, onClick = {
|
||||
onExportLogsClick()
|
||||
showOptionsMenu = false
|
||||
})
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
|
|
@ -76,6 +78,7 @@ import androidx.compose.material.icons.filled.FolderSpecial
|
|||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
|
|
@ -131,6 +134,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.TagEntity
|
||||
import com.aryan.reader.opds.OpdsAcquisition
|
||||
import com.aryan.reader.opds.OpdsCatalog
|
||||
import com.aryan.reader.opds.OpdsEntry
|
||||
|
|
@ -159,7 +163,7 @@ fun LibraryScreen(
|
|||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val selectedItems = uiState.contextualActionItems
|
||||
val isContextualModeActive = selectedItems.isNotEmpty()
|
||||
val selectedShelves = uiState.contextualActionShelfNames
|
||||
val selectedShelves = uiState.contextualActionShelfIds
|
||||
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
||||
val sortOrder = uiState.sortOrder
|
||||
val shelves = uiState.shelves
|
||||
|
|
@ -278,6 +282,7 @@ fun LibraryScreen(
|
|||
selectedShelves = selectedShelves,
|
||||
sortOrder = sortOrder,
|
||||
libraryFilters = uiState.libraryFilters,
|
||||
allTags = uiState.allTags,
|
||||
pinnedLibraryBookIds = uiState.pinnedLibraryBookIds,
|
||||
pagerState = pagerState,
|
||||
scope = scope,
|
||||
|
|
@ -289,6 +294,7 @@ fun LibraryScreen(
|
|||
onFilterClick = { showFilterSheet = true },
|
||||
onClearFilters = { viewModel.updateLibraryFilters(LibraryFilters()) },
|
||||
onRemoveFilter = { viewModel.updateLibraryFilters(it) },
|
||||
onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) },
|
||||
onPinClick = { viewModel.togglePinForContextualItems(isHome = false) },
|
||||
onClearSelection = { viewModel.clearContextualAction() },
|
||||
onItemClick = viewModel::onRecentFileClicked,
|
||||
|
|
@ -358,6 +364,7 @@ fun LibraryScreen(
|
|||
if (showFilterSheet) {
|
||||
LibraryFilterSheet(
|
||||
filters = uiState.libraryFilters,
|
||||
allTags = uiState.allTags,
|
||||
syncedFolders = uiState.syncedFolders,
|
||||
onApply = { viewModel.updateLibraryFilters(it) },
|
||||
onDismiss = { showFilterSheet = false }
|
||||
|
|
@ -385,7 +392,8 @@ fun LibraryScreen(
|
|||
},
|
||||
onUpdateName = { newName ->
|
||||
viewModel.updateCustomName(item.bookId, newName)
|
||||
}
|
||||
},
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -399,7 +407,7 @@ fun ShelfScreen(
|
|||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val selectedItems = uiState.contextualActionItems
|
||||
val viewingShelfName = uiState.viewingShelfName
|
||||
val viewingShelfId = uiState.viewingShelfId
|
||||
val isAddingBooks = uiState.isAddingBooksToShelf
|
||||
val shelves = uiState.shelves
|
||||
val sortOrder = uiState.sortOrder
|
||||
|
|
@ -414,17 +422,20 @@ fun ShelfScreen(
|
|||
when {
|
||||
selectedItems.isNotEmpty() -> viewModel.clearContextualAction()
|
||||
isAddingBooks -> viewModel.dismissAddBooksToShelf()
|
||||
else -> viewModel.unselectShelf()
|
||||
else -> viewModel.navigateBackFromShelf()
|
||||
}
|
||||
}
|
||||
|
||||
val currentShelf = shelves.find { it.name == viewingShelfName }
|
||||
val currentShelf = shelves.find { it.id == viewingShelfId }
|
||||
val childShelves = remember(shelves, currentShelf) {
|
||||
currentShelf?.childShelfIds?.mapNotNull { childId -> shelves.find { it.id == childId } } ?: emptyList()
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (viewingShelfName != null && currentShelf != null) {
|
||||
if (viewingShelfId != null && currentShelf != null) {
|
||||
if (isAddingBooks) {
|
||||
AddBooksModeScreen(
|
||||
shelfName = viewingShelfName,
|
||||
shelfName = currentShelf.name,
|
||||
availableBooks = uiState.booksAvailableForAdding,
|
||||
selectedBookUris = uiState.booksSelectedForAdding,
|
||||
currentSource = uiState.addBooksSource,
|
||||
|
|
@ -433,20 +444,23 @@ fun ShelfScreen(
|
|||
onSourceChange = viewModel::setAddBooksSource,
|
||||
onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) },
|
||||
onBack = viewModel::dismissAddBooksToShelf,
|
||||
onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfName) },
|
||||
onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfId) },
|
||||
downloadingBookIds = uiState.downloadingBookIds
|
||||
)
|
||||
} else {
|
||||
ShelfDetailScreen(
|
||||
shelf = currentShelf,
|
||||
childShelves = childShelves,
|
||||
selectedItems = selectedItems,
|
||||
sortOrder = sortOrder,
|
||||
onSortOrderChange = viewModel::setSortOrder,
|
||||
onBack = viewModel::unselectShelf,
|
||||
onBack = viewModel::navigateBackFromShelf,
|
||||
onAddBooksClick = viewModel::showAddBooksToShelf,
|
||||
onChildShelfClick = viewModel::onShelfClick,
|
||||
onBookClick = viewModel::onRecentFileClicked,
|
||||
onBookLongClick = viewModel::onRecentItemLongPress,
|
||||
onClearSelection = viewModel::clearContextualAction,
|
||||
onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) },
|
||||
onInfoClick = {
|
||||
if (selectedItems.size == 1) {
|
||||
itemForInfoDialog = selectedItems.first()
|
||||
|
|
@ -454,24 +468,27 @@ fun ShelfScreen(
|
|||
}
|
||||
},
|
||||
onDeleteClick = { showRemoveFromShelfDialog = true },
|
||||
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.name) },
|
||||
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.name) },
|
||||
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
|
||||
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
|
||||
downloadingBookIds = uiState.downloadingBookIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showRenameDialogFor != null) {
|
||||
RenameShelfDialog(
|
||||
initialName = showRenameDialogFor,
|
||||
onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) },
|
||||
onDismiss = viewModel::dismissRenameShelfDialog
|
||||
)
|
||||
val shelfToRename = shelves.find { it.id == showRenameDialogFor }
|
||||
if (shelfToRename != null) {
|
||||
RenameShelfDialog(
|
||||
initialName = shelfToRename.name,
|
||||
onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) },
|
||||
onDismiss = viewModel::dismissRenameShelfDialog
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialogFor != null) {
|
||||
DeleteShelfConfirmationDialog(
|
||||
shelfName = showDeleteDialogFor,
|
||||
shelfName = shelves.find { it.id == showDeleteDialogFor }?.name ?: "",
|
||||
onConfirm = { viewModel.deleteShelf(showDeleteDialogFor) },
|
||||
onDismiss = viewModel::dismissDeleteShelfDialog
|
||||
)
|
||||
|
|
@ -493,13 +510,9 @@ fun ShelfScreen(
|
|||
if (showInfoDialog) {
|
||||
FileInfoDialog(
|
||||
item = item,
|
||||
onDismiss = {
|
||||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
},
|
||||
onUpdateName = { newName ->
|
||||
viewModel.updateCustomName(item.bookId, newName)
|
||||
}
|
||||
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
||||
onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) },
|
||||
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -519,6 +532,7 @@ fun LibraryScreenContent(
|
|||
selectedShelves: Set<String>,
|
||||
sortOrder: SortOrder,
|
||||
libraryFilters: LibraryFilters,
|
||||
allTags: List<TagEntity>,
|
||||
pinnedLibraryBookIds: Set<String>,
|
||||
pagerState: PagerState,
|
||||
scope: CoroutineScope,
|
||||
|
|
@ -530,6 +544,7 @@ fun LibraryScreenContent(
|
|||
onFilterClick: () -> Unit,
|
||||
onClearFilters: () -> Unit,
|
||||
onRemoveFilter: (LibraryFilters) -> Unit,
|
||||
onTagClick: () -> Unit,
|
||||
onPinClick: () -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
onItemClick: (RecentFileItem) -> Unit,
|
||||
|
|
@ -590,6 +605,7 @@ fun LibraryScreenContent(
|
|||
ContextualTopAppBar(
|
||||
selectedItemCount = selectedItems.size,
|
||||
onNavIconClick = onClearSelection,
|
||||
onTagClick = onTagClick,
|
||||
onPinClick = onPinClick,
|
||||
onInfoClick = onInfoClick,
|
||||
onDeleteClick = onDeleteClick,
|
||||
|
|
@ -734,6 +750,19 @@ fun LibraryScreenContent(
|
|||
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
|
||||
)
|
||||
}
|
||||
if (libraryFilters.tagIds.isNotEmpty()) {
|
||||
val selectedTags = allTags.filter { it.id in libraryFilters.tagIds }
|
||||
val tagLabel = when {
|
||||
selectedTags.isEmpty() -> "${libraryFilters.tagIds.size} tags"
|
||||
selectedTags.size <= 2 -> selectedTags.joinToString { it.name }
|
||||
else -> "${selectedTags.size} tags"
|
||||
}
|
||||
AssistChip(
|
||||
onClick = { onRemoveFilter(libraryFilters.copy(tagIds = emptySet())) },
|
||||
label = { Text("Tags: $tagLabel") },
|
||||
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -846,16 +875,60 @@ private fun ShelvesScreen(
|
|||
onShelfLongClick: (Shelf) -> Unit,
|
||||
selectedShelves: Set<String>,
|
||||
) {
|
||||
val tagShelves = remember(shelves) { shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 } }
|
||||
val visibleShelves = remember(shelves) {
|
||||
shelves.filter { shelf ->
|
||||
when {
|
||||
shelf.type == ShelfType.TAG -> false
|
||||
shelf.type == ShelfType.FOLDER -> shelf.parentShelfId == null
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(shelves, key = { it.name }) { shelf ->
|
||||
if (tagShelves.isNotEmpty() && selectedShelves.isEmpty()) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = "Browse by tag",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
tagShelves.forEach { shelf ->
|
||||
FilterChip(
|
||||
selected = false,
|
||||
onClick = { onShelfClick(shelf) },
|
||||
label = { Text(shelf.name) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.tag),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(visibleShelves, key = { it.id }) { shelf ->
|
||||
ShelfListItem(
|
||||
shelf = shelf,
|
||||
isSelected = shelf.name in selectedShelves,
|
||||
isSelected = shelf.id in selectedShelves,
|
||||
onItemClick = { onShelfClick(shelf) },
|
||||
onItemLongClick = { onShelfLongClick(shelf) }
|
||||
)
|
||||
|
|
@ -904,14 +977,17 @@ private fun CreateShelfDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit
|
|||
@Composable
|
||||
private fun ShelfDetailScreen(
|
||||
shelf: Shelf,
|
||||
childShelves: List<Shelf>,
|
||||
selectedItems: Set<RecentFileItem>,
|
||||
sortOrder: SortOrder,
|
||||
onSortOrderChange: (SortOrder) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAddBooksClick: () -> Unit,
|
||||
onChildShelfClick: (Shelf) -> Unit,
|
||||
onBookClick: (RecentFileItem) -> Unit,
|
||||
onBookLongClick: (RecentFileItem) -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
onTagClick: () -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
onRenameShelf: () -> Unit,
|
||||
|
|
@ -919,8 +995,71 @@ private fun ShelfDetailScreen(
|
|||
downloadingBookIds: Set<String>,
|
||||
) {
|
||||
val isContextualModeActive = selectedItems.isNotEmpty()
|
||||
val isFolderShelf = shelf.type == ShelfType.FOLDER
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
var showMoreMenu by remember { mutableStateOf(false) }
|
||||
var isSearchActive by remember(shelf.id) { mutableStateOf(false) }
|
||||
var searchQuery by remember(shelf.id) { mutableStateOf("") }
|
||||
val searchFocusRequester = remember { FocusRequester() }
|
||||
var searchFieldValue by remember(isSearchActive, shelf.id) {
|
||||
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
|
||||
}
|
||||
val normalizedQuery = searchQuery.trim()
|
||||
val filteredChildShelves = remember(childShelves, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
childShelves
|
||||
} else {
|
||||
childShelves.filter { childShelf ->
|
||||
childShelf.name.contains(normalizedQuery, ignoreCase = true) ||
|
||||
childShelf.books.any { item ->
|
||||
item.displayName.contains(normalizedQuery, ignoreCase = true) ||
|
||||
item.title?.contains(normalizedQuery, ignoreCase = true) == true ||
|
||||
item.author?.contains(normalizedQuery, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val filteredDirectBooks = remember(shelf.directBooks, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
shelf.directBooks
|
||||
} else {
|
||||
shelf.directBooks.filter { item ->
|
||||
item.displayName.contains(normalizedQuery, ignoreCase = true) ||
|
||||
item.title?.contains(normalizedQuery, ignoreCase = true) == true ||
|
||||
item.author?.contains(normalizedQuery, ignoreCase = true) == true ||
|
||||
item.tags.any { tag -> tag.name.contains(normalizedQuery, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(searchQuery) {
|
||||
if (searchFieldValue.text != searchQuery) {
|
||||
searchFieldValue = searchFieldValue.copy(
|
||||
text = searchQuery,
|
||||
selection = TextRange(searchQuery.length)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isSearchActive) {
|
||||
if (isSearchActive) {
|
||||
searchFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearShelfSearchQuery() {
|
||||
searchQuery = ""
|
||||
searchFieldValue = TextFieldValue("", TextRange.Zero)
|
||||
}
|
||||
|
||||
fun closeShelfSearch() {
|
||||
isSearchActive = false
|
||||
clearShelfSearchQuery()
|
||||
}
|
||||
|
||||
BackHandler(enabled = isSearchActive) {
|
||||
closeShelfSearch()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier,
|
||||
|
|
@ -929,9 +1068,54 @@ private fun ShelfDetailScreen(
|
|||
ContextualTopAppBar(
|
||||
selectedItemCount = selectedItems.size,
|
||||
onNavIconClick = onClearSelection,
|
||||
onTagClick = onTagClick,
|
||||
onInfoClick = onInfoClick,
|
||||
onDeleteClick = onDeleteClick
|
||||
)
|
||||
} else if (isSearchActive) {
|
||||
Surface(
|
||||
shadowElevation = 4.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.height(64.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { closeShelfSearch() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = searchFieldValue,
|
||||
onValueChange = {
|
||||
searchFieldValue = it
|
||||
searchQuery = it.text
|
||||
},
|
||||
placeholder = { Text(stringResource(R.string.search_placeholder)) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 4.dp)
|
||||
.focusRequester(searchFocusRequester),
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
trailingIcon = {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { clearShelfSearchQuery() }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Clear query")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
CustomTopAppBar(
|
||||
title = {
|
||||
|
|
@ -942,7 +1126,14 @@ private fun ShelfDetailScreen(
|
|||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = getBookCountString(shelf.bookCount),
|
||||
text = when {
|
||||
isFolderShelf && shelf.childShelfCount > 0 && shelf.directBookCount > 0 ->
|
||||
"${shelf.childShelfCount} folders • ${getBookCountString(shelf.directBookCount)}"
|
||||
isFolderShelf && shelf.childShelfCount > 0 ->
|
||||
"${shelf.childShelfCount} folders"
|
||||
isFolderShelf -> getBookCountString(shelf.directBookCount)
|
||||
else -> getBookCountString(shelf.bookCount)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
|
@ -988,7 +1179,14 @@ private fun ShelfDetailScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (shelf.name != "Unshelved") {
|
||||
IconButton(onClick = { isSearchActive = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = "Search shelf"
|
||||
)
|
||||
}
|
||||
|
||||
if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") {
|
||||
Box {
|
||||
IconButton(onClick = { showMoreMenu = true }) {
|
||||
Icon(
|
||||
|
|
@ -1022,40 +1220,81 @@ private fun ShelfDetailScreen(
|
|||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (shelf.name != "Unshelved" && !isContextualModeActive) {
|
||||
if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved" && !isContextualModeActive) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onAddBooksClick,
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.fab_add_books)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (shelf.books.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(R.string.shelf_empty), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(shelf.books, key = { it.bookId }) { item ->
|
||||
LibraryListItem(
|
||||
item = item,
|
||||
isSelected = selectedItems.any { it.bookId == item.bookId },
|
||||
onItemClick = { onBookClick(item) },
|
||||
onItemLongClick = { onBookLongClick(item) },
|
||||
isDownloading = item.bookId in downloadingBookIds
|
||||
},
|
||||
content = { paddingValues ->
|
||||
if (filteredChildShelves.isEmpty() && filteredDirectBooks.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (normalizedQuery.isBlank()) stringResource(R.string.shelf_empty) else stringResource(
|
||||
R.string.no_results_found,
|
||||
normalizedQuery
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (filteredChildShelves.isNotEmpty()) {
|
||||
if (isFolderShelf) {
|
||||
item {
|
||||
Text(
|
||||
text = "Folders",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
items(filteredChildShelves, key = { it.id }) { childShelf ->
|
||||
ShelfListItem(
|
||||
shelf = childShelf,
|
||||
isSelected = false,
|
||||
onItemClick = { onChildShelfClick(childShelf) },
|
||||
onItemLongClick = {},
|
||||
showHierarchyIndent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
if (filteredDirectBooks.isNotEmpty() && isFolderShelf && filteredChildShelves.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = "Files",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
items(filteredDirectBooks, key = { it.bookId }) { item ->
|
||||
LibraryListItem(
|
||||
item = item,
|
||||
isSelected = selectedItems.any { it.bookId == item.bookId },
|
||||
onItemClick = { onBookClick(item) },
|
||||
onItemLongClick = { onBookLongClick(item) },
|
||||
isDownloading = item.bookId in downloadingBookIds
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -1073,76 +1312,67 @@ private fun AddBooksModeScreen(
|
|||
downloadingBookIds: Set<String>,
|
||||
) {
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
var showSourceMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier,
|
||||
topBar = {
|
||||
CustomTopAppBar(
|
||||
title = { Text(stringResource(R.string.add_to_shelf, shelfName)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Box {
|
||||
TextButton(onClick = { showSortMenu = true }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.sort),
|
||||
contentDescription = "Sort",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(sortOrder.displayName)
|
||||
Column {
|
||||
CustomTopAppBar(
|
||||
title = { Text(stringResource(R.string.add_to_shelf, shelfName)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSortMenu,
|
||||
onDismissRequest = { showSortMenu = false }
|
||||
) {
|
||||
SortOrder.entries.forEach { order ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(order.displayName) },
|
||||
onClick = {
|
||||
onSortOrderChange(order)
|
||||
showSortMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (order == sortOrder) {
|
||||
Icon(Icons.Default.Check, contentDescription = "Selected")
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Box {
|
||||
TextButton(onClick = { showSortMenu = true }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.sort),
|
||||
contentDescription = "Sort",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(sortOrder.displayName)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSortMenu,
|
||||
onDismissRequest = { showSortMenu = false }
|
||||
) {
|
||||
SortOrder.entries.forEach { order ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(order.displayName) },
|
||||
onClick = {
|
||||
onSortOrderChange(order)
|
||||
showSortMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (order == sortOrder) {
|
||||
Icon(Icons.Default.Check, contentDescription = "Selected")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
IconButton(onClick = { showSourceMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "More options")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSourceMenu,
|
||||
onDismissRequest = { showSourceMenu = false }
|
||||
) {
|
||||
AddBooksSource.entries.forEach { source ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(source.displayName) },
|
||||
onClick = {
|
||||
onSourceChange(source)
|
||||
showSourceMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (source == currentSource) {
|
||||
Icon(Icons.Default.Check, contentDescription = "Selected")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
AddBooksSource.entries.forEach { source ->
|
||||
FilterChip(
|
||||
selected = source == currentSource,
|
||||
onClick = { onSourceChange(source) },
|
||||
label = { Text(source.displayName) }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (selectedBookUris.isNotEmpty()) {
|
||||
|
|
@ -1152,37 +1382,42 @@ private fun AddBooksModeScreen(
|
|||
onClick = onAddSelectedBooks
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (availableBooks.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (currentSource == AddBooksSource.UNSHELVED) stringResource(R.string.no_unshelved_books) else stringResource(R.string.all_books_in_shelf),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(availableBooks, key = { it.bookId }) { item ->
|
||||
val isSelected = item.bookId in selectedBookUris
|
||||
LibraryListItem(
|
||||
item = item,
|
||||
isSelected = isSelected,
|
||||
onItemClick = { onBookClick(item) },
|
||||
onItemLongClick = { onBookClick(item) },
|
||||
isDownloading = item.bookId in downloadingBookIds
|
||||
},
|
||||
content = { paddingValues ->
|
||||
if (availableBooks.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (currentSource == AddBooksSource.UNSHELVED) {
|
||||
stringResource(R.string.no_unshelved_books)
|
||||
} else {
|
||||
stringResource(R.string.all_books_in_shelf)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(availableBooks, key = { it.bookId }) { item ->
|
||||
val isSelected = item.bookId in selectedBookUris
|
||||
LibraryListItem(
|
||||
item = item,
|
||||
isSelected = isSelected,
|
||||
onItemClick = { onBookClick(item) },
|
||||
onItemLongClick = { onBookClick(item) },
|
||||
isDownloading = item.bookId in downloadingBookIds
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -1260,7 +1495,10 @@ private fun ShelfListItem(
|
|||
isSelected: Boolean,
|
||||
onItemClick: () -> Unit,
|
||||
onItemLongClick: () -> Unit,
|
||||
showHierarchyIndent: Boolean = true,
|
||||
) {
|
||||
val folderIndent = if (showHierarchyIndent && shelf.type == ShelfType.FOLDER) (shelf.depth * 14).dp else 0.dp
|
||||
|
||||
androidx.compose.material3.ElevatedCard(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
||||
|
|
@ -1286,7 +1524,7 @@ private fun ShelfListItem(
|
|||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
modifier = Modifier.padding(start = 12.dp + folderIndent, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ShelfCover(shelf = shelf)
|
||||
|
|
@ -1294,13 +1532,29 @@ private fun ShelfListItem(
|
|||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = shelf.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val icon = when (shelf.type) {
|
||||
ShelfType.SMART -> Icons.Default.Star
|
||||
ShelfType.TAG -> Icons.AutoMirrored.Filled.LibraryBooks
|
||||
ShelfType.FOLDER -> Icons.Default.Folder
|
||||
ShelfType.SERIES -> Icons.AutoMirrored.Filled.LibraryBooks
|
||||
ShelfType.MANUAL -> Icons.AutoMirrored.Filled.List
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = shelf.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = getBookCountString(shelf.bookCount),
|
||||
|
|
@ -1378,7 +1632,6 @@ private fun LibraryListItem(
|
|||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier.matchParentSize().background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
|
||||
|
|
@ -1432,57 +1685,60 @@ private fun LibraryListItem(
|
|||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FileTypeBadge(type = item.type, overlay = false)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(28.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
if (!item.isAvailable) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = if (isDownloading) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
},
|
||||
contentColor = if (isDownloading) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
}
|
||||
if (item.tags.isNotEmpty()) {
|
||||
BookTagChipsRow(
|
||||
tags = item.tags,
|
||||
compact = true,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
if (!item.isAvailable) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = if (isDownloading) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
},
|
||||
contentColor = if (isDownloading) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
if (isDownloading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Filled.Info,
|
||||
contentDescription = stringResource(R.string.not_available_locally),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (isDownloading) {
|
||||
stringResource(R.string.status_downloading)
|
||||
} else {
|
||||
stringResource(R.string.not_available_locally)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
if (isDownloading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Filled.Info,
|
||||
contentDescription = stringResource(R.string.not_available_locally),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (isDownloading) {
|
||||
stringResource(R.string.status_downloading)
|
||||
} else {
|
||||
stringResource(R.string.not_available_locally)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1558,7 +1814,7 @@ private fun DeleteShelfConfirmationDialog(
|
|||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.dialog_delete_shelf)) },
|
||||
text = { Text(stringResource(R.string.dialog_delete_shelf_desc)) },
|
||||
text = { Text(stringResource(R.string.dialog_delete_shelf_desc, shelfName)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
|
||||
},
|
||||
|
|
@ -1912,6 +2168,7 @@ private fun EditFolderFiltersDialog(
|
|||
@Composable
|
||||
fun LibraryFilterSheet(
|
||||
filters: LibraryFilters,
|
||||
allTags: List<TagEntity>,
|
||||
syncedFolders: List<SyncedFolder>,
|
||||
onApply: (LibraryFilters) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
|
|
@ -1987,6 +2244,41 @@ fun LibraryFilterSheet(
|
|||
}
|
||||
}
|
||||
|
||||
if (allTags.isNotEmpty()) {
|
||||
Text("Tags", style = MaterialTheme.typography.titleMedium)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
allTags.forEach { tag ->
|
||||
val selected = tag.id in currentFilters.tagIds
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
val newSet = if (selected) {
|
||||
currentFilters.tagIds - tag.id
|
||||
} else {
|
||||
currentFilters.tagIds + tag.id
|
||||
}
|
||||
currentFilters = currentFilters.copy(tagIds = newSet)
|
||||
},
|
||||
label = { Text(tag.name) },
|
||||
leadingIcon = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.background(
|
||||
Color(tag.color ?: 0xFF64B5F6.toInt()),
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = { currentFilters = LibraryFilters() }) {
|
||||
Text(stringResource(R.string.clear_all))
|
||||
|
|
|
|||
|
|
@ -69,60 +69,77 @@ fun MainScreen(
|
|||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val viewingShelfName = uiState.viewingShelfName
|
||||
val viewingShelfName = uiState.viewingShelfId
|
||||
|
||||
if (viewingShelfName != null) {
|
||||
ShelfScreen(viewModel = viewModel)
|
||||
} else {
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = uiState.mainScreenStartPage,
|
||||
pageCount = { bottomBarItems.size }
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (viewingShelfName != null) {
|
||||
ShelfScreen(viewModel = viewModel)
|
||||
} else {
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = uiState.mainScreenStartPage,
|
||||
pageCount = { bottomBarItems.size }
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(uiState.mainScreenStartPage) {
|
||||
if (pagerState.currentPage != uiState.mainScreenStartPage) {
|
||||
pagerState.animateScrollToPage(uiState.mainScreenStartPage)
|
||||
LaunchedEffect(uiState.mainScreenStartPage) {
|
||||
if (pagerState.currentPage != uiState.mainScreenStartPage) {
|
||||
pagerState.animateScrollToPage(uiState.mainScreenStartPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
viewModel.setMainScreenPage(pagerState.currentPage)
|
||||
}
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
viewModel.setMainScreenPage(pagerState.currentPage)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
bottomBarItems.forEachIndexed { index, screen ->
|
||||
NavigationBarItem(
|
||||
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) },
|
||||
label = { Text(stringResource(screen.stringResId)) },
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(index) } }
|
||||
Scaffold(
|
||||
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
bottomBarItems.forEachIndexed { index, screen ->
|
||||
NavigationBarItem(
|
||||
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) },
|
||||
label = { Text(stringResource(screen.stringResId)) },
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(index) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
key = { bottomBarItems[it].route },
|
||||
beyondViewportPageCount = 1,
|
||||
userScrollEnabled = false
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> HomeScreen(
|
||||
viewModel = viewModel,
|
||||
windowSizeClass = windowSizeClass,
|
||||
navController = navController
|
||||
)
|
||||
1 -> LibraryScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
key = { bottomBarItems[it].route },
|
||||
beyondViewportPageCount = 1,
|
||||
userScrollEnabled = false
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> HomeScreen(
|
||||
viewModel = viewModel,
|
||||
windowSizeClass = windowSizeClass,
|
||||
navController = navController
|
||||
)
|
||||
1 -> LibraryScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
|
||||
TagSelectionBottomSheet(
|
||||
allTags = uiState.allTags,
|
||||
selectedBookIds = uiState.showTagSelectionDialogFor,
|
||||
booksWithTags = uiState.rawLibraryFiles,
|
||||
onCreateAndAssign = { name ->
|
||||
viewModel.createAndAssignTag(name, uiState.showTagSelectionDialogFor)
|
||||
},
|
||||
onToggleTag = { tagId, assign ->
|
||||
viewModel.toggleTagForBooks(tagId, uiState.showTagSelectionDialogFor, assign)
|
||||
},
|
||||
onDismiss = viewModel::closeTagSelection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@ import androidx.work.CoroutineWorker
|
|||
import androidx.work.WorkerParameters
|
||||
import com.aryan.reader.data.RecentFilesRepository
|
||||
import com.aryan.reader.epub.EpubParser
|
||||
import com.aryan.reader.epub.ImportedFileCache
|
||||
import com.aryan.reader.epub.MobiParser
|
||||
import com.aryan.reader.pdf.PdfCoverGenerator
|
||||
import io.legere.pdfiumandroid.PdfiumCore
|
||||
|
|
@ -55,6 +56,13 @@ class MetadataExtractionWorker(
|
|||
|
||||
if (item.sourceFolderUri == null) return@forEach
|
||||
|
||||
val tempExtractionDir =
|
||||
if (item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.ODT || item.type == FileType.FODT) {
|
||||
ImportedFileCache.createTemporaryBookDir(appContext, item.bookId, "metadata")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
try {
|
||||
val uri = item.uriString?.toUri() ?: return@forEach
|
||||
val type = item.type
|
||||
|
|
@ -86,7 +94,8 @@ class MetadataExtractionWorker(
|
|||
inputStream = inputStream,
|
||||
bookId = item.bookId,
|
||||
originalBookNameHint = item.displayName,
|
||||
parseContent = false
|
||||
parseContent = false,
|
||||
extractionDirOverride = tempExtractionDir
|
||||
)
|
||||
title = book.title.takeIf { it.isNotBlank() && it != "content" }
|
||||
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
|
|
@ -97,7 +106,8 @@ class MetadataExtractionWorker(
|
|||
inputStream = inputStream,
|
||||
bookId = item.bookId,
|
||||
originalBookNameHint = item.displayName,
|
||||
parseContent = false
|
||||
parseContent = false,
|
||||
extractionDirOverride = tempExtractionDir
|
||||
)
|
||||
book?.let {
|
||||
title = it.title.takeIf { t -> t.isNotBlank() && t != "content" }
|
||||
|
|
@ -139,7 +149,8 @@ class MetadataExtractionWorker(
|
|||
bookId = item.bookId,
|
||||
originalBookNameHint = item.displayName,
|
||||
isFlat = type == FileType.FODT,
|
||||
parseContent = false
|
||||
parseContent = false,
|
||||
extractionDirOverride = tempExtractionDir
|
||||
)
|
||||
title = book.title.takeIf { it.isNotBlank() && it != "content" }
|
||||
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
|
|
@ -166,13 +177,16 @@ class MetadataExtractionWorker(
|
|||
Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}")
|
||||
} finally {
|
||||
try {
|
||||
val cacheDir = File(appContext.cacheDir, "imported_file_${item.bookId}")
|
||||
if (cacheDir.exists()) {
|
||||
val deleted = cacheDir.deleteRecursively()
|
||||
if (deleted) Timber.tag("MetadataWorker").d("Cleaned up extraction cache for ${item.bookId}")
|
||||
if (tempExtractionDir?.exists() == true) {
|
||||
val deleted = tempExtractionDir.deleteRecursively()
|
||||
if (deleted) {
|
||||
Timber.tag("MetadataWorker")
|
||||
.d("Cleaned up temporary extraction cache for ${item.bookId}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MetadataWorker").e(e, "Failed to clean up extraction cache for ${item.bookId}")
|
||||
Timber.tag("MetadataWorker")
|
||||
.e(e, "Failed to clean up temporary extraction cache for ${item.bookId}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -183,4 +197,4 @@ class MetadataExtractionWorker(
|
|||
return@withContext Result.failure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,19 @@ import androidx.compose.animation.fadeIn
|
|||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.material3.TriStateCheckbox
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import com.aryan.reader.data.TagEntity
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -90,7 +102,6 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
|
|
@ -220,6 +231,7 @@ fun ContextualTopAppBar(
|
|||
selectedItemCount: Int,
|
||||
onNavIconClick: () -> Unit,
|
||||
onInfoClick: (() -> Unit)? = null,
|
||||
onTagClick: (() -> Unit)? = null,
|
||||
onSelectAllClick: (() -> Unit)? = null,
|
||||
onPinClick: (() -> Unit)? = null,
|
||||
onDeleteClick: () -> Unit
|
||||
|
|
@ -232,6 +244,11 @@ fun ContextualTopAppBar(
|
|||
}
|
||||
},
|
||||
actions = {
|
||||
if (onTagClick != null) {
|
||||
IconButton(onClick = onTagClick) {
|
||||
Icon(painterResource(id = R.drawable.tag), contentDescription = "Tag")
|
||||
}
|
||||
}
|
||||
if (onPinClick != null) {
|
||||
IconButton(onClick = onPinClick) {
|
||||
Icon(Icons.Filled.PushPin, contentDescription = stringResource(R.string.pin_unpin))
|
||||
|
|
@ -342,7 +359,7 @@ fun DeleteConfirmationDialog(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit) {
|
||||
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit, onOpenTags: () -> Unit) {
|
||||
LocalContext.current
|
||||
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
|
|
@ -465,6 +482,14 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
|
|||
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
|
||||
InfoRowDetailed(stringResource(R.string.author), it)
|
||||
}
|
||||
item.seriesName?.takeIf { it.isNotBlank() }?.let { series ->
|
||||
val seriesText = if (item.seriesIndex != null && item.seriesIndex > 0) {
|
||||
"$series #${item.seriesIndex.toInt()}"
|
||||
} else {
|
||||
series
|
||||
}
|
||||
InfoRowDetailed("Series", seriesText)
|
||||
}
|
||||
InfoRowDetailed(stringResource(R.string.format), item.type.name)
|
||||
InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize))
|
||||
InfoRowDetailed(stringResource(R.string.added), formattedDate)
|
||||
|
|
@ -488,6 +513,19 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
|
|||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Tags", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
TextButton(onClick = onOpenTags) { Text("+ Add / Edit") }
|
||||
}
|
||||
|
||||
if (item.tags.isNotEmpty()) {
|
||||
BookTagChipsRow(tags = item.tags, compact = false)
|
||||
} else {
|
||||
Text("No tags assigned.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -901,6 +939,53 @@ fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolea
|
|||
}
|
||||
}
|
||||
|
||||
private fun TagEntity.displayColor(): Color = Color(color ?: 0xFF64B5F6.toInt())
|
||||
|
||||
@Composable
|
||||
fun BookTagChipsRow(
|
||||
tags: List<TagEntity>,
|
||||
modifier: Modifier = Modifier,
|
||||
compact: Boolean = true,
|
||||
) {
|
||||
if (tags.isEmpty()) return
|
||||
|
||||
Row(
|
||||
modifier = modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
tags.forEach { tag ->
|
||||
val tagColor = tag.displayColor()
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = tagColor.copy(alpha = 0.14f),
|
||||
contentColor = tagColor
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = if (compact) 8.dp else 10.dp,
|
||||
vertical = if (compact) 4.dp else 6.dp
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (compact) 6.dp else 8.dp)
|
||||
.background(tagColor, androidx.compose.foundation.shape.CircleShape)
|
||||
)
|
||||
Text(
|
||||
text = tag.name,
|
||||
style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val UNKNOWN_AUTHOR_LABEL = "No author listed"
|
||||
|
||||
fun RecentFileItem.cardTitle(): String {
|
||||
|
|
@ -1046,4 +1131,92 @@ fun ReadingProgressSection(
|
|||
trackColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TagSelectionBottomSheet(
|
||||
allTags: List<TagEntity>,
|
||||
selectedBookIds: Set<String>,
|
||||
booksWithTags: List<RecentFileItem>,
|
||||
onCreateAndAssign: (String) -> Unit,
|
||||
onToggleTag: (String, Boolean) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
|
||||
val filteredTags = remember(allTags, searchQuery) {
|
||||
if (searchQuery.isBlank()) allTags else allTags.filter { it.name.contains(searchQuery, ignoreCase = true) }
|
||||
}
|
||||
|
||||
val exactMatch = allTags.any { it.name.equals(searchQuery.trim(), ignoreCase = true) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).heightIn(max = 500.dp)) {
|
||||
Text("Apply Tags", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 16.dp))
|
||||
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Search or create tag...") },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
leadingIcon = { Icon(Icons.Default.Search, null) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f, fill = false)) {
|
||||
if (searchQuery.isNotBlank() && !exactMatch) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
onCreateAndAssign(searchQuery)
|
||||
searchQuery = ""
|
||||
}.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text("Create \"${searchQuery.trim()}\"", color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(filteredTags, key = { it.id }) { tag ->
|
||||
var checkedCount = 0
|
||||
selectedBookIds.forEach { bookId ->
|
||||
val book = booksWithTags.find { it.bookId == bookId }
|
||||
if (book?.tags?.any { it.id == tag.id } == true) checkedCount++
|
||||
}
|
||||
|
||||
val state = when (checkedCount) {
|
||||
0 -> ToggleableState.Off
|
||||
selectedBookIds.size -> ToggleableState.On
|
||||
else -> ToggleableState.Indeterminate
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
val assign = state != ToggleableState.On
|
||||
onToggleTag(tag.id, assign)
|
||||
}.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TriStateCheckbox(state = state, onClick = null)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Surface(shape = androidx.compose.foundation.shape.CircleShape, color = Color(tag.color ?: 0xFF64B5F6.toInt()).copy(alpha = 0.2f), modifier = Modifier.size(24.dp)) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painterResource(id = R.drawable.tag), contentDescription = null, modifier = Modifier.size(12.dp), tint = Color(tag.color ?: 0xFF64B5F6.toInt()))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(tag.name, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,24 @@ import androidx.room.TypeConverters
|
|||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(entities =[RecentFileEntity::class, CustomFontEntity::class], version = 16, exportSchema = false)
|
||||
@Database(
|
||||
entities =[
|
||||
RecentFileEntity::class,
|
||||
CustomFontEntity::class,
|
||||
ShelfEntity::class,
|
||||
BookShelfCrossRef::class,
|
||||
TagEntity::class,
|
||||
BookTagCrossRef::class
|
||||
],
|
||||
version = 18,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(FileTypeConverter::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun recentFileDao(): RecentFileDao
|
||||
abstract fun customFontDao(): CustomFontDao
|
||||
abstract fun shelfDao(): ShelfDao
|
||||
abstract fun tagDao(): TagDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
|
|
@ -191,6 +204,53 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
}
|
||||
}
|
||||
|
||||
val MIGRATION_16_17 = object : Migration(16, 17) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE recent_files ADD COLUMN seriesName TEXT DEFAULT NULL")
|
||||
db.execSQL("ALTER TABLE recent_files ADD COLUMN seriesIndex REAL DEFAULT NULL")
|
||||
db.execSQL("ALTER TABLE recent_files ADD COLUMN description TEXT DEFAULT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_17_18 = object : Migration(17, 18) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS `shelves` (
|
||||
`id` TEXT NOT NULL, `name` TEXT NOT NULL, `isSmart` INTEGER NOT NULL,
|
||||
`smartRulesJson` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL,
|
||||
`isDeleted` INTEGER NOT NULL, PRIMARY KEY(`id`)
|
||||
)
|
||||
""")
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS `tags` (
|
||||
`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER,
|
||||
`createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`)
|
||||
)
|
||||
""")
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS `book_shelf_cross_ref` (
|
||||
`bookId` TEXT NOT NULL, `shelfId` TEXT NOT NULL, `addedAt` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`bookId`, `shelfId`),
|
||||
FOREIGN KEY(`bookId`) REFERENCES `recent_files`(`bookId`) ON UPDATE NO ACTION ON DELETE CASCADE,
|
||||
FOREIGN KEY(`shelfId`) REFERENCES `shelves`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_shelf_cross_ref_shelfId` ON `book_shelf_cross_ref` (`shelfId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_shelf_cross_ref_bookId` ON `book_shelf_cross_ref` (`bookId`)")
|
||||
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS `book_tag_cross_ref` (
|
||||
`bookId` TEXT NOT NULL, `tagId` TEXT NOT NULL,
|
||||
PRIMARY KEY(`bookId`, `tagId`),
|
||||
FOREIGN KEY(`bookId`) REFERENCES `recent_files`(`bookId`) ON UPDATE NO ACTION ON DELETE CASCADE,
|
||||
FOREIGN KEY(`tagId`) REFERENCES `tags`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_tag_cross_ref_tagId` ON `book_tag_cross_ref` (`tagId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_tag_cross_ref_bookId` ON `book_tag_cross_ref` (`bookId`)")
|
||||
}
|
||||
}
|
||||
|
||||
fun getDatabase(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
|
|
@ -202,7 +262,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
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_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
||||
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16
|
||||
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
|
||||
MIGRATION_16_17, MIGRATION_17_18
|
||||
)
|
||||
.fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
|
|
|
|||
61
app/src/main/java/com/aryan/reader/data/LibraryDaos.kt
Normal file
61
app/src/main/java/com/aryan/reader/data/LibraryDaos.kt
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package com.aryan.reader.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ShelfDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertShelf(shelf: ShelfEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertBookShelfCrossRefs(crossRefs: List<BookShelfCrossRef>)
|
||||
|
||||
@Query("SELECT * FROM shelves WHERE isDeleted = 0 ORDER BY name ASC")
|
||||
fun getAllActiveShelves(): Flow<List<ShelfEntity>>
|
||||
|
||||
@Query("SELECT * FROM book_shelf_cross_ref")
|
||||
fun getAllBookShelfCrossRefs(): Flow<List<BookShelfCrossRef>>
|
||||
|
||||
@Query("DELETE FROM book_shelf_cross_ref WHERE shelfId = :shelfId AND bookId IN (:bookIds)")
|
||||
suspend fun removeBooksFromShelf(shelfId: String, bookIds: List<String>)
|
||||
|
||||
@Query("UPDATE shelves SET isDeleted = 1, updatedAt = :timestamp WHERE id = :shelfId")
|
||||
suspend fun markShelfAsDeleted(shelfId: String, timestamp: Long)
|
||||
|
||||
@Query("UPDATE shelves SET name = :newName, updatedAt = :timestamp WHERE id = :shelfId")
|
||||
suspend fun updateShelfName(shelfId: String, newName: String, timestamp: Long)
|
||||
|
||||
@Query("SELECT * FROM shelves WHERE id = :shelfId")
|
||||
suspend fun getShelfById(shelfId: String): ShelfEntity?
|
||||
|
||||
@Query("SELECT * FROM book_shelf_cross_ref WHERE shelfId = :shelfId")
|
||||
suspend fun getCrossRefsForShelf(shelfId: String): List<BookShelfCrossRef>
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface TagDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertTag(tag: TagEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertTags(tags: List<TagEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertBookTagCrossRef(crossRef: BookTagCrossRef)
|
||||
|
||||
@Query("SELECT * FROM tags ORDER BY name ASC")
|
||||
fun getAllTags(): Flow<List<TagEntity>>
|
||||
|
||||
@Query("SELECT * FROM book_tag_cross_ref")
|
||||
fun getAllBookTagCrossRefs(): Flow<List<BookTagCrossRef>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM tags")
|
||||
suspend fun getTagCount(): Int
|
||||
|
||||
@Query("DELETE FROM book_tag_cross_ref WHERE tagId = :tagId AND bookId = :bookId")
|
||||
suspend fun removeTagFromBook(tagId: String, bookId: String)
|
||||
}
|
||||
74
app/src/main/java/com/aryan/reader/data/LibraryEntities.kt
Normal file
74
app/src/main/java/com/aryan/reader/data/LibraryEntities.kt
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package com.aryan.reader.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "shelves")
|
||||
data class ShelfEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val name: String,
|
||||
val isSmart: Boolean = false,
|
||||
val smartRulesJson: String? = null,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
val isDeleted: Boolean = false
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "book_shelf_cross_ref",
|
||||
primaryKeys =["bookId", "shelfId"],
|
||||
foreignKeys =[
|
||||
ForeignKey(
|
||||
entity = RecentFileEntity::class,
|
||||
parentColumns = ["bookId"],
|
||||
childColumns = ["bookId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
ForeignKey(
|
||||
entity = ShelfEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["shelfId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index(value = ["shelfId"]), Index(value = ["bookId"])]
|
||||
)
|
||||
data class BookShelfCrossRef(
|
||||
val bookId: String,
|
||||
val shelfId: String,
|
||||
val addedAt: Long
|
||||
)
|
||||
|
||||
@Entity(tableName = "tags")
|
||||
data class TagEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val name: String,
|
||||
val color: Int? = null,
|
||||
val createdAt: Long
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "book_tag_cross_ref",
|
||||
primaryKeys = ["bookId", "tagId"],
|
||||
foreignKeys =[
|
||||
ForeignKey(
|
||||
entity = RecentFileEntity::class,
|
||||
parentColumns = ["bookId"],
|
||||
childColumns = ["bookId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
ForeignKey(
|
||||
entity = TagEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns =["tagId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices =[Index(value = ["tagId"]), Index(value = ["bookId"])]
|
||||
)
|
||||
data class BookTagCrossRef(
|
||||
val bookId: String,
|
||||
val tagId: String
|
||||
)
|
||||
|
|
@ -72,34 +72,14 @@ object LocalSyncUtils {
|
|||
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
|
||||
|
||||
val syncFileName = ".${metadata.bookId}.json"
|
||||
val legacyVisibleName = "${metadata.bookId}.json"
|
||||
|
||||
val existingHidden = syncDir.findFile(syncFileName)
|
||||
val existingVisible = syncDir.findFile(legacyVisibleName)
|
||||
val fileToCheck = existingHidden ?: existingVisible
|
||||
|
||||
if (fileToCheck != null && fileToCheck.exists()) {
|
||||
try {
|
||||
val existingContent = context.contentResolver.openInputStream(fileToCheck.uri)?.use { input ->
|
||||
input.bufferedReader().use { it.readText() }
|
||||
}
|
||||
if (existingContent != null) {
|
||||
val existingMeta = FolderBookMetadata.fromJsonString(existingContent)
|
||||
if (existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
|
||||
Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.")
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
if (existingVisible != null && existingVisible.exists()) {
|
||||
try { existingVisible.delete() } catch (_: Exception) {}
|
||||
val existingMeta = resolveAndCleanMetadataConflicts(context, syncDir, metadata.bookId)
|
||||
if (existingMeta != null && existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
|
||||
Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val tempFileName = ".${metadata.bookId}.tmp"
|
||||
syncDir.findFile(tempFileName)?.delete()
|
||||
|
||||
val tempFile = syncDir.createFile("application/json", tempFileName)
|
||||
if (tempFile == null) {
|
||||
Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}")
|
||||
|
|
@ -128,7 +108,7 @@ object LocalSyncUtils {
|
|||
}
|
||||
|
||||
@Suppress("KotlinConstantConditions") if (writeSuccess) {
|
||||
val targetFile = rootTree.findFile(syncFileName)
|
||||
val targetFile = syncDir.findFile(syncFileName)
|
||||
if (targetFile != null && targetFile.exists()) {
|
||||
targetFile.delete()
|
||||
}
|
||||
|
|
@ -169,8 +149,8 @@ object LocalSyncUtils {
|
|||
val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
|
||||
val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json"
|
||||
val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp"
|
||||
syncDir.findFile(tempName)?.delete()
|
||||
val tempFile = syncDir.createFile("application/json", tempName)
|
||||
val existingMain = syncDir.findFile(targetName)
|
||||
|
||||
if (currentBest != null) {
|
||||
val (remoteTs, _) = currentBest
|
||||
|
|
@ -186,8 +166,6 @@ object LocalSyncUtils {
|
|||
wrapper.put("data", JSONObject(jsonPayload))
|
||||
val contentBytes = wrapper.toString().toByteArray()
|
||||
|
||||
syncDir.findFile(tempName)?.delete()
|
||||
|
||||
if (tempFile == null) {
|
||||
Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.")
|
||||
return@withContext
|
||||
|
|
@ -210,7 +188,7 @@ object LocalSyncUtils {
|
|||
}
|
||||
|
||||
@Suppress("KotlinConstantConditions") if (writeSuccess) {
|
||||
val existingMain = rootTree.findFile(targetName)
|
||||
val existingMain = syncDir.findFile(targetName)
|
||||
if (existingMain != null) {
|
||||
if (!existingMain.delete()) {
|
||||
Timber.tag("FolderAnnotationSync").w("Failed to delete existing sidecar before rename. Attempting rename anyway (might fail on some SAF providers).")
|
||||
|
|
@ -238,55 +216,14 @@ object LocalSyncUtils {
|
|||
try {
|
||||
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME)
|
||||
if (syncDir == null || !syncDir.isDirectory) return@withContext results
|
||||
val allFiles = syncDir.listFiles()
|
||||
val bookIds = syncDir.listFiles()
|
||||
.mapNotNull { extractAnnotationBookId(it.name) }
|
||||
.toSet()
|
||||
|
||||
val annotationFiles = allFiles.filter { file ->
|
||||
val name = file.name ?: ""
|
||||
name.contains(ANNOTATION_SUFFIX) && name.endsWith(".json") && !name.endsWith(".tmp")
|
||||
}
|
||||
|
||||
val filesByBookId = annotationFiles.groupBy { file ->
|
||||
val name = file.name ?: ""
|
||||
var temp = name.substringBeforeLast(".json")
|
||||
if (temp.contains(".sync-conflict")) {
|
||||
temp = temp.substringBefore(".sync-conflict")
|
||||
}
|
||||
if (temp.endsWith(ANNOTATION_SUFFIX)) {
|
||||
temp = temp.substring(0, temp.length - ANNOTATION_SUFFIX.length)
|
||||
}
|
||||
if (temp.startsWith(".")) {
|
||||
temp = temp.substring(1)
|
||||
}
|
||||
temp
|
||||
}
|
||||
|
||||
filesByBookId.forEach { (bookId, files) ->
|
||||
if (bookId.isNotBlank()) {
|
||||
var bestTs = -1L
|
||||
var bestData: String? = null
|
||||
|
||||
for (file in files) {
|
||||
try {
|
||||
val content = context.contentResolver.openInputStream(file.uri)?.use {
|
||||
it.bufferedReader().readText()
|
||||
} ?: continue
|
||||
|
||||
val json = JSONObject(content)
|
||||
val ts = json.optLong("timestamp", 0L)
|
||||
val data = json.optJSONObject("data")?.toString()
|
||||
|
||||
if (data != null && ts > bestTs) {
|
||||
bestTs = ts
|
||||
bestData = data
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("FolderAnnotationSync").e(e, "Error parsing preloaded file: ${file.name}")
|
||||
}
|
||||
}
|
||||
|
||||
if (bestData != null) {
|
||||
results[bookId] = Pair(bestTs, bestData)
|
||||
}
|
||||
for (bookId in bookIds) {
|
||||
val best = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
|
||||
if (best != null) {
|
||||
results[bookId] = best
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -319,15 +256,16 @@ object LocalSyncUtils {
|
|||
bookId: String
|
||||
): Pair<Long, String>? {
|
||||
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
|
||||
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
|
||||
|
||||
val allFiles = syncDir.listFiles()
|
||||
|
||||
val candidates = allFiles.filter { file ->
|
||||
val name = file.name ?: ""
|
||||
name.startsWith(basePattern) &&
|
||||
name.endsWith(".json") &&
|
||||
!name.endsWith(".tmp") &&
|
||||
!name.contains(".syncthing.")
|
||||
(name.startsWith(basePattern) || name.startsWith(legacyPattern)) &&
|
||||
name.endsWith(".json") &&
|
||||
!name.endsWith(".tmp") &&
|
||||
!name.contains(".syncthing.")
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) return null
|
||||
|
|
@ -460,16 +398,76 @@ object LocalSyncUtils {
|
|||
}
|
||||
}
|
||||
|
||||
// 3. Migrate Legacy to Hidden if needed
|
||||
val winnerName = bestFile.name ?: ""
|
||||
if (!winnerName.startsWith(".")) {
|
||||
Timber.tag(TAG).i("Migrating legacy file to hidden: $winnerName")
|
||||
val correctName = ".${bookId}.json"
|
||||
if (bestFile.name != correctName) {
|
||||
Timber.tag(TAG).i("Renaming metadata winner ${bestFile.name} to $correctName")
|
||||
bestFile.renameTo(correctName)
|
||||
}
|
||||
}
|
||||
|
||||
return bestMeta
|
||||
}
|
||||
|
||||
private fun resolveAndCleanMetadataConflicts(
|
||||
context: Context,
|
||||
syncDir: DocumentFile,
|
||||
bookId: String
|
||||
): FolderBookMetadata? {
|
||||
val candidates = syncDir.listFiles().filter { file ->
|
||||
val name = file.name ?: ""
|
||||
val normalizedName = if (name.startsWith(".")) name.substring(1) else name
|
||||
normalizedName == "$bookId.json" ||
|
||||
normalizedName.startsWith("$bookId.sync-conflict") ||
|
||||
normalizedName.startsWith("$bookId.json.sync-conflict")
|
||||
}
|
||||
if (candidates.isEmpty()) return null
|
||||
return resolveAndCleanConflicts(context, candidates, bookId)
|
||||
}
|
||||
|
||||
private fun extractAnnotationBookId(name: String?): String? {
|
||||
if (name.isNullOrBlank()) return null
|
||||
var temp = name
|
||||
if (!temp.contains(ANNOTATION_SUFFIX) || !temp.endsWith(".json") || temp.endsWith(".tmp")) return null
|
||||
if (temp.contains(".sync-conflict")) {
|
||||
temp = temp.substringBefore(".sync-conflict")
|
||||
}
|
||||
temp = temp.substringBeforeLast(".json")
|
||||
if (temp.endsWith(ANNOTATION_SUFFIX)) {
|
||||
temp = temp.substring(0, temp.length - ANNOTATION_SUFFIX.length)
|
||||
}
|
||||
if (temp.startsWith(".")) {
|
||||
temp = temp.substring(1)
|
||||
}
|
||||
return temp.ifBlank { null }
|
||||
}
|
||||
|
||||
suspend fun deleteBookSidecars(
|
||||
context: Context,
|
||||
sourceFolderUri: Uri,
|
||||
bookId: String
|
||||
) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
||||
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext
|
||||
val targets = syncDir.listFiles().filter { file ->
|
||||
val name = file.name ?: return@filter false
|
||||
val normalized = if (name.startsWith(".")) name.substring(1) else name
|
||||
normalized == "$bookId.json" ||
|
||||
normalized.startsWith("$bookId.sync-conflict") ||
|
||||
normalized.startsWith("$bookId.json.sync-conflict") ||
|
||||
normalized.startsWith("$bookId${ANNOTATION_SUFFIX}")
|
||||
}
|
||||
targets.forEach {
|
||||
try {
|
||||
it.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to delete folder sidecars for $bookId")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAllFolderMetadata(
|
||||
context: Context,
|
||||
sourceFolderUri: Uri
|
||||
|
|
@ -486,6 +484,7 @@ object LocalSyncUtils {
|
|||
.filter {
|
||||
val name = it.name ?: ""
|
||||
(name.endsWith(".json") || name.contains(".sync-conflict")) &&
|
||||
!name.contains(ANNOTATION_SUFFIX) &&
|
||||
!name.endsWith(".tmp") &&
|
||||
!name.contains(".syncthing.")
|
||||
}
|
||||
|
|
@ -513,4 +512,4 @@ object LocalSyncUtils {
|
|||
}
|
||||
return@withContext finalResults
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,20 +21,19 @@
|
|||
package com.aryan.reader.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface RecentFileDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
@Upsert
|
||||
suspend fun insertOrUpdateFile(file: RecentFileEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
@Upsert
|
||||
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
|
||||
|
||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
|
||||
fun getRecentFiles(): Flow<List<RecentFileSummary>>
|
||||
|
||||
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
|
||||
|
|
@ -46,7 +45,7 @@ interface RecentFileDao {
|
|||
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
|
||||
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
|
||||
|
||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
||||
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
|
||||
fun getRecentFilesList(limit: Int): List<RecentFileSummary>
|
||||
|
||||
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
|
||||
|
|
@ -99,4 +98,4 @@ interface RecentFileDao {
|
|||
|
||||
@Query("UPDATE recent_files SET highlights = :highlightsJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
|
||||
suspend fun updateHighlights(bookId: String, highlightsJson: String, timestamp: Long)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ data class RecentFileEntity(
|
|||
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
|
||||
@ColumnInfo(defaultValue = "NULL") val customName: String?,
|
||||
@ColumnInfo(defaultValue = "NULL") val highlights: String?,
|
||||
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long
|
||||
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
|
||||
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
|
||||
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
|
||||
@ColumnInfo(defaultValue = "NULL") val description: String?
|
||||
)
|
||||
|
||||
data class RecentFileSummary(
|
||||
|
|
@ -76,5 +79,8 @@ data class RecentFileSummary(
|
|||
@ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?,
|
||||
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
|
||||
@ColumnInfo(defaultValue = "NULL") val customName: String?,
|
||||
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long
|
||||
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
|
||||
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
|
||||
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
|
||||
@ColumnInfo(defaultValue = "NULL") val description: String?
|
||||
)
|
||||
|
|
@ -47,7 +47,11 @@ data class RecentFileItem(
|
|||
val isReflowPreferred: Boolean = false,
|
||||
val customName: String? = null,
|
||||
val highlightsJson: String? = null,
|
||||
val fileSize: Long = 0L
|
||||
val fileSize: Long = 0L,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val description: String? = null,
|
||||
val tags: List<TagEntity> = emptyList()
|
||||
) {
|
||||
fun getUri(): Uri? = uriString?.toUri()
|
||||
}
|
||||
|
|
@ -77,7 +81,10 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
|||
isReflowPreferred = this.isReflowPreferred,
|
||||
customName = this.customName,
|
||||
highlightsJson = this.highlights,
|
||||
fileSize = this.fileSize
|
||||
fileSize = this.fileSize,
|
||||
seriesName = this.seriesName,
|
||||
seriesIndex = this.seriesIndex,
|
||||
description = this.description
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +113,10 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
|
|||
isReflowPreferred = this.isReflowPreferred,
|
||||
customName = this.customName,
|
||||
highlights = this.highlightsJson,
|
||||
fileSize = this.fileSize
|
||||
fileSize = this.fileSize,
|
||||
seriesName = this.seriesName,
|
||||
seriesIndex = this.seriesIndex,
|
||||
description = this.description
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +194,9 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem {
|
|||
isReflowPreferred = this.isReflowPreferred,
|
||||
customName = this.customName,
|
||||
highlightsJson = null,
|
||||
fileSize = this.fileSize
|
||||
fileSize = this.fileSize,
|
||||
seriesName = this.seriesName,
|
||||
seriesIndex = this.seriesIndex,
|
||||
description = this.description
|
||||
)
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import timber.log.Timber
|
|||
import com.aryan.reader.BookImporter
|
||||
import com.aryan.reader.paginatedreader.Locator
|
||||
import com.aryan.reader.pdf.PdfRichTextRepository
|
||||
import com.aryan.reader.epub.ImportedFileCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -39,6 +40,8 @@ import com.aryan.reader.pdf.data.PageLayoutRepository
|
|||
import com.aryan.reader.pdf.data.PdfTextBoxRepository
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONArray
|
||||
import java.util.UUID
|
||||
import androidx.core.content.edit
|
||||
|
||||
private const val COVER_CACHE_DIR = "cover_cache"
|
||||
|
||||
|
|
@ -54,6 +57,11 @@ class RecentFilesRepository(private val context: Context) {
|
|||
private val pdfTextBoxRepository = PdfTextBoxRepository(context)
|
||||
private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context)
|
||||
|
||||
val activeShelvesFlow = AppDatabase.getDatabase(context).shelfDao().getAllActiveShelves()
|
||||
val shelfCrossRefsFlow = AppDatabase.getDatabase(context).shelfDao().getAllBookShelfCrossRefs()
|
||||
val tagsFlow = AppDatabase.getDatabase(context).tagDao().getAllTags()
|
||||
val tagCrossRefsFlow = AppDatabase.getDatabase(context).tagDao().getAllBookTagCrossRefs()
|
||||
|
||||
init {
|
||||
if (!coverCacheDir.exists()) {
|
||||
coverCacheDir.mkdirs()
|
||||
|
|
@ -74,6 +82,28 @@ class RecentFilesRepository(private val context: Context) {
|
|||
return@withContext recentFileDao.getFileByUri(uriString)?.toRecentFileItem()
|
||||
}
|
||||
|
||||
suspend fun addShelf(shelf: ShelfEntity) = withContext(Dispatchers.IO) {
|
||||
AppDatabase.getDatabase(context).shelfDao().insertShelf(shelf)
|
||||
}
|
||||
|
||||
suspend fun addBooksToShelf(shelfId: String, bookIds: List<String>) = withContext(Dispatchers.IO) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val crossRefs = bookIds.map { BookShelfCrossRef(it, shelfId, timestamp) }
|
||||
AppDatabase.getDatabase(context).shelfDao().insertBookShelfCrossRefs(crossRefs)
|
||||
}
|
||||
|
||||
suspend fun renameShelf(shelfId: String, newName: String) = withContext(Dispatchers.IO) {
|
||||
AppDatabase.getDatabase(context).shelfDao().updateShelfName(shelfId, newName, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
suspend fun deleteShelf(shelfId: String) = withContext(Dispatchers.IO) {
|
||||
AppDatabase.getDatabase(context).shelfDao().markShelfAsDeleted(shelfId, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
suspend fun removeBooksFromShelf(shelfId: String, bookIds: List<String>) = withContext(Dispatchers.IO) {
|
||||
AppDatabase.getDatabase(context).shelfDao().removeBooksFromShelf(shelfId, bookIds)
|
||||
}
|
||||
|
||||
suspend fun getFilesBySourceFolder(sourceFolderUri: String): List<RecentFileItem> = withContext(Dispatchers.IO) {
|
||||
return@withContext recentFileDao.getFilesBySourceFolder(sourceFolderUri).map { it.toRecentFileItem() }
|
||||
}
|
||||
|
|
@ -82,6 +112,26 @@ class RecentFilesRepository(private val context: Context) {
|
|||
return@withContext recentFileDao.getAllFiles().map { it.toRecentFileItem() }
|
||||
}
|
||||
|
||||
suspend fun createTag(tag: TagEntity) = withContext(Dispatchers.IO) {
|
||||
AppDatabase.getDatabase(context).tagDao().insertTag(tag)
|
||||
}
|
||||
|
||||
suspend fun seedTagsIfEmpty(tags: List<TagEntity>) = withContext(Dispatchers.IO) {
|
||||
if (tags.isEmpty()) return@withContext
|
||||
val tagDao = AppDatabase.getDatabase(context).tagDao()
|
||||
if (tagDao.getTagCount() == 0) {
|
||||
tagDao.insertTags(tags)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun assignTagToBook(bookId: String, tagId: String) = withContext(Dispatchers.IO) {
|
||||
AppDatabase.getDatabase(context).tagDao().insertBookTagCrossRef(BookTagCrossRef(bookId, tagId))
|
||||
}
|
||||
|
||||
suspend fun removeTagFromBook(bookId: String, tagId: String) = withContext(Dispatchers.IO) {
|
||||
AppDatabase.getDatabase(context).tagDao().removeTagFromBook(tagId, bookId)
|
||||
}
|
||||
|
||||
suspend fun clearAllLocalData() = withContext(Dispatchers.IO) {
|
||||
recentFileDao.clearAll()
|
||||
if (coverCacheDir.exists()) {
|
||||
|
|
@ -131,7 +181,10 @@ class RecentFilesRepository(private val context: Context) {
|
|||
isDeleted = item.isDeleted,
|
||||
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
|
||||
highlights = item.highlightsJson ?: existingItem.highlights,
|
||||
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize
|
||||
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
|
||||
seriesName = item.seriesName ?: existingItem.seriesName,
|
||||
seriesIndex = item.seriesIndex ?: existingItem.seriesIndex,
|
||||
description = item.description ?: existingItem.description
|
||||
)
|
||||
} else {
|
||||
item.toRecentFileEntity()
|
||||
|
|
@ -348,7 +401,9 @@ class RecentFilesRepository(private val context: Context) {
|
|||
if (item != null) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime)
|
||||
Timber.d("Updated PDF reading position for ${item.bookId} to page $page, progress $progress%")
|
||||
Timber.tag("PdfPositionDebug").i("Repository: Executed DB update for ${item.bookId} to Page $page, Progress $progress% at TS: $currentTime")
|
||||
} else {
|
||||
Timber.tag("PdfPositionDebug").e("Repository: DB Update Failed! No recent file found matching URI: $uriString")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -400,8 +455,7 @@ class RecentFilesRepository(private val context: Context) {
|
|||
pdfTextBoxRepository.getFileForSync(item.bookId).delete()
|
||||
pdfHighlightRepository.getFileForSync(item.bookId).delete()
|
||||
|
||||
val cacheDir = File(context.cacheDir, "imported_file_${item.bookId}")
|
||||
if (cacheDir.exists()) cacheDir.deleteRecursively()
|
||||
ImportedFileCache.clearBookCache(context, item.bookId)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error during deep cleanup of sidecars for ${item.bookId}: ${e.message}")
|
||||
}
|
||||
|
|
@ -473,8 +527,10 @@ class RecentFilesRepository(private val context: Context) {
|
|||
renameSafely(pdfTextBoxRepository.getFileForSync(oldId), pdfTextBoxRepository.getFileForSync(newId))
|
||||
renameSafely(pdfHighlightRepository.getFileForSync(oldId), pdfHighlightRepository.getFileForSync(newId))
|
||||
|
||||
val oldCache = File(context.cacheDir, "imported_file_$oldId")
|
||||
val newCache = File(context.cacheDir, "imported_file_$newId")
|
||||
ImportedFileCache.clearTemporaryBookDirs(context, oldId)
|
||||
ImportedFileCache.clearTemporaryBookDirs(context, newId)
|
||||
val oldCache = ImportedFileCache.activeBookDir(context, oldId)
|
||||
val newCache = ImportedFileCache.activeBookDir(context, newId)
|
||||
if (oldCache.exists()) {
|
||||
if (newCache.exists()) newCache.deleteRecursively()
|
||||
oldCache.renameTo(newCache)
|
||||
|
|
@ -486,8 +542,7 @@ class RecentFilesRepository(private val context: Context) {
|
|||
try {
|
||||
pdfRichTextRepository.getFileForSync(bookId).delete()
|
||||
pageLayoutRepository.getLayoutFile(bookId).delete()
|
||||
val cacheDir = File(context.cacheDir, "imported_file_$bookId")
|
||||
if (cacheDir.exists()) cacheDir.deleteRecursively()
|
||||
ImportedFileCache.clearBookCache(context, bookId)
|
||||
Timber.d("Cleared layout and text caches for modified book: $bookId")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error clearing caches for $bookId")
|
||||
|
|
@ -502,4 +557,46 @@ class RecentFilesRepository(private val context: Context) {
|
|||
}
|
||||
Timber.d("Batch inserted/updated ${items.size} recent files in DB.")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun migrateLegacyShelvesToRoom() = withContext(Dispatchers.IO) {
|
||||
val prefs = context.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean("is_shelves_migrated_to_room", false)) return@withContext
|
||||
|
||||
Timber.i("Starting migration of legacy SharedPreferences shelves to Room DB...")
|
||||
|
||||
val shelfNames = prefs.getStringSet("shelf_names", emptySet()) ?: emptySet()
|
||||
if (shelfNames.isEmpty()) {
|
||||
prefs.edit { putBoolean("is_shelves_migrated_to_room", true) }
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val db = AppDatabase.getDatabase(context)
|
||||
val shelfDao = db.shelfDao()
|
||||
|
||||
val validBookIds = recentFileDao.getAllFiles().map { it.bookId }.toSet()
|
||||
|
||||
shelfNames.forEach { name ->
|
||||
val shelfId = UUID.nameUUIDFromBytes(name.toByteArray()).toString()
|
||||
val timestamp = prefs.getLong("shelf_timestamp_$name", System.currentTimeMillis())
|
||||
val isDeleted = prefs.getBoolean("shelf_deleted_$name", false)
|
||||
val bookIds = prefs.getStringSet("shelf_content_$name", emptySet()) ?: emptySet()
|
||||
|
||||
val shelf = ShelfEntity(
|
||||
id = shelfId, name = name, isSmart = false, smartRulesJson = null,
|
||||
createdAt = timestamp, updatedAt = timestamp, isDeleted = isDeleted
|
||||
)
|
||||
shelfDao.insertShelf(shelf)
|
||||
|
||||
val crossRefs = bookIds.filter { it in validBookIds }.map { bookId ->
|
||||
BookShelfCrossRef(bookId = bookId, shelfId = shelfId, addedAt = timestamp)
|
||||
}
|
||||
|
||||
if (crossRefs.isNotEmpty()) {
|
||||
shelfDao.insertBookShelfCrossRefs(crossRefs)
|
||||
}
|
||||
}
|
||||
|
||||
prefs.edit { putBoolean("is_shelves_migrated_to_room", true) }
|
||||
Timber.i("Successfully migrated legacy shelves to Room.")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
package com.aryan.reader.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
enum class SmartField { TITLE, AUTHOR, PROGRESS, FILE_TYPE, FOLDER, TAG }
|
||||
@Serializable
|
||||
enum class SmartOperator { EQUALS, CONTAINS, GREATER_THAN, LESS_THAN }
|
||||
|
||||
@Serializable
|
||||
data class SmartRule(
|
||||
val field: SmartField,
|
||||
val operator: SmartOperator,
|
||||
val value: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SmartCollectionDefinition(
|
||||
val matchAll: Boolean = true,
|
||||
val rules: List<SmartRule> = emptyList()
|
||||
)
|
||||
|
||||
object SmartCollectionEngine {
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition)
|
||||
|
||||
fun fromJson(json: String?): SmartCollectionDefinition? {
|
||||
if (json.isNullOrBlank()) return null
|
||||
return try {
|
||||
this.json.decodeFromString<SmartCollectionDefinition>(json)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean {
|
||||
if (definition.rules.isEmpty()) return false
|
||||
|
||||
val results = definition.rules.map { rule ->
|
||||
when (rule.field) {
|
||||
SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule)
|
||||
SmartField.AUTHOR -> evaluateString(book.author ?: "", rule)
|
||||
SmartField.FILE_TYPE -> evaluateString(book.type.name, rule)
|
||||
SmartField.FOLDER -> evaluateString(book.sourceFolderUri ?: "", rule)
|
||||
SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule)
|
||||
SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule)
|
||||
}
|
||||
}
|
||||
return if (definition.matchAll) results.all { it } else results.any { it }
|
||||
}
|
||||
|
||||
private fun evaluateString(target: String, rule: SmartRule): Boolean {
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true)
|
||||
SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateNumber(target: Float, rule: SmartRule): Boolean {
|
||||
val ruleValue = rule.value.toFloatOrNull() ?: return false
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> target == ruleValue
|
||||
SmartOperator.GREATER_THAN -> target > ruleValue
|
||||
SmartOperator.LESS_THAN -> target < ruleValue
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateTags(tags: List<String>, rule: SmartRule): Boolean {
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) }
|
||||
SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) }
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
// CalibreBundleExtractor.kt
|
||||
package com.aryan.reader.epub
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.aryan.reader.BookImporter
|
||||
import com.aryan.reader.FileType
|
||||
import com.aryan.reader.data.RecentFilesRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.w3c.dom.Element
|
||||
import timber.log.Timber
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
data class CalibreBundleResult(
|
||||
val internalBookUri: Uri,
|
||||
val type: FileType,
|
||||
val title: String?,
|
||||
val author: String?,
|
||||
val description: String?,
|
||||
val seriesName: String?,
|
||||
val seriesIndex: Double?,
|
||||
val coverCachePath: String?
|
||||
)
|
||||
|
||||
object CalibreBundleExtractor {
|
||||
suspend fun processZip(
|
||||
context: Context,
|
||||
zipUri: Uri,
|
||||
bookId: String,
|
||||
bookImporter: BookImporter,
|
||||
recentFilesRepository: RecentFilesRepository
|
||||
): CalibreBundleResult? = withContext(Dispatchers.IO) {
|
||||
var tempBookFile: File? = null
|
||||
var extractedType: FileType? = null
|
||||
var ext = ""
|
||||
var opfData: String? = null
|
||||
var coverBytes: ByteArray? = null
|
||||
|
||||
try {
|
||||
context.contentResolver.openInputStream(zipUri)?.use { inputStream ->
|
||||
val zis = ZipInputStream(inputStream)
|
||||
var entry = zis.nextEntry
|
||||
Timber.d("CalibreExtractor: Started reading zip entries from $zipUri")
|
||||
while (entry != null) {
|
||||
val name = entry.name.lowercase()
|
||||
Timber.d("CalibreExtractor: Found zip entry: $name")
|
||||
if (!entry.isDirectory) {
|
||||
if (name.endsWith(".opf")) {
|
||||
opfData = String(zis.readBytes(), Charsets.UTF_8)
|
||||
Timber.d("CalibreExtractor: Extracted OPF data, length=${opfData?.length}")
|
||||
} else if (name == "cover.jpg" || name == "cover.jpeg" || name.endsWith(".jpg")) {
|
||||
// Prefer exact 'cover.jpg' but grab the first image as fallback
|
||||
if (coverBytes == null || name.startsWith("cover")) {
|
||||
coverBytes = zis.readBytes()
|
||||
Timber.d("CalibreExtractor: Extracted cover image from $name")
|
||||
}
|
||||
} else {
|
||||
val type = when {
|
||||
name.endsWith(".epub") -> FileType.EPUB
|
||||
name.endsWith(".mobi") || name.endsWith(".azw3") -> FileType.MOBI
|
||||
name.endsWith(".pdf") -> FileType.PDF
|
||||
name.endsWith(".fb2") -> FileType.FB2
|
||||
else -> null
|
||||
}
|
||||
if (type != null && tempBookFile == null) {
|
||||
extractedType = type
|
||||
ext = File(name).extension
|
||||
tempBookFile = File(context.cacheDir, "temp_bundle_${bookId}.$ext")
|
||||
FileOutputStream(tempBookFile!!).use { fos ->
|
||||
zis.copyTo(fos)
|
||||
}
|
||||
Timber.d("CalibreExtractor: Extracted book file $name to temp file")
|
||||
}
|
||||
}
|
||||
}
|
||||
zis.closeEntry()
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d("CalibreExtractor: Finished parsing zip. tempBookFile exists=${tempBookFile != null}, opfData exists=${opfData != null}, extractedType=$extractedType")
|
||||
|
||||
if (tempBookFile != null && opfData != null && extractedType != null) {
|
||||
val finalBookFile = bookImporter.createBookFile("$bookId.$ext")
|
||||
tempBookFile!!.renameTo(finalBookFile)
|
||||
|
||||
var coverPath: String? = null
|
||||
if (coverBytes != null) {
|
||||
val bitmap = BitmapFactory.decodeByteArray(coverBytes, 0, coverBytes!!.size)
|
||||
if (bitmap != null) {
|
||||
coverPath = recentFilesRepository.saveCoverToCache(bitmap, zipUri)
|
||||
}
|
||||
}
|
||||
|
||||
var title: String? = null
|
||||
var author: String? = null
|
||||
var description: String? = null
|
||||
var seriesName: String? = null
|
||||
var seriesIndex: Double? = null
|
||||
|
||||
try {
|
||||
val factory = DocumentBuilderFactory.newInstance()
|
||||
val builder = factory.newDocumentBuilder()
|
||||
val document = builder.parse(ByteArrayInputStream(opfData!!.toByteArray(Charsets.UTF_8)))
|
||||
val metadataNodes = document.getElementsByTagName("metadata")
|
||||
Timber.d("CalibreExtractor: Parsed OPF XML. metadataNodes count: ${metadataNodes.length}")
|
||||
|
||||
if (metadataNodes.length > 0) {
|
||||
val metadata = metadataNodes.item(0) as Element
|
||||
|
||||
val titleNodes = metadata.getElementsByTagName("dc:title")
|
||||
Timber.d("CalibreExtractor: Found ${titleNodes.length} dc:title nodes")
|
||||
if (titleNodes.length > 0) title = titleNodes.item(0).textContent
|
||||
|
||||
val authorNodes = metadata.getElementsByTagName("dc:creator")
|
||||
Timber.d("CalibreExtractor: Found ${authorNodes.length} dc:creator nodes")
|
||||
if (authorNodes.length > 0) author = authorNodes.item(0).textContent
|
||||
|
||||
val descNodes = metadata.getElementsByTagName("dc:description")
|
||||
Timber.d("CalibreExtractor: Found ${descNodes.length} dc:description nodes")
|
||||
if (descNodes.length > 0) description = descNodes.item(0).textContent
|
||||
|
||||
val metaNodes = metadata.getElementsByTagName("meta")
|
||||
Timber.d("CalibreExtractor: Found ${metaNodes.length} meta nodes")
|
||||
|
||||
for (i in 0 until metaNodes.length) {
|
||||
val meta = metaNodes.item(i) as Element
|
||||
val nameAttr = meta.getAttribute("name")
|
||||
val contentAttr = meta.getAttribute("content")
|
||||
if (nameAttr == "calibre:series") seriesName = contentAttr
|
||||
if (nameAttr == "calibre:series_index") seriesIndex = contentAttr.toDoubleOrNull()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse metadata.opf")
|
||||
}
|
||||
|
||||
Timber.d("CalibreExtractor: Final extracted info - title=$title, author=$author, series=$seriesName, index=$seriesIndex")
|
||||
|
||||
return@withContext CalibreBundleResult(
|
||||
internalBookUri = finalBookFile.toUri(),
|
||||
type = extractedType!!,
|
||||
title = title,
|
||||
author = author,
|
||||
description = description,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex,
|
||||
coverCachePath = coverPath
|
||||
)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to process zip bundle")
|
||||
} finally {
|
||||
tempBookFile?.delete() // Cleanup if parsing failed midway
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
|
|
@ -46,5 +46,8 @@ data class EpubBook(
|
|||
val extractionBasePath: String = "",
|
||||
val css: Map<String, String> = emptyMap(),
|
||||
@Transient
|
||||
val chaptersForPagination: List<EpubChapter> = chapters
|
||||
val chaptersForPagination: List<EpubChapter> = chapters,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val description: String? = null,
|
||||
)
|
||||
|
|
@ -167,17 +167,14 @@ class EpubParser(private val context: Context) {
|
|||
bookId: String,
|
||||
shouldUseToc: Boolean = true,
|
||||
originalBookNameHint: String = "streamed_book",
|
||||
parseContent: Boolean = true
|
||||
parseContent: Boolean = true,
|
||||
extractionDirOverride: File? = null
|
||||
): EpubBook {
|
||||
return withContext(Dispatchers.IO) {
|
||||
Timber.d("Parsing EPUB input stream for bookId: $bookId")
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId")
|
||||
|
||||
if (extractionDir.exists()) {
|
||||
extractionDir.deleteRecursively()
|
||||
}
|
||||
extractionDir.mkdirs()
|
||||
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
|
||||
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||
|
||||
val tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir)
|
||||
val filesMap: Map<String, EpubFile>
|
||||
|
|
@ -244,6 +241,24 @@ class EpubParser(private val context: Context) {
|
|||
val metadataLanguage =
|
||||
document.metadata.selectFirstChildTag("dc:language")?.textContent ?: "en"
|
||||
val metadataCoverId = getMetadataCoverId(document.metadata)
|
||||
val metadataDescription =
|
||||
document.metadata.selectFirstChildTag("dc:description")?.textContent
|
||||
|
||||
Timber.d("EpubParser: Extracted OPF metadata: title='$metadataTitle', author='$metadataAuthor'")
|
||||
|
||||
var metadataSeriesName: String? = null
|
||||
var metadataSeriesIndex: Double? = null
|
||||
|
||||
document.metadata.selectChildTag("meta")
|
||||
.ifEmpty { document.metadata.selectChildTag("opf:meta") }
|
||||
.forEach { meta ->
|
||||
val nameAttr = meta.getAttributeValue("name")
|
||||
val contentAttr = meta.getAttributeValue("content")
|
||||
|
||||
if (nameAttr == "calibre:series") metadataSeriesName = contentAttr
|
||||
if (nameAttr == "calibre:series_index") metadataSeriesIndex = contentAttr?.toDoubleOrNull()
|
||||
}
|
||||
|
||||
val opfRelativePath = document.opfFilePath
|
||||
val opfParentDir = File(opfRelativePath).parentFile ?: File("")
|
||||
val manifestItems = getManifestItems(document.manifest, opfParentDir)
|
||||
|
|
@ -344,7 +359,10 @@ class EpubParser(private val context: Context) {
|
|||
pageList = pageTargets,
|
||||
tableOfContents = tableOfContents,
|
||||
extractionBasePath = extractionBasePath,
|
||||
css = cssContent
|
||||
css = cssContent,
|
||||
seriesName = metadataSeriesName,
|
||||
seriesIndex = metadataSeriesIndex,
|
||||
description = metadataDescription
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -688,4 +706,4 @@ class EpubParser(private val context: Context) {
|
|||
lowerName.endsWith(".htm") ||
|
||||
lowerName.endsWith(".css")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,11 @@ class Fb2Parser(private val context: Context) {
|
|||
inputStream: InputStream,
|
||||
bookId: String,
|
||||
originalBookNameHint: String,
|
||||
parseContent: Boolean = true
|
||||
parseContent: Boolean = true,
|
||||
extractionDirOverride: File? = null
|
||||
): EpubBook = withContext(Dispatchers.IO) {
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
|
||||
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||
|
||||
var streamToParse = inputStream
|
||||
try {
|
||||
|
|
@ -324,4 +322,4 @@ class Fb2Parser(private val context: Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt
Normal file
81
app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package com.aryan.reader.epub
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
object ImportedFileCache {
|
||||
private const val ACTIVE_PREFIX = "imported_file_"
|
||||
private const val TEMP_PREFIX = "imported_file_tmp_"
|
||||
private val invalidSegmentChars = Regex("[^A-Za-z0-9._-]+")
|
||||
|
||||
fun activeBookDir(context: Context, bookId: String): File {
|
||||
return File(context.cacheDir, "$ACTIVE_PREFIX$bookId")
|
||||
}
|
||||
|
||||
fun prepareActiveBookDir(context: Context, bookId: String): File {
|
||||
return prepareDirectory(activeBookDir(context, bookId))
|
||||
}
|
||||
|
||||
fun createTemporaryBookDir(context: Context, bookId: String, purpose: String): File {
|
||||
val dirName = buildString {
|
||||
append(TEMP_PREFIX)
|
||||
append(purpose.toCacheSegment())
|
||||
append('_')
|
||||
append(bookMarker(bookId))
|
||||
append('_')
|
||||
append(UUID.randomUUID())
|
||||
}
|
||||
return prepareDirectory(File(context.cacheDir, dirName))
|
||||
}
|
||||
|
||||
fun prepareDirectory(directory: File): File {
|
||||
if (directory.exists()) {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
directory.mkdirs()
|
||||
return directory
|
||||
}
|
||||
|
||||
fun clearBookCache(context: Context, bookId: String) {
|
||||
activeBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
|
||||
clearTemporaryBookDirs(context, bookId)
|
||||
}
|
||||
|
||||
fun clearTemporaryBookDirs(context: Context, bookId: String) {
|
||||
val marker = "_${bookMarker(bookId)}_"
|
||||
context.cacheDir.listFiles()?.forEach { file ->
|
||||
if (isTemporaryBookDir(file.name) && file.name.contains(marker)) {
|
||||
file.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteStaleTemporaryBookDirs(
|
||||
context: Context,
|
||||
olderThanMillis: Long,
|
||||
nowMillis: Long = System.currentTimeMillis()
|
||||
) {
|
||||
context.cacheDir.listFiles()?.forEach { file ->
|
||||
if (isTemporaryBookDir(file.name) && nowMillis - file.lastModified() >= olderThanMillis) {
|
||||
file.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isTemporaryBookDir(name: String): Boolean = name.startsWith(TEMP_PREFIX)
|
||||
|
||||
fun isActiveBookDir(name: String): Boolean {
|
||||
return name.startsWith(ACTIVE_PREFIX) && !isTemporaryBookDir(name)
|
||||
}
|
||||
|
||||
private fun bookMarker(bookId: String): String {
|
||||
val normalized = bookId.toCacheSegment().ifBlank { "book" }.take(40)
|
||||
val hash = bookId.hashCode().toLong() and 0xffffffffL
|
||||
return "${normalized}_${hash.toString(16)}"
|
||||
}
|
||||
|
||||
private fun String.toCacheSegment(): String {
|
||||
return replace(invalidSegmentChars, "_").trim('_')
|
||||
}
|
||||
}
|
||||
|
|
@ -109,27 +109,18 @@ class MobiParser(private val context: Context) {
|
|||
private external fun parseMobiFile(filePath: String): ParsedMobiData?
|
||||
|
||||
companion object {
|
||||
const val EXTRACTED_EPUB_DIR_NAME = "extracted_epubs"
|
||||
|
||||
init {
|
||||
System.loadLibrary("mobi")
|
||||
System.loadLibrary("native-lib")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBookExtractionDir(bookIdentifier: String): File {
|
||||
val parentDir = File(context.cacheDir, EXTRACTED_EPUB_DIR_NAME)
|
||||
if (!parentDir.exists()) {
|
||||
parentDir.mkdirs()
|
||||
}
|
||||
return File(parentDir, bookIdentifier)
|
||||
}
|
||||
|
||||
suspend fun createMobiBook(
|
||||
inputStream: InputStream,
|
||||
bookId: String,
|
||||
originalBookNameHint: String,
|
||||
parseContent: Boolean = true
|
||||
parseContent: Boolean = true,
|
||||
extractionDirOverride: File? = null
|
||||
): EpubBook? = withContext(Dispatchers.IO) {
|
||||
val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
|
||||
try {
|
||||
|
|
@ -162,8 +153,8 @@ class MobiParser(private val context: Context) {
|
|||
val bookTitle = parsedData.title ?: originalBookNameHint
|
||||
val bookAuthor = parsedData.author ?: "Unknown Author"
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId")
|
||||
extractionDir.mkdirs()
|
||||
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
|
||||
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||
|
||||
val sequentialImageMap = parsedData.resources
|
||||
.filter { it.mediaType.startsWith("image/") }
|
||||
|
|
@ -314,4 +305,4 @@ class MobiParser(private val context: Context) {
|
|||
Timber.d("Final EpubBook created. CSS map size: ${finalBook.css.size}, Image count: ${finalBook.images.size}")
|
||||
return@withContext finalBook
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,12 +29,11 @@ class OdtParser(private val context: Context) {
|
|||
bookId: String,
|
||||
originalBookNameHint: String,
|
||||
isFlat: Boolean,
|
||||
parseContent: Boolean = true
|
||||
parseContent: Boolean = true,
|
||||
extractionDirOverride: File? = null
|
||||
): EpubBook = withContext(Dispatchers.IO) {
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
|
||||
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||
|
||||
val mathJaxFileName = "tex-mml-chtml.js"
|
||||
val mathJaxFile = File(extractionDir, mathJaxFileName)
|
||||
|
|
@ -427,4 +426,4 @@ class OdtParser(private val context: Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,11 +41,17 @@ import java.io.File
|
|||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class SingleFileImporter(private val context: Context) {
|
||||
|
||||
private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
companion object {
|
||||
private const val MAX_DOCX_ARCHIVE_BYTES = 64L * 1024L * 1024L
|
||||
private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L
|
||||
}
|
||||
|
||||
suspend fun importSingleFile(
|
||||
inputStream: InputStream,
|
||||
type: FileType,
|
||||
|
|
@ -104,14 +110,19 @@ class SingleFileImporter(private val context: Context) {
|
|||
var inQuotes = false
|
||||
|
||||
for (char in line) {
|
||||
if (char == '\"') {
|
||||
inQuotes = !inQuotes
|
||||
} else if (char == delimiter && !inQuotes) {
|
||||
val escaped = current.toString().replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
writer.write("<td>$escaped</td>")
|
||||
current.clear()
|
||||
} else {
|
||||
current.append(char)
|
||||
when (char) {
|
||||
'\"' -> {
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
delimiter if !inQuotes -> {
|
||||
val escaped =
|
||||
current.toString().replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
writer.write("<td>$escaped</td>")
|
||||
current.clear()
|
||||
}
|
||||
else -> {
|
||||
current.append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
val escapedFinal = current.toString().replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
|
@ -663,15 +674,29 @@ class SingleFileImporter(private val context: Context) {
|
|||
val parseStart = System.currentTimeMillis()
|
||||
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint")
|
||||
|
||||
val htmlContent = inputStream.use { stream ->
|
||||
val converter = DocumentConverter()
|
||||
converter.convertToHtml(stream).value ?: ""
|
||||
}
|
||||
|
||||
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms")
|
||||
|
||||
val sourceDocxFile = File(context.cacheDir, "temp_docx_source_${UUID.randomUUID()}.docx")
|
||||
val tempFile = File(context.cacheDir, "temp_docx_${UUID.randomUUID()}.html")
|
||||
try {
|
||||
inputStream.use { stream ->
|
||||
FileOutputStream(sourceDocxFile).use { output ->
|
||||
stream.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
validateDocxForImport(sourceDocxFile, originalBookNameHint)
|
||||
|
||||
val htmlContent = sourceDocxFile.inputStream().use { stream ->
|
||||
try {
|
||||
val converter = DocumentConverter()
|
||||
converter.convertToHtml(stream).value ?: ""
|
||||
} catch (oom: OutOfMemoryError) {
|
||||
Timber.e(oom, "DOCX conversion ran out of memory for $originalBookNameHint")
|
||||
throw IllegalStateException("This DOCX file is too large to open safely on this device.")
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms")
|
||||
|
||||
FileOutputStream(tempFile).bufferedWriter().use { writer ->
|
||||
val title = originalBookNameHint.substringBeforeLast(".")
|
||||
writer.write("<!DOCTYPE html>\n<html>\n<head>\n<title>$title</title>\n</head>\n<body>\n")
|
||||
|
|
@ -683,12 +708,41 @@ class SingleFileImporter(private val context: Context) {
|
|||
return@withContext parseHtml(tempStream, originalBookNameHint, bookId, parseContent)
|
||||
}
|
||||
} finally {
|
||||
if (sourceDocxFile.exists()) {
|
||||
sourceDocxFile.delete()
|
||||
}
|
||||
if (tempFile.exists()) {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateDocxForImport(sourceDocxFile: File, originalBookNameHint: String) {
|
||||
val archiveBytes = sourceDocxFile.length()
|
||||
if (archiveBytes > MAX_DOCX_ARCHIVE_BYTES) {
|
||||
throw IllegalStateException("This DOCX file is too large to open safely on this device.")
|
||||
}
|
||||
|
||||
ZipFile(sourceDocxFile).use { zip ->
|
||||
var totalXmlBytes = 0L
|
||||
val entries = zip.entries()
|
||||
while (entries.hasMoreElements()) {
|
||||
val entry = entries.nextElement()
|
||||
if (entry.isDirectory) continue
|
||||
if (entry.name.endsWith(".xml", ignoreCase = true)) {
|
||||
val entrySize = entry.size
|
||||
if (entrySize > 0) {
|
||||
totalXmlBytes += entrySize
|
||||
}
|
||||
if (totalXmlBytes > MAX_DOCX_XML_BYTES) {
|
||||
Timber.w("DOCX XML payload too large for import: file=$originalBookNameHint xmlBytes=$totalXmlBytes")
|
||||
throw IllegalStateException("This DOCX file is too large to open safely on this device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeHtmlChapter(
|
||||
extractionDir: File,
|
||||
bookId: String,
|
||||
|
|
@ -718,4 +772,4 @@ class SingleFileImporter(private val context: Context) {
|
|||
isInToc = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ import java.io.BufferedReader
|
|||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStreamReader
|
||||
|
||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||
|
||||
private fun getFontCssInjection(): String {
|
||||
return """
|
||||
@font-face { font-family: 'Merriweather'; src: url('file:///android_asset/fonts/merriweather.ttf'); }
|
||||
|
|
@ -313,6 +315,17 @@ class FootnoteJsBridge(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
class LinkNavJsBridge(
|
||||
private val currentChapterTitle: String
|
||||
) {
|
||||
@JavascriptInterface
|
||||
fun onLinkClicked(href: String, epubType: String, linkText: String) {
|
||||
Timber.tag(TAG_LINK_NAV)
|
||||
.d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun ChapterWebView(
|
||||
|
|
@ -336,6 +349,8 @@ fun ChapterWebView(
|
|||
currentFontSize: Float,
|
||||
currentLineHeight: Float,
|
||||
currentParagraphGap: Float,
|
||||
currentImageSize: Float,
|
||||
currentHorizontalMargin: Float,
|
||||
onChapterInitiallyScrolled: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onTap: () -> Unit,
|
||||
|
|
@ -370,6 +385,7 @@ fun ChapterWebView(
|
|||
onAutoScrollChapterEnd: () -> Unit = {},
|
||||
activeHighlightPalette: List<HighlightColor>,
|
||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||
onInternalLinkClick: (String) -> Unit,
|
||||
activeTextureId: String? = null
|
||||
) {
|
||||
Timber.d(
|
||||
|
|
@ -471,6 +487,8 @@ fun ChapterWebView(
|
|||
currentFontSize,
|
||||
currentLineHeight,
|
||||
currentParagraphGap,
|
||||
currentImageSize,
|
||||
currentHorizontalMargin,
|
||||
currentFontFamily,
|
||||
currentTextAlign
|
||||
) {
|
||||
|
|
@ -550,6 +568,11 @@ fun ChapterWebView(
|
|||
consoleMessage?.let {
|
||||
val message = it.message()
|
||||
when {
|
||||
message.startsWith("LINK_NAV:") -> {
|
||||
Timber.tag(TAG_LINK_NAV)
|
||||
.d("JS -> ${message.substringAfter("LINK_NAV: ")}")
|
||||
}
|
||||
|
||||
message.startsWith("FootnoteDiag:") -> {
|
||||
Timber.tag("FootnoteDiag")
|
||||
.d("JS -> ${message.substringAfter("FootnoteDiag: ")}")
|
||||
|
|
@ -653,16 +676,31 @@ fun ChapterWebView(
|
|||
}, "FootnoteBridge"
|
||||
)
|
||||
|
||||
addJavascriptInterface(
|
||||
LinkNavJsBridge(chapterTitle), "LinkNavBridge"
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?, request: WebResourceRequest?
|
||||
): Boolean {
|
||||
val url = request?.url?.toString()
|
||||
if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) {
|
||||
Timber.d("Intercepted external link: $url")
|
||||
Timber.tag(TAG_LINK_NAV)
|
||||
.d("[EXTERNAL-INTERCEPT] url='$url' from chapter '$chapterTitle'")
|
||||
showExternalLinkDialog = url
|
||||
return true
|
||||
}
|
||||
if (url != null && url.startsWith("file://")) {
|
||||
Timber.tag(TAG_LINK_NAV)
|
||||
.d("[INTERNAL-LINK-INTERCEPTED] url='$url' from chapter '$chapterTitle'")
|
||||
onInternalLinkClick(url)
|
||||
return true
|
||||
}
|
||||
if (url != null) {
|
||||
Timber.tag(TAG_LINK_NAV)
|
||||
.d("[INTERNAL-LINK-PASSED] url='$url' from chapter '$chapterTitle' — allowing WebView to handle")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -744,7 +782,7 @@ fun ChapterWebView(
|
|||
}
|
||||
|
||||
view?.evaluateJavascript(
|
||||
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap);",
|
||||
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
|
||||
null
|
||||
)
|
||||
|
||||
|
|
@ -768,10 +806,59 @@ fun ChapterWebView(
|
|||
}
|
||||
} else if (!initialFragmentId.isNullOrBlank()) {
|
||||
Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId")
|
||||
view?.evaluateJavascript(
|
||||
"javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }",
|
||||
null
|
||||
)
|
||||
val js = """
|
||||
(function() {
|
||||
var targetId = '$initialFragmentId';
|
||||
var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]');
|
||||
if (el) {
|
||||
var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10);
|
||||
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
|
||||
return -2;
|
||||
}
|
||||
if (window.virtualization && window.virtualization.chunksData) {
|
||||
for (var i = 0; i < window.virtualization.chunksData.length; i++) {
|
||||
var chunkHtml = window.virtualization.chunksData[i];
|
||||
if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
})()
|
||||
""".trimIndent()
|
||||
|
||||
view?.evaluateJavascript(js) { result ->
|
||||
val chunkIdx = result?.toIntOrNull() ?: -1
|
||||
if (chunkIdx >= 0) {
|
||||
for (i in 0..chunkIdx) {
|
||||
onChunkRequested(i)
|
||||
}
|
||||
val scrollJs = """
|
||||
(function() {
|
||||
var chunkIndex = $chunkIdx;
|
||||
var fragmentId = '$initialFragmentId';
|
||||
setTimeout(function() {
|
||||
var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]');
|
||||
if (chunkDiv && chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) {
|
||||
chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex];
|
||||
chunkDiv.style.height = "";
|
||||
}
|
||||
setTimeout(function() {
|
||||
var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]');
|
||||
if (el) {
|
||||
var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10);
|
||||
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
|
||||
} else if (chunkDiv) {
|
||||
var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP;
|
||||
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
|
||||
}
|
||||
}, 50);
|
||||
}, 200);
|
||||
})()
|
||||
""".trimIndent()
|
||||
view.evaluateJavascript(scrollJs, null)
|
||||
}
|
||||
}
|
||||
onChapterInitiallyScrolled()
|
||||
scrollActionTaken = true
|
||||
} else if (initialScrollTarget != null) {
|
||||
|
|
@ -859,7 +946,7 @@ fun ChapterWebView(
|
|||
)
|
||||
|
||||
webView.evaluateJavascript(
|
||||
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap);",
|
||||
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
|
||||
null
|
||||
)
|
||||
|
||||
|
|
@ -1075,4 +1162,4 @@ fun ChapterWebView(
|
|||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1378,6 +1378,7 @@ fun TtsOverlayControls(
|
|||
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
|
||||
isCollapsed: Boolean,
|
||||
onCollapseChange: (Boolean) -> Unit,
|
||||
onLocateCurrentChunk: () -> Unit,
|
||||
onOpenTtsSettings: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -1504,6 +1505,14 @@ fun TtsOverlayControls(
|
|||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
IconButton(onClick = onLocateCurrentChunk, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.pin_drop),
|
||||
"Locate current chunk",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.ChevronRight, "Collapse", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
|
@ -1589,4 +1598,4 @@ fun TtsOverlayControls(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -24,17 +24,7 @@ import android.net.Uri
|
|||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -42,69 +32,88 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.data.CustomFontEntity
|
||||
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
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
const val SETTINGS_PREFS_NAME = "epub_reader_settings"
|
||||
private const val TEXT_ALIGN_KEY = "reader_text_align"
|
||||
private const val FONT_SIZE_KEY = "reader_font_size"
|
||||
private const val LINE_HEIGHT_KEY = "reader_line_height"
|
||||
private const val PARAGRAPH_GAP_KEY = "reader_paragraph_gap"
|
||||
private const val IMAGE_SIZE_KEY = "reader_image_size"
|
||||
private const val AUTO_SCROLL_SPEED_KEY = "reader_auto_scroll_speed"
|
||||
private const val FONT_FAMILY_KEY = "reader_font_family"
|
||||
private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
|
||||
|
|
@ -116,6 +125,8 @@ private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
|
|||
const val DEFAULT_FONT_SIZE_VAL = 1.0f
|
||||
const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
|
||||
const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
|
||||
const val DEFAULT_IMAGE_SIZE_VAL = 1.0f
|
||||
const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f
|
||||
private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
|
||||
private const val TTS_PITCH_KEY = "tts_pitch"
|
||||
|
||||
|
|
@ -170,6 +181,8 @@ data class FormatSettings(
|
|||
val fontSize: Float,
|
||||
val lineHeight: Float,
|
||||
val paragraphGap: Float,
|
||||
val imageSize: Float,
|
||||
val horizontalMargin: Float,
|
||||
val font: ReaderFont,
|
||||
val customPath: String?,
|
||||
val textAlign: ReaderTextAlign
|
||||
|
|
@ -179,8 +192,11 @@ private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_"
|
|||
private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_"
|
||||
private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_"
|
||||
private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_"
|
||||
private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_"
|
||||
private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_"
|
||||
private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_"
|
||||
private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_"
|
||||
private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin"
|
||||
|
||||
fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
|
@ -198,6 +214,8 @@ fun saveLocalReaderSettings(
|
|||
fontSize: Float,
|
||||
lineHeight: Float,
|
||||
paragraphGap: Float,
|
||||
imageSize: Float,
|
||||
horizontalMargin: Float,
|
||||
fontFamily: ReaderFont,
|
||||
customFontPath: String?,
|
||||
textAlign: ReaderTextAlign
|
||||
|
|
@ -207,6 +225,8 @@ fun saveLocalReaderSettings(
|
|||
putFloat(LOCAL_FONT_SIZE_PREFIX + bookId, fontSize)
|
||||
putFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, lineHeight)
|
||||
putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
|
||||
putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
|
||||
putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
|
||||
if (customFontPath != null) {
|
||||
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
|
||||
} else {
|
||||
|
|
@ -260,6 +280,14 @@ fun loadPullToTurnMultiplier(context: Context): Float {
|
|||
return prefs.getFloat(PULL_TO_TURN_MULTIPLIER_KEY, 1.0f)
|
||||
}
|
||||
|
||||
fun loadHorizontalMargin(context: Context): Float {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (prefs.contains(HORIZONTAL_MARGIN_KEY)) {
|
||||
return prefs.getFloat(HORIZONTAL_MARGIN_KEY, DEFAULT_HORIZONTAL_MARGIN_VAL)
|
||||
}
|
||||
return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL
|
||||
}
|
||||
|
||||
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
|
|
@ -281,6 +309,18 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
|
|||
prefs.getFloat(PARAGRAPH_GAP_KEY, DEFAULT_PARAGRAPH_GAP_VAL)
|
||||
}
|
||||
|
||||
val imageSize = if (isLocal && prefs.contains(LOCAL_IMAGE_SIZE_PREFIX + bookId)) {
|
||||
prefs.getFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, DEFAULT_IMAGE_SIZE_VAL)
|
||||
} else {
|
||||
prefs.getFloat(IMAGE_SIZE_KEY, DEFAULT_IMAGE_SIZE_VAL)
|
||||
}
|
||||
|
||||
val horizontalMargin = if (isLocal && prefs.contains(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId)) {
|
||||
prefs.getFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, DEFAULT_HORIZONTAL_MARGIN_VAL)
|
||||
} else {
|
||||
loadHorizontalMargin(context)
|
||||
}
|
||||
|
||||
val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) {
|
||||
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
|
||||
} else {
|
||||
|
|
@ -300,7 +340,16 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
|
|||
}
|
||||
val textAlign = ReaderTextAlign.entries.find { it.id == alignId } ?: ReaderTextAlign.DEFAULT
|
||||
|
||||
return FormatSettings(fontSize, lineHeight, paragraphGap, font, customPath, textAlign)
|
||||
return FormatSettings(
|
||||
fontSize = fontSize,
|
||||
lineHeight = lineHeight,
|
||||
paragraphGap = paragraphGap,
|
||||
imageSize = imageSize,
|
||||
horizontalMargin = horizontalMargin,
|
||||
font = font,
|
||||
customPath = customPath,
|
||||
textAlign = textAlign
|
||||
)
|
||||
}
|
||||
|
||||
fun getComposeFontFamily(
|
||||
|
|
@ -339,6 +388,8 @@ fun saveReaderSettings(
|
|||
fontSize: Float,
|
||||
lineHeight: Float,
|
||||
paragraphGap: Float,
|
||||
imageSize: Float,
|
||||
horizontalMargin: Float,
|
||||
fontFamily: ReaderFont,
|
||||
customFontPath: String?,
|
||||
textAlign: ReaderTextAlign
|
||||
|
|
@ -348,6 +399,8 @@ fun saveReaderSettings(
|
|||
putFloat(FONT_SIZE_KEY, fontSize)
|
||||
putFloat(LINE_HEIGHT_KEY, lineHeight)
|
||||
putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
|
||||
putFloat(IMAGE_SIZE_KEY, imageSize)
|
||||
putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
|
||||
if (customFontPath != null) {
|
||||
putString(FONT_FAMILY_KEY, "custom|$customFontPath")
|
||||
} else {
|
||||
|
|
@ -387,6 +440,7 @@ fun loadVolumeScrollSetting(context: Context): Boolean {
|
|||
return prefs.getBoolean(VOLUME_SCROLL_ENABLED_KEY, false)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReaderTextFormatPanel(
|
||||
isVisible: Boolean,
|
||||
|
|
@ -394,8 +448,12 @@ fun ReaderTextFormatPanel(
|
|||
onFontSizeChange: (Float) -> Unit,
|
||||
currentLineHeight: Float,
|
||||
onLineHeightChange: (Float) -> Unit,
|
||||
currentParagraphGap: Float, // NEW
|
||||
onParagraphGapChange: (Float) -> Unit, // NEW
|
||||
currentParagraphGap: Float,
|
||||
onParagraphGapChange: (Float) -> Unit,
|
||||
currentImageSize: Float,
|
||||
onImageSizeChange: (Float) -> Unit,
|
||||
currentHorizontalMargin: Float,
|
||||
onHorizontalMarginChange: (Float) -> Unit,
|
||||
currentFont: ReaderFont,
|
||||
currentCustomFontName: String?,
|
||||
onFontOptionClick: () -> Unit,
|
||||
|
|
@ -404,27 +462,28 @@ fun ReaderTextFormatPanel(
|
|||
onReset: () -> Unit,
|
||||
isLocalMode: Boolean,
|
||||
onLocalModeToggle: (Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = slideInVertically { it } + fadeIn(),
|
||||
exit = slideOutVertically { it } + fadeOut(),
|
||||
modifier = modifier
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.98f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 8.dp,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize()
|
||||
if (isVisible) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onClose,
|
||||
sheetState = sheetState,
|
||||
scrimColor = Color.Transparent,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.9f),
|
||||
contentWindowInsets = { WindowInsets.navigationBars }
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val maxSheetHeight = (configuration.screenHeightDp * 0.7f).dp
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = maxSheetHeight)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.padding(bottom = 24.dp)
|
||||
) {
|
||||
// Header Row (Local/Global + Close/Reset)
|
||||
Row(
|
||||
|
|
@ -500,7 +559,7 @@ fun ReaderTextFormatPanel(
|
|||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp)
|
||||
)
|
||||
|
||||
// Font Button (Full width)
|
||||
// Font Button
|
||||
Surface(
|
||||
onClick = onFontOptionClick,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
|
|
@ -540,7 +599,7 @@ fun ReaderTextFormatPanel(
|
|||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Alignment Button (Full width Segmented)
|
||||
// Alignment Button (Segmented)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
|
|
@ -586,49 +645,61 @@ fun ReaderTextFormatPanel(
|
|||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp)
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
// Sliders
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
// Size
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.label_font_size), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
|
||||
Slider(
|
||||
value = currentFontSize,
|
||||
onValueChange = onFontSizeChange,
|
||||
valueRange = 0.5f..3.0f,
|
||||
steps = 24,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(if (currentFontSize in 0.99f..1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentFontSize), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
|
||||
}
|
||||
// Lines
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.label_line_height), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
|
||||
Slider(
|
||||
value = currentLineHeight,
|
||||
onValueChange = onLineHeightChange,
|
||||
valueRange = 1.0f..3.0f,
|
||||
steps = 19,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(if (currentLineHeight <= 1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
|
||||
}
|
||||
// Paragraph Gap
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.label_paragraph_gap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
|
||||
Slider(
|
||||
value = currentParagraphGap,
|
||||
onValueChange = onParagraphGapChange,
|
||||
valueRange = 0.0f..3.0f,
|
||||
steps = 29,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(if (currentParagraphGap in 0.99f..1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentParagraphGap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
|
||||
}
|
||||
// Resolve the string once outside the lambdas
|
||||
val originalLabel = stringResource(R.string.label_original)
|
||||
val noneLabel = stringResource(R.string.label_none)
|
||||
|
||||
// Wide, smooth sliders without dots
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FormatSlider(
|
||||
label = stringResource(R.string.label_font_size),
|
||||
value = currentFontSize,
|
||||
onValueChange = onFontSizeChange,
|
||||
valueRange = 0.5f..3.0f,
|
||||
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
|
||||
)
|
||||
|
||||
FormatSlider(
|
||||
label = stringResource(R.string.label_line_height),
|
||||
value = currentLineHeight,
|
||||
onValueChange = onLineHeightChange,
|
||||
valueRange = 1.0f..3.0f,
|
||||
formatValue = { if (it <= 1.01f) originalLabel else "%.1fx".format(it) }
|
||||
)
|
||||
|
||||
FormatSlider(
|
||||
label = stringResource(R.string.label_paragraph_gap),
|
||||
value = currentParagraphGap,
|
||||
onValueChange = onParagraphGapChange,
|
||||
valueRange = 0.0f..3.0f,
|
||||
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
|
||||
)
|
||||
|
||||
FormatSlider(
|
||||
label = stringResource(R.string.label_image_size),
|
||||
value = currentImageSize,
|
||||
onValueChange = onImageSizeChange,
|
||||
valueRange = 0.5f..2.0f,
|
||||
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
|
||||
)
|
||||
|
||||
FormatSlider(
|
||||
label = stringResource(R.string.label_horizontal_margin),
|
||||
value = currentHorizontalMargin,
|
||||
onValueChange = onHorizontalMarginChange,
|
||||
valueRange = 0.0f..3.0f,
|
||||
formatValue = {
|
||||
when {
|
||||
it <= 0.01f -> noneLabel
|
||||
it in 0.99f..1.01f -> originalLabel
|
||||
else -> "%.1fx".format(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -761,8 +832,6 @@ fun VisualOptionsSheet(
|
|||
onPageInfoModeChange: (PageInfoMode) -> Unit,
|
||||
pullToTurnEnabled: Boolean,
|
||||
onPullToTurnChange: (Boolean) -> Unit,
|
||||
removeEdgePadding: Boolean,
|
||||
onRemoveEdgePaddingChange: (Boolean) -> Unit,
|
||||
pullToTurnMultiplier: Float,
|
||||
onPullToTurnMultiplierChange: (Float) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
|
|
@ -859,30 +928,6 @@ fun VisualOptionsSheet(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onRemoveEdgePaddingChange(!removeEdgePadding) }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.visual_options_edge_padding), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.visual_options_edge_padding_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Switch(checked = removeEdgePadding, onCheckedChange = { onRemoveEdgePaddingChange(it) })
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -922,4 +967,138 @@ fun <T> OptionSegmentedControl(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CustomCanvasSlider(
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val fraction = ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)).coerceIn(0f, 1f)
|
||||
val activeColor = MaterialTheme.colorScheme.primary
|
||||
val inactiveColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
val thumbColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(24.dp) // Keeps the touch target height slim
|
||||
.pointerInput(valueRange) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
fun update(offset: Offset) {
|
||||
val newFraction = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
val rawValue = valueRange.start + newFraction * (valueRange.endInclusive - valueRange.start)
|
||||
// Snap to 0.1 intervals for consistent formatting
|
||||
onValueChange((rawValue * 10f).roundToInt() / 10f)
|
||||
}
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val trackHeight = 4.dp.toPx()
|
||||
val cornerRadius = CornerRadius(trackHeight / 2, trackHeight / 2)
|
||||
val trackY = (size.height - trackHeight) / 2
|
||||
|
||||
// Draw Inactive Track
|
||||
drawRoundRect(
|
||||
color = inactiveColor,
|
||||
topLeft = Offset(0f, trackY),
|
||||
size = Size(size.width, trackHeight),
|
||||
cornerRadius = cornerRadius
|
||||
)
|
||||
|
||||
// Draw Active Track
|
||||
val activeWidth = fraction * size.width
|
||||
drawRoundRect(
|
||||
color = activeColor,
|
||||
topLeft = Offset(0f, trackY),
|
||||
size = Size(activeWidth, trackHeight),
|
||||
cornerRadius = cornerRadius
|
||||
)
|
||||
|
||||
// Draw Thumb
|
||||
val thumbRadius = 8.dp.toPx()
|
||||
drawCircle(
|
||||
color = thumbColor,
|
||||
radius = thumbRadius,
|
||||
center = Offset(
|
||||
x = activeWidth.coerceIn(thumbRadius, size.width - thumbRadius),
|
||||
y = size.height / 2
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FormatSlider(
|
||||
label: String,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
stepSize: Float = 0.1f,
|
||||
formatValue: (Float) -> String
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = formatValue(value),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
val newValue = (value - stepSize).coerceAtLeast(valueRange.start)
|
||||
onValueChange((newValue * 10f).roundToInt() / 10f)
|
||||
},
|
||||
modifier = Modifier.size(32.dp) // Slimmer buttons
|
||||
) {
|
||||
Icon(Icons.Default.Remove, contentDescription = "Decrease", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
||||
// Using our new CustomCanvasSlider here!
|
||||
CustomCanvasSlider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
valueRange = valueRange,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
val newValue = (value + stepSize).coerceAtMost(valueRange.endInclusive)
|
||||
onValueChange((newValue * 10f).roundToInt() / 10f)
|
||||
},
|
||||
modifier = Modifier.size(32.dp) // Slimmer buttons
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Increase", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ fun TtsSessionObserver(
|
|||
fun TtsHighlightHandler(
|
||||
ttsState: TtsPlaybackManager.TtsState,
|
||||
currentRenderMode: RenderMode,
|
||||
currentChapterIndex: Int,
|
||||
webViewRef: WebView?,
|
||||
paginator: IPaginator?,
|
||||
pagerState: PagerState,
|
||||
|
|
@ -223,14 +224,42 @@ fun TtsHighlightHandler(
|
|||
val text = ttsState.currentText
|
||||
val cfi = ttsState.sourceCfi
|
||||
val offset = ttsState.startOffsetInSource
|
||||
val activeTtsChapterIndex = ttsState.chapterIndex ?: ttsChapterIndex
|
||||
|
||||
if (
|
||||
currentRenderMode == RenderMode.VERTICAL_SCROLL &&
|
||||
activeTtsChapterIndex != null &&
|
||||
activeTtsChapterIndex != currentChapterIndex
|
||||
) {
|
||||
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
|
||||
"Vertical highlight skipped because visible chapter differs from active TTS chapter. " +
|
||||
"visibleChapter=$currentChapterIndex activeTtsChapter=$activeTtsChapterIndex " +
|
||||
"cfi=${cfi?.take(48)} offset=$offset"
|
||||
)
|
||||
webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) {
|
||||
val escapedText = escapeJsString(text)
|
||||
val escapedCfi = escapeJsString(cfi)
|
||||
val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);"
|
||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
|
||||
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
|
||||
"Applying vertical TTS highlight. visibleChapter=$currentChapterIndex " +
|
||||
"activeTtsChapter=$activeTtsChapterIndex cfi=${cfi.take(48)} " +
|
||||
"offset=$offset textLen=${text.length}"
|
||||
)
|
||||
}
|
||||
webViewRef?.evaluateJavascript(jsCommand, null)
|
||||
} else {
|
||||
if (!ttsState.isPlaying && !ttsState.isLoading) {
|
||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
|
||||
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
|
||||
"Removing vertical TTS highlight because playback is idle. " +
|
||||
"visibleChapter=$currentChapterIndex activeTtsChapter=$activeTtsChapterIndex"
|
||||
)
|
||||
}
|
||||
webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null)
|
||||
}
|
||||
}
|
||||
|
|
@ -298,6 +327,7 @@ private fun handleVerticalAutoAdvance(
|
|||
bookTitle = epubBookTitle,
|
||||
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
|
||||
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
|
||||
chapterIndex = currentTtsChapterIndex,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -327,6 +357,7 @@ private fun handleVerticalAutoAdvance(
|
|||
bookTitle = epubBookTitle,
|
||||
chapterTitle = chapters.getOrNull(nextIdx)?.title,
|
||||
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
|
||||
chapterIndex = nextIdx,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -401,6 +432,7 @@ private fun handlePaginatedAutoAdvance(
|
|||
bookTitle = epubBookTitle,
|
||||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
chapterIndex = chapterToTry,
|
||||
ttsMode = ttsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -421,4 +453,4 @@ private fun handlePaginatedAutoAdvance(
|
|||
} else {
|
||||
onUpdateTtsChapter(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,11 @@ object ExternalDictionaryHelper {
|
|||
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
|
||||
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
|
||||
setPackage(packageName)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
// Only add NEW_TASK if we don't have an Activity context,
|
||||
// preventing task switch animations for NoDisplay apps like Notification Dictionary
|
||||
if (context.getActivity() == null) {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
|
||||
if (processTextIntent.resolveActivity(pm) != null) {
|
||||
|
|
@ -148,7 +152,9 @@ object ExternalDictionaryHelper {
|
|||
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
|
||||
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
|
||||
setPackage(packageName)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (context.getActivity() == null) {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
if (translateIntent.resolveActivity(pm) != null) {
|
||||
context.startActivity(translateIntent)
|
||||
|
|
@ -162,7 +168,9 @@ object ExternalDictionaryHelper {
|
|||
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
|
||||
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
|
||||
setPackage(packageName)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (context.getActivity() == null) {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
|
||||
if (processTextIntent.resolveActivity(pm) != null) {
|
||||
|
|
@ -281,4 +289,15 @@ object ExternalDictionaryHelper {
|
|||
|
||||
return apps.sortedBy { it.label }
|
||||
}
|
||||
|
||||
private fun Context.getActivity(): android.app.Activity? {
|
||||
var currentContext = this
|
||||
while (currentContext is android.content.ContextWrapper) {
|
||||
if (currentContext is android.app.Activity) {
|
||||
return currentContext
|
||||
}
|
||||
currentContext = currentContext.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.aryan.reader.ml
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.RectF
|
||||
|
||||
data class SpeechBubble(
|
||||
val bounds: RectF,
|
||||
val maskBitmap: Bitmap? = null
|
||||
)
|
||||
|
||||
interface ISpeechBubbleDetector : AutoCloseable {
|
||||
fun detectBubbles(bitmap: Bitmap, confidenceThreshold: Float = 0.1f): List<SpeechBubble>
|
||||
}
|
||||
|
|
@ -119,7 +119,8 @@ class BookPaginator(
|
|||
private val context: Context,
|
||||
private val mathMLRenderer: MathMLRenderer,
|
||||
private val userTextAlign: TextAlign?,
|
||||
private val paragraphGapMultiplier: Float
|
||||
private val paragraphGapMultiplier: Float,
|
||||
private val imageSizeMultiplier: Float
|
||||
) : IPaginator {
|
||||
override var totalPageCount by mutableIntStateOf(0)
|
||||
private set
|
||||
|
|
@ -267,7 +268,16 @@ class BookPaginator(
|
|||
}
|
||||
|
||||
private fun generateConfigurationHash(): Int {
|
||||
val configString = "w:${constraints.maxWidth}-h:${constraints.maxHeight}-fs:${textStyle.fontSize.value}-ta:$userTextAlign-pg:$paragraphGapMultiplier"
|
||||
val configString = buildString {
|
||||
append("w:${constraints.maxWidth}")
|
||||
append("-h:${constraints.maxHeight}")
|
||||
append("-fs:${textStyle.fontSize.value}")
|
||||
append("-lh:${textStyle.lineHeight.value}")
|
||||
append("-ff:${textStyle.fontFamily}")
|
||||
append("-ta:$userTextAlign")
|
||||
append("-pg:$paragraphGapMultiplier")
|
||||
append("-img:$imageSizeMultiplier")
|
||||
}
|
||||
val hash = configString.hashCode()
|
||||
return hash
|
||||
}
|
||||
|
|
@ -722,7 +732,8 @@ class BookPaginator(
|
|||
textMeasurer = textMeasurer,
|
||||
constraints = constraints,
|
||||
textStyle = textStyle,
|
||||
density = density
|
||||
density = density,
|
||||
imageSizeMultiplier = imageSizeMultiplier
|
||||
)
|
||||
Timber.d("paginateChapter: Calling PaginatorLogic for chapter $chapterIndex.")
|
||||
val pages = paginate(
|
||||
|
|
@ -971,31 +982,7 @@ class BookPaginator(
|
|||
return@launch
|
||||
}
|
||||
|
||||
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
|
||||
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
|
||||
|
||||
if (chapterPages == null) {
|
||||
Timber.e("Href Navigation failed: Could not paginate target chapter $targetChapterIndex.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
var targetPageInChapter = 0
|
||||
if (anchor != null) {
|
||||
Timber.d("Searching for anchor '$anchor' in chapter $targetChapterIndex.")
|
||||
pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||
for (block in page.content) {
|
||||
if (block.elementId == anchor) {
|
||||
targetPageInChapter = pageIndex
|
||||
Timber.i("Found anchor '$anchor' on page $pageIndex in chapter $targetChapterIndex")
|
||||
break@pageLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val finalPageIndex = chapterStartPage + targetPageInChapter
|
||||
Timber.i("Href navigation complete. Final page index: $finalPageIndex")
|
||||
withContext(Dispatchers.Main) { onNavigationComplete(finalPageIndex) }
|
||||
findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1304,4 +1291,4 @@ class BookPaginator(
|
|||
return null to null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -144,7 +144,8 @@ class PaginatedReaderViewModel : ViewModel() {
|
|||
context = context.applicationContext,
|
||||
mathMLRenderer = mathMLRenderer,
|
||||
userTextAlign = null,
|
||||
paragraphGapMultiplier = paragraphGapMultiplier
|
||||
paragraphGapMultiplier = paragraphGapMultiplier,
|
||||
imageSizeMultiplier = 1.0f
|
||||
)
|
||||
paginator = newPaginator
|
||||
|
||||
|
|
@ -174,4 +175,4 @@ class PaginatedReaderViewModel : ViewModel() {
|
|||
fun onLinkClick(currentChapterPath: String, href: String, onNavigationComplete: (Int) -> Unit) {
|
||||
paginator?.navigateToHref(currentChapterPath, href, onNavigationComplete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ class SuspendingAndroidBlockMeasurementProvider(
|
|||
private val textMeasurer: TextMeasurer,
|
||||
private val constraints: Constraints,
|
||||
private val textStyle: TextStyle,
|
||||
private val density: Density
|
||||
private val density: Density,
|
||||
private val imageSizeMultiplier: Float
|
||||
) : BlockMeasurementProvider {
|
||||
|
||||
override suspend fun measure(block: ContentBlock): Int {
|
||||
|
|
@ -60,7 +61,8 @@ class SuspendingAndroidBlockMeasurementProvider(
|
|||
constraints = constraints,
|
||||
defaultStyle = textStyle,
|
||||
headerStyle = textStyle.copy(fontWeight = FontWeight.Bold),
|
||||
density = density
|
||||
density = density,
|
||||
imageSizeMultiplier = imageSizeMultiplier
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -79,29 +81,12 @@ class SuspendingAndroidBlockMeasurementProvider(
|
|||
|
||||
val imageBlock = block.floatedImage
|
||||
val (imageWidthPx, imageHeightPx) = run {
|
||||
val imageStyle = imageBlock.style
|
||||
val intrinsicWidth = imageBlock.intrinsicWidth
|
||||
val intrinsicHeight = imageBlock.intrinsicHeight
|
||||
|
||||
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) {
|
||||
0f to 0f
|
||||
} else {
|
||||
val aspectRatio = intrinsicHeight / intrinsicWidth
|
||||
val renderWidth = with(density) {
|
||||
var w = intrinsicWidth
|
||||
|
||||
if (imageStyle.width != Dp.Unspecified) {
|
||||
w = imageStyle.width.toPx()
|
||||
}
|
||||
|
||||
if (imageStyle.maxWidth != Dp.Unspecified) {
|
||||
w = w.coerceAtMost(imageStyle.maxWidth.toPx())
|
||||
}
|
||||
|
||||
w.coerceAtMost(constraints.maxWidth.toFloat())
|
||||
}
|
||||
renderWidth to (renderWidth * aspectRatio)
|
||||
}
|
||||
measureScaledImageSizePx(
|
||||
block = imageBlock,
|
||||
density = density,
|
||||
maxWidthPx = constraints.maxWidth.toFloat(),
|
||||
imageSizeMultiplier = imageSizeMultiplier
|
||||
)
|
||||
}
|
||||
|
||||
if (imageWidthPx <= 0 || imageHeightPx <= 0) {
|
||||
|
|
@ -668,62 +653,25 @@ private suspend fun measureBlockHeight(
|
|||
constraints: Constraints,
|
||||
defaultStyle: TextStyle,
|
||||
headerStyle: TextStyle,
|
||||
density: Density
|
||||
density: Density,
|
||||
imageSizeMultiplier: Float = 1.0f
|
||||
): Int {
|
||||
var verticalPaddingPx = 0f
|
||||
var horizontalPaddingPx = 0f
|
||||
var verticalBorderPx = 0f
|
||||
var horizontalBorderPx = 0f
|
||||
|
||||
with(density) {
|
||||
verticalPaddingPx = block.style.padding.top.toPx() + block.style.padding.bottom.toPx()
|
||||
horizontalPaddingPx = block.style.padding.left.toPx() + block.style.padding.right.toPx()
|
||||
|
||||
verticalBorderPx = (block.style.borderTop?.width?.toPx() ?: 0f) + (block.style.borderBottom?.width?.toPx() ?: 0f)
|
||||
horizontalBorderPx = (block.style.borderLeft?.width?.toPx() ?: 0f) + (block.style.borderRight?.width?.toPx() ?: 0f)
|
||||
}
|
||||
|
||||
val isBorderBox = block.style.boxSizing == "border-box"
|
||||
val specifiedWidthDp = block.style.width
|
||||
val specifiedMaxWidthDp = block.style.maxWidth
|
||||
|
||||
val blockOuterWidthPx = with(density) {
|
||||
var effectiveWidthPx = constraints.maxWidth.toFloat()
|
||||
if (specifiedWidthDp != Dp.Unspecified) {
|
||||
effectiveWidthPx = specifiedWidthDp.toPx()
|
||||
}
|
||||
if (specifiedMaxWidthDp != Dp.Unspecified) {
|
||||
val maxWidthPx = specifiedMaxWidthDp.toPx()
|
||||
if (effectiveWidthPx > maxWidthPx) {
|
||||
effectiveWidthPx = maxWidthPx
|
||||
}
|
||||
}
|
||||
effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat())
|
||||
}
|
||||
|
||||
val contentMaxWidth = if (specifiedWidthDp == Dp.Unspecified) {
|
||||
(blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx)
|
||||
} else if (isBorderBox) {
|
||||
(blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx)
|
||||
} else {
|
||||
blockOuterWidthPx
|
||||
}
|
||||
|
||||
val adjustedConstraints = constraints.copy(
|
||||
maxWidth = contentMaxWidth.roundToInt().coerceAtLeast(0),
|
||||
maxHeight = Constraints.Infinity
|
||||
)
|
||||
val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
|
||||
val verticalPaddingPx = boxMetrics.verticalPaddingPx
|
||||
val verticalBorderPx = boxMetrics.verticalBorderPx
|
||||
val adjustedConstraints = boxMetrics.contentConstraints
|
||||
|
||||
val contentHeight = when (block) {
|
||||
is ParagraphBlock -> {
|
||||
val paragraphStyle = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign)
|
||||
val height = withContext(Dispatchers.Main) {
|
||||
textMeasurer.measure(
|
||||
text = block.content,
|
||||
style = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign),
|
||||
style = paragraphStyle,
|
||||
constraints = adjustedConstraints
|
||||
).size.height
|
||||
}
|
||||
height
|
||||
height + centeredTextSafetyPaddingPx(paragraphStyle, density)
|
||||
}
|
||||
is HeaderBlock -> {
|
||||
val style = headerStyle.copy(
|
||||
|
|
@ -736,27 +684,15 @@ private suspend fun measureBlockHeight(
|
|||
constraints = adjustedConstraints
|
||||
).size.height
|
||||
}
|
||||
height
|
||||
height + centeredTextSafetyPaddingPx(style, density)
|
||||
}
|
||||
is ImageBlock -> {
|
||||
val imageIntrinsicWidth = block.intrinsicWidth
|
||||
val imageIntrinsicHeight = block.intrinsicHeight
|
||||
|
||||
val styledHeightPx = if (block.style.height.isSpecified) with(density) { block.style.height.toPx() } else null
|
||||
val styledWidthPx = if (block.style.width.isSpecified) with(density) { block.style.width.toPx() } else null
|
||||
|
||||
val measuredHeight = when {
|
||||
styledHeightPx != null && styledHeightPx > 0f -> styledHeightPx
|
||||
styledWidthPx != null && styledWidthPx > 0f && imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
|
||||
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
|
||||
styledWidthPx * aspectRatio
|
||||
}
|
||||
imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
|
||||
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
|
||||
contentMaxWidth * aspectRatio
|
||||
}
|
||||
else -> with(density) { 250.dp.toPx() }
|
||||
}
|
||||
val measuredHeight = measureScaledImageHeightPx(
|
||||
block = block,
|
||||
density = density,
|
||||
contentMaxWidth = adjustedConstraints.maxWidth.toFloat(),
|
||||
imageSizeMultiplier = imageSizeMultiplier
|
||||
) ?: with(density) { 250.dp.toPx() }
|
||||
|
||||
val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt()
|
||||
Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})")
|
||||
|
|
@ -767,14 +703,15 @@ private suspend fun measureBlockHeight(
|
|||
height
|
||||
}
|
||||
is QuoteBlock -> {
|
||||
val quoteStyle = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign)
|
||||
val height = withContext(Dispatchers.Main) {
|
||||
textMeasurer.measure(
|
||||
text = block.content,
|
||||
style = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign),
|
||||
style = quoteStyle,
|
||||
constraints = adjustedConstraints
|
||||
).size.height
|
||||
}
|
||||
height
|
||||
height + centeredTextSafetyPaddingPx(quoteStyle, density)
|
||||
}
|
||||
is ListItemBlock -> {
|
||||
val markerWidthPx = with(density) { 32.dp.toPx() }.toInt()
|
||||
|
|
@ -811,7 +748,7 @@ private suspend fun measureBlockHeight(
|
|||
|
||||
val cellConstraints = adjustedConstraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0))
|
||||
|
||||
val cellContentHeight = calculateContentHeightWithMargins(cell.content, textMeasurer, cellConstraints, defaultStyle, headerStyle, density)
|
||||
val cellContentHeight = calculateContentHeightWithMargins(cell.content, textMeasurer, cellConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
|
||||
|
||||
var cellDecorationHeight = 0f
|
||||
with(density) {
|
||||
|
|
@ -829,35 +766,18 @@ private suspend fun measureBlockHeight(
|
|||
val imageBlock = block.floatedImage
|
||||
|
||||
val (imageWidthPx, imageHeightPx) = run {
|
||||
val imageStyle = imageBlock.style
|
||||
val intrinsicWidth = imageBlock.intrinsicWidth
|
||||
val intrinsicHeight = imageBlock.intrinsicHeight
|
||||
|
||||
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) {
|
||||
0f to 0f
|
||||
} else {
|
||||
val aspectRatio = intrinsicHeight / intrinsicWidth
|
||||
val renderWidth = with(density) {
|
||||
var w = intrinsicWidth
|
||||
|
||||
if (imageStyle.width != Dp.Unspecified) {
|
||||
w = imageStyle.width.toPx()
|
||||
}
|
||||
|
||||
if (imageStyle.maxWidth != Dp.Unspecified) {
|
||||
w = w.coerceAtMost(imageStyle.maxWidth.toPx())
|
||||
}
|
||||
|
||||
w.coerceAtMost(adjustedConstraints.maxWidth.toFloat())
|
||||
}
|
||||
renderWidth to (renderWidth * aspectRatio)
|
||||
}
|
||||
measureScaledImageSizePx(
|
||||
block = imageBlock,
|
||||
density = density,
|
||||
maxWidthPx = adjustedConstraints.maxWidth.toFloat(),
|
||||
imageSizeMultiplier = imageSizeMultiplier
|
||||
)
|
||||
}
|
||||
|
||||
// If image has no size, it can't float. Just measure the paragraphs.
|
||||
if (imageWidthPx <= 0 || imageHeightPx <= 0) {
|
||||
val height = block.paragraphsToWrap.sumOf { p ->
|
||||
measureBlockHeight(p, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density)
|
||||
measureBlockHeight(p, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
|
@ -949,10 +869,10 @@ private suspend fun measureBlockHeight(
|
|||
val isRow = block.style.flexDirection == "row"
|
||||
val height = if (isRow) {
|
||||
block.children.maxOfOrNull { child ->
|
||||
measureBlockHeight(child, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density)
|
||||
measureBlockHeight(child, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
|
||||
} ?: 0
|
||||
} else {
|
||||
calculateContentHeightWithMargins(block.children, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density)
|
||||
calculateContentHeightWithMargins(block.children, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
|
||||
}
|
||||
height
|
||||
}
|
||||
|
|
@ -980,7 +900,7 @@ private suspend fun measureBlockHeight(
|
|||
}
|
||||
}
|
||||
val specifiedHeightDp = block.style.height
|
||||
val finalHeight = if (isBorderBox && specifiedHeightDp != Dp.Unspecified) {
|
||||
val finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) {
|
||||
with(density) { specifiedHeightDp.toPx().roundToInt() }
|
||||
} else {
|
||||
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
|
||||
|
|
@ -1000,6 +920,10 @@ private suspend fun splitParagraphBlock(
|
|||
): Pair<ParagraphBlock, ParagraphBlock>? {
|
||||
val text = block.content
|
||||
if (text.isEmpty()) return null
|
||||
val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
|
||||
val paragraphConstraints = boxMetrics.contentConstraints
|
||||
val paragraphStyle = textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign)
|
||||
val centeredSafetyPaddingPx = centeredTextSafetyPaddingPx(paragraphStyle, density)
|
||||
|
||||
val decorationTop = with(density) {
|
||||
block.style.padding.top.toPx() + (block.style.borderTop?.width?.toPx() ?: 0f)
|
||||
|
|
@ -1009,7 +933,7 @@ private suspend fun splitParagraphBlock(
|
|||
block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f)
|
||||
}.roundToInt()
|
||||
|
||||
val availableTextHeight = availableHeight - decorationTop - decorationBottom
|
||||
val availableTextHeight = availableHeight - decorationTop - decorationBottom - centeredSafetyPaddingPx
|
||||
|
||||
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
|
||||
|
||||
|
|
@ -1021,8 +945,8 @@ private suspend fun splitParagraphBlock(
|
|||
val layoutResult = withContext(Dispatchers.Main) {
|
||||
textMeasurer.measure(
|
||||
text = text,
|
||||
style = textStyle,
|
||||
constraints = constraints.copy(maxHeight = Constraints.Infinity)
|
||||
style = paragraphStyle,
|
||||
constraints = paragraphConstraints
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1036,7 +960,7 @@ private suspend fun splitParagraphBlock(
|
|||
|
||||
var lastVisibleLine = layoutResult.getLineForVerticalPosition(availableTextHeight.toFloat())
|
||||
|
||||
if (layoutResult.getLineBottom(lastVisibleLine) > availableHeight.toFloat()) {
|
||||
if (layoutResult.getLineBottom(lastVisibleLine) > availableTextHeight.toFloat()) {
|
||||
lastVisibleLine--
|
||||
}
|
||||
|
||||
|
|
@ -1056,7 +980,8 @@ private suspend fun splitParagraphBlock(
|
|||
val part2Layout = withContext(Dispatchers.Main) {
|
||||
textMeasurer.measure(
|
||||
text = part2CheckText,
|
||||
constraints = constraints
|
||||
style = paragraphStyle,
|
||||
constraints = paragraphConstraints
|
||||
)
|
||||
}
|
||||
if (part2Layout.lineCount == 1) {
|
||||
|
|
@ -1149,11 +1074,12 @@ private suspend fun calculateContentHeightWithMargins(
|
|||
constraints: Constraints,
|
||||
defaultStyle: TextStyle,
|
||||
headerStyle: TextStyle,
|
||||
density: Density
|
||||
density: Density,
|
||||
imageSizeMultiplier: Float = 1.0f
|
||||
): Int {
|
||||
var totalHeight = 0
|
||||
children.forEachIndexed { index, child ->
|
||||
val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density)
|
||||
val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
|
||||
val margin = with(density) {
|
||||
if (index > 0) {
|
||||
val prevMargin = children[index - 1].style.margin.bottom.toPx()
|
||||
|
|
@ -1172,6 +1098,117 @@ private suspend fun calculateContentHeightWithMargins(
|
|||
return totalHeight
|
||||
}
|
||||
|
||||
private data class BlockBoxMetrics(
|
||||
val verticalPaddingPx: Float,
|
||||
val verticalBorderPx: Float,
|
||||
val contentConstraints: Constraints
|
||||
)
|
||||
|
||||
private fun computeBlockBoxMetrics(
|
||||
block: ContentBlock,
|
||||
constraints: Constraints,
|
||||
density: Density
|
||||
): BlockBoxMetrics {
|
||||
val verticalPaddingPx: Float
|
||||
val horizontalPaddingPx: Float
|
||||
val verticalBorderPx: Float
|
||||
val horizontalBorderPx: Float
|
||||
|
||||
with(density) {
|
||||
verticalPaddingPx = block.style.padding.top.toPx() + block.style.padding.bottom.toPx()
|
||||
horizontalPaddingPx = block.style.padding.left.toPx() + block.style.padding.right.toPx()
|
||||
verticalBorderPx = (block.style.borderTop?.width?.toPx() ?: 0f) + (block.style.borderBottom?.width?.toPx() ?: 0f)
|
||||
horizontalBorderPx = (block.style.borderLeft?.width?.toPx() ?: 0f) + (block.style.borderRight?.width?.toPx() ?: 0f)
|
||||
}
|
||||
|
||||
val isBorderBox = block.style.boxSizing == "border-box"
|
||||
val specifiedWidthDp = block.style.width
|
||||
val specifiedMaxWidthDp = block.style.maxWidth
|
||||
|
||||
val blockOuterWidthPx = with(density) {
|
||||
var effectiveWidthPx = constraints.maxWidth.toFloat()
|
||||
if (specifiedWidthDp != Dp.Unspecified) {
|
||||
effectiveWidthPx = specifiedWidthDp.toPx()
|
||||
}
|
||||
if (specifiedMaxWidthDp != Dp.Unspecified) {
|
||||
val maxWidthPx = specifiedMaxWidthDp.toPx()
|
||||
if (effectiveWidthPx > maxWidthPx) {
|
||||
effectiveWidthPx = maxWidthPx
|
||||
}
|
||||
}
|
||||
effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat())
|
||||
}
|
||||
|
||||
val contentMaxWidth = if (specifiedWidthDp == Dp.Unspecified || isBorderBox) {
|
||||
blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx
|
||||
} else {
|
||||
blockOuterWidthPx
|
||||
}
|
||||
|
||||
return BlockBoxMetrics(
|
||||
verticalPaddingPx = verticalPaddingPx,
|
||||
verticalBorderPx = verticalBorderPx,
|
||||
contentConstraints = constraints.copy(
|
||||
maxWidth = contentMaxWidth.roundToInt().coerceAtLeast(0),
|
||||
maxHeight = Constraints.Infinity
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun centeredTextSafetyPaddingPx(
|
||||
style: TextStyle,
|
||||
density: Density
|
||||
): Int {
|
||||
if (style.textAlign != androidx.compose.ui.text.style.TextAlign.Center) return 0
|
||||
|
||||
val fallbackLineHeight = if (style.fontSize.isSpecified) {
|
||||
style.fontSize * 1.2f
|
||||
} else {
|
||||
16.sp * 1.2f
|
||||
}
|
||||
val effectiveLineHeight = if (style.lineHeight.isSpecified) style.lineHeight else fallbackLineHeight
|
||||
|
||||
return with(density) { effectiveLineHeight.toPx().roundToInt() }
|
||||
}
|
||||
|
||||
private fun measureScaledImageHeightPx(
|
||||
block: ImageBlock,
|
||||
density: Density,
|
||||
contentMaxWidth: Float,
|
||||
imageSizeMultiplier: Float
|
||||
): Float? = measureScaledImageSizePx(
|
||||
block = block,
|
||||
density = density,
|
||||
maxWidthPx = contentMaxWidth,
|
||||
imageSizeMultiplier = imageSizeMultiplier
|
||||
).second.takeIf { it > 0f }
|
||||
|
||||
private fun measureScaledImageSizePx(
|
||||
block: ImageBlock,
|
||||
density: Density,
|
||||
maxWidthPx: Float,
|
||||
imageSizeMultiplier: Float
|
||||
): Pair<Float, Float> {
|
||||
val intrinsicWidth = block.intrinsicWidth
|
||||
val intrinsicHeight = block.intrinsicHeight
|
||||
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f || intrinsicHeight <= 0f) {
|
||||
return 0f to 0f
|
||||
}
|
||||
|
||||
val aspectRatio = intrinsicHeight / intrinsicWidth
|
||||
val baseWidth = with(density) {
|
||||
if (block.style.width.isSpecified) block.style.width.toPx() else maxWidthPx
|
||||
}
|
||||
|
||||
var scaledWidth = baseWidth * imageSizeMultiplier
|
||||
if (block.style.maxWidth.isSpecified) {
|
||||
scaledWidth = scaledWidth.coerceAtMost(with(density) { block.style.maxWidth.toPx() } * imageSizeMultiplier)
|
||||
}
|
||||
scaledWidth = scaledWidth.coerceAtMost(maxWidthPx)
|
||||
|
||||
return scaledWidth to (scaledWidth * aspectRatio)
|
||||
}
|
||||
|
||||
private fun zeroOutBottomMargin(blocks: MutableList<ContentBlock>) {
|
||||
if (blocks.isNotEmpty()) {
|
||||
val lastBlock = blocks.last()
|
||||
|
|
@ -1180,4 +1217,4 @@ private fun zeroOutBottomMargin(blocks: MutableList<ContentBlock>) {
|
|||
setBlockExpectedHeight(copyBlockWithNewStyle(lastBlock, newLastStyle), lastBlock.expectedHeight)
|
||||
blocks[blocks.size - 1] = newLastBlock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ abstract class BookCacheDao {
|
|||
ConfigurationCache::class,
|
||||
AnchorIndexEntry::class
|
||||
],
|
||||
version = 7,
|
||||
version = 8,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class BookCacheDatabase : RoomDatabase() {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import androidx.room.ForeignKey
|
|||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
const val LATEST_PROCESSING_VERSION = 7
|
||||
const val LATEST_PROCESSING_VERSION = 8
|
||||
|
||||
@Entity(tableName = "processed_books")
|
||||
data class ProcessedBook(
|
||||
|
|
|
|||
|
|
@ -26,10 +26,8 @@ import com.aryan.reader.pdf.data.PdfAnnotation
|
|||
|
||||
object DemoAnnotationGenerator {
|
||||
|
||||
// --- SVG Configuration ---
|
||||
private const val SVG_WIDTH = 800f
|
||||
|
||||
// Extracted from your Figma SVG
|
||||
private val DECORATIVE_DOTS = listOf(
|
||||
DotData(120f, 90f, 5f, Color(0xFFF59E0B), 0.7f),
|
||||
DotData(680f, 210f, 6f, Color(0xFFEC4899), 0.7f),
|
||||
|
|
@ -72,47 +70,36 @@ object DemoAnnotationGenerator {
|
|||
fun generateDemoAnnotations(pageIndex: Int): List<PdfAnnotation> {
|
||||
val annotations = mutableListOf<PdfAnnotation>()
|
||||
|
||||
// --- Layout Calculation ---
|
||||
// We want the SVG to occupy 80% of the page width, centered.
|
||||
// PDF coordinates are 0..1.
|
||||
val targetWidthPercent = 0.8f
|
||||
// SVG aspect ratio 300 / 800 = 0.375
|
||||
|
||||
// Calculate scale factor relative to normalized page coordinates
|
||||
val scaleX = targetWidthPercent / SVG_WIDTH
|
||||
val scaleY = scaleX // Keep uniform scale in abstract space
|
||||
|
||||
// Center offsets (0.5 is middle of page)
|
||||
val startX = (1f - targetWidthPercent) / 2f
|
||||
val startY = 0.4f // Position slightly above center vertically
|
||||
val startY = 0.2f
|
||||
|
||||
var currentTime = System.currentTimeMillis()
|
||||
|
||||
// Helper to transform SVG points to PDF Page Points
|
||||
fun transformPoint(x: Float, y: Float): PdfPoint {
|
||||
val pdfX = startX + (x * scaleX)
|
||||
val pdfY = startY + (y * scaleY)
|
||||
val pdfY = startY + (y * scaleX)
|
||||
return PdfPoint(pdfX, pdfY, currentTime)
|
||||
}
|
||||
|
||||
// 1. Render Decorative Dots
|
||||
DECORATIVE_DOTS.forEach { dot ->
|
||||
val pdfPoint = transformPoint(dot.cx, dot.cy)
|
||||
|
||||
// To make a "Dot" with the pen, we need at least 2 points very close together
|
||||
// or a single point might not render depending on the implementation.
|
||||
val points = listOf(
|
||||
pdfPoint,
|
||||
pdfPoint.copy(x = pdfPoint.x + 0.0001f, timestamp = currentTime + 10)
|
||||
)
|
||||
|
||||
// Convert SVG radius to stroke width
|
||||
val relativeThickness = (dot.r / SVG_WIDTH) * 2.5f
|
||||
|
||||
annotations.add(
|
||||
PdfAnnotation(
|
||||
type = AnnotationType.INK,
|
||||
inkType = InkType.PEN, // Standard pen for dots
|
||||
inkType = InkType.PEN,
|
||||
pageIndex = pageIndex,
|
||||
points = points,
|
||||
color = dot.color.copy(alpha = dot.alpha),
|
||||
|
|
@ -122,7 +109,6 @@ object DemoAnnotationGenerator {
|
|||
currentTime += 50
|
||||
}
|
||||
|
||||
// 2. Render Text ("Try Episteme!")
|
||||
val textPaths = splitSvgPaths(TEXT_STROKES_DATA)
|
||||
textPaths.forEach { pathString ->
|
||||
val path = PathParser.createPathFromPathData(pathString)
|
||||
|
|
@ -130,7 +116,6 @@ object DemoAnnotationGenerator {
|
|||
|
||||
if (flattenedPoints.isNotEmpty()) {
|
||||
val pdfPoints = flattenedPoints.mapIndexed { _, p ->
|
||||
// Increment time to simulate drawing speed for Fountain Pen physics
|
||||
currentTime += 8
|
||||
transformPoint(p.x, p.y).copy(timestamp = currentTime)
|
||||
}
|
||||
|
|
@ -138,18 +123,17 @@ object DemoAnnotationGenerator {
|
|||
annotations.add(
|
||||
PdfAnnotation(
|
||||
type = AnnotationType.INK,
|
||||
inkType = InkType.FOUNTAIN_PEN, // Handwriting looks best with this
|
||||
inkType = InkType.FOUNTAIN_PEN,
|
||||
pageIndex = pageIndex,
|
||||
points = pdfPoints,
|
||||
color = Color(0xFF418377), // Updated Green
|
||||
strokeWidth = 0.004f // Fine tip
|
||||
color = Color(0xFF418377),
|
||||
strokeWidth = 0.004f
|
||||
)
|
||||
)
|
||||
currentTime += 150 // Pen lift delay
|
||||
currentTime += 150
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Render Underline
|
||||
val underlinePath = PathParser.createPathFromPathData(UNDERLINE_DATA)
|
||||
val underlinePointsRaw = flattenPath(underlinePath)
|
||||
val underlinePdfPoints = underlinePointsRaw.map { p ->
|
||||
|
|
@ -160,10 +144,10 @@ object DemoAnnotationGenerator {
|
|||
annotations.add(
|
||||
PdfAnnotation(
|
||||
type = AnnotationType.INK,
|
||||
inkType = InkType.PEN, // Consistent width for underline
|
||||
inkType = InkType.PEN,
|
||||
pageIndex = pageIndex,
|
||||
points = underlinePdfPoints,
|
||||
color = Color(0xFFEC4899).copy(alpha = 0.6f), // Pink
|
||||
color = Color(0xFFEC4899).copy(alpha = 0.6f),
|
||||
strokeWidth = 0.005f
|
||||
)
|
||||
)
|
||||
|
|
@ -171,8 +155,6 @@ object DemoAnnotationGenerator {
|
|||
return annotations
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private data class DotData(val cx: Float, val cy: Float, val r: Float, val color: Color, val alpha: Float)
|
||||
private data class PointF(val x: Float, val y: Float)
|
||||
|
||||
|
|
@ -180,11 +162,9 @@ object DemoAnnotationGenerator {
|
|||
* Android's Path doesn't give us points directly. We use approximate().
|
||||
*/
|
||||
private fun flattenPath(path: Path): List<PointF> {
|
||||
// Approximate the path with error tolerance 0.5 (pixels in SVG space)
|
||||
val approximation = path.approximate(0.5f)
|
||||
val points = mutableListOf<PointF>()
|
||||
|
||||
// approximation array format: [t0, x0, y0, t1, x1, y1, ...]
|
||||
var i = 0
|
||||
while (i < approximation.size) {
|
||||
val x = approximation[i + 1]
|
||||
|
|
@ -204,15 +184,12 @@ object DemoAnnotationGenerator {
|
|||
val result = mutableListOf<String>()
|
||||
|
||||
rawPaths.forEach { fullPathString ->
|
||||
// Clean up and standardize
|
||||
val cleanStr = fullPathString.trim()
|
||||
|
||||
// Split by "M" (Move command).
|
||||
val parts = cleanStr.split("M")
|
||||
|
||||
parts.forEach { part ->
|
||||
if (part.isNotBlank()) {
|
||||
// Re-prepend M because split removed it
|
||||
result.add("M ${part.trim()}")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,24 @@
|
|||
// PdfNavigationDrawerContent.kt
|
||||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
|
|
@ -13,29 +28,260 @@ import androidx.compose.foundation.pager.rememberPagerState
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.legere.pdfiumandroid.api.Bookmark
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
import androidx.core.graphics.createBitmap
|
||||
|
||||
private const val MAX_FIXED_RECURSION = 128
|
||||
|
||||
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
|
||||
|
||||
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
|
||||
|
||||
/**
|
||||
* Patches the library bug where siblings are truncated due to depth-state leakage.
|
||||
*/
|
||||
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
|
||||
val tag = "PdfTocFix"
|
||||
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
|
||||
|
||||
return try {
|
||||
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
|
||||
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
|
||||
val docUInstance = documentField.get(this) ?: return getTableOfContents()
|
||||
|
||||
// 2. Get the 'nativeDocument' field from PdfDocumentU
|
||||
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
|
||||
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
|
||||
|
||||
// 3. Get the native pointer (long) from PdfDocumentU
|
||||
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
|
||||
val mNativeDocPtr = ptrField.get(docUInstance) as Long
|
||||
|
||||
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
|
||||
val nClass = nativeDocInstance.javaClass
|
||||
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
|
||||
|
||||
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
|
||||
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
|
||||
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
|
||||
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
|
||||
|
||||
val topLevel = mutableListOf<Bookmark>()
|
||||
val visited = mutableSetOf<Long>()
|
||||
|
||||
/**
|
||||
* Corrected traversal: Iterative for siblings, recursive for children.
|
||||
*/
|
||||
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
|
||||
var currentPtr = startPtr
|
||||
var itemIndex = 0
|
||||
|
||||
while (currentPtr != 0L) {
|
||||
if (visited.contains(currentPtr)) break
|
||||
visited.add(currentPtr)
|
||||
|
||||
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
|
||||
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
|
||||
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
|
||||
|
||||
val bookmark = Bookmark().apply {
|
||||
this.mNativePtr = currentPtr
|
||||
this.title = title
|
||||
this.pageIdx = pageIdx
|
||||
}
|
||||
parentList.add(bookmark)
|
||||
|
||||
// Recursive dive into children
|
||||
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
|
||||
walk(bookmark.children, firstChild, level + 1)
|
||||
}
|
||||
|
||||
// Iterative move to next sibling
|
||||
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
itemIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Start from the root (Pass 0L as primitive long)
|
||||
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
|
||||
if (firstRoot != 0L) {
|
||||
walk(topLevel, firstRoot, 0)
|
||||
}
|
||||
|
||||
if (topLevel.isEmpty()) {
|
||||
Timber.tag(tag).w("No items found, falling back to library.")
|
||||
getTableOfContents()
|
||||
} else {
|
||||
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
|
||||
topLevel
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(tag).e(e, "Reflection traversal critical error.")
|
||||
this.getTableOfContents()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
|
||||
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
|
||||
val entries = mutableListOf<TocEntry>()
|
||||
for ((index, bookmark) in bookmarks.withIndex()) {
|
||||
val title = bookmark.title ?: "Untitled Chapter"
|
||||
val childCount = bookmark.children.size
|
||||
|
||||
Timber.tag("PdfTocDebug").d(
|
||||
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
|
||||
)
|
||||
|
||||
entries.add(
|
||||
TocEntry(
|
||||
title = title,
|
||||
pageIndex = bookmark.pageIdx.toInt(),
|
||||
nestLevel = level
|
||||
)
|
||||
)
|
||||
|
||||
if (childCount > 0) {
|
||||
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
|
||||
entries.addAll(flattenToc(bookmark.children, level + 1))
|
||||
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
|
||||
if (bookmarksJson.isNullOrBlank()) return emptySet()
|
||||
return try {
|
||||
val jsonArray = JSONArray(bookmarksJson)
|
||||
(0 until jsonArray.length()).mapNotNull { i ->
|
||||
try {
|
||||
val json = jsonArray.getJSONObject(i)
|
||||
PdfBookmark(
|
||||
pageIndex = json.getInt("pageIndex"),
|
||||
title = json.getString("title"),
|
||||
totalPages = json.getInt("totalPages")
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse bookmark from JSON object")
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson")
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PdfTocTreeItem(
|
||||
label: String,
|
||||
nestLevel: Int,
|
||||
isExpanded: Boolean,
|
||||
hasChildren: Boolean,
|
||||
isCurrent: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
|
||||
label = "TocItemBackground"
|
||||
)
|
||||
|
||||
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width((16 * nestLevel).dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clickable(enabled = hasChildren, onClick = onToggleExpand),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasChildren) {
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = contentColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(end = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun PdfNavigationDrawerContent(
|
||||
pdfDocument: ReaderDocument?,
|
||||
flatTableOfContents: List<TocEntry>,
|
||||
bookmarks: Set<PdfBookmark>,
|
||||
userHighlights: List<PdfUserHighlight>,
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
customHighlightColors: Map<PdfHighlightColor, Color>,
|
||||
onPageSelected: (Int) -> Unit,
|
||||
onRenameBookmark: (PdfBookmark, String) -> Unit,
|
||||
|
|
@ -44,11 +290,15 @@ internal fun PdfNavigationDrawerContent(
|
|||
onNoteRequested: (String?) -> Unit,
|
||||
onCloseDrawer: () -> Unit
|
||||
) {
|
||||
val drawerPagerState = rememberPagerState(pageCount = { 3 })
|
||||
val drawerPagerState = rememberPagerState(pageCount = { 4 })
|
||||
val drawerScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TabRow(selectedTabIndex = drawerPagerState.currentPage) {
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = drawerPagerState.currentPage,
|
||||
edgePadding = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Tab(selected = drawerPagerState.currentPage == 0, onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(0) }
|
||||
}, text = { Text("Chapters") })
|
||||
|
|
@ -68,6 +318,14 @@ internal fun PdfNavigationDrawerContent(
|
|||
text = { Text("Highlights") },
|
||||
modifier = Modifier.testTag("HighlightsTab")
|
||||
)
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 3,
|
||||
onClick = {
|
||||
drawerScope.launch { drawerPagerState.animateScrollToPage(3) }
|
||||
},
|
||||
text = { Text("Pages") },
|
||||
modifier = Modifier.testTag("PagesTab")
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
|
|
@ -531,6 +789,130 @@ internal fun PdfNavigationDrawerContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
3 -> { // Pages Page
|
||||
val listState = rememberLazyListState()
|
||||
val pageRows = remember(totalPages) { (0 until totalPages).chunked(3) }
|
||||
|
||||
val currentRowIndex = currentPage / 3
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
drawerScope.launch {
|
||||
if (currentRowIndex in pageRows.indices) {
|
||||
listState.animateScrollToItem(currentRowIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Locate")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(end = 12.dp)
|
||||
) {
|
||||
items(pageRows, key = { it.firstOrNull() ?: 0 }) { row ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
row.forEach { pageIdx ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(0.707f)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
RoundedCornerShape(4.dp)
|
||||
)
|
||||
.border(
|
||||
width = if (currentPage == pageIdx) 2.dp else 1.dp,
|
||||
color = if (currentPage == pageIdx) MaterialTheme.colorScheme.primary else Color.Black.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.clickable {
|
||||
onCloseDrawer()
|
||||
onPageSelected(pageIdx)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
var thumb by remember { mutableStateOf(PdfThumbnailCache.get(pageIdx)) }
|
||||
|
||||
LaunchedEffect(pageIdx, pdfDocument) {
|
||||
if (thumb == null && pdfDocument != null) {
|
||||
withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
val cached = PdfThumbnailCache.get(pageIdx)
|
||||
if (cached != null) {
|
||||
thumb = cached
|
||||
} else {
|
||||
pdfDocument.openPage(pageIdx)?.use { p ->
|
||||
val w = p.getPageWidthPoint()
|
||||
val h = p.getPageHeightPoint()
|
||||
val ratio = if (h > 0) w.toFloat() / h.toFloat() else 1f
|
||||
val thumbW = 200
|
||||
val thumbH = (thumbW / ratio).toInt().coerceAtLeast(1)
|
||||
val bmp = createBitmap(thumbW, thumbH)
|
||||
bmp.eraseColor(android.graphics.Color.WHITE)
|
||||
p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false)
|
||||
PdfThumbnailCache.put(pageIdx, bmp)
|
||||
thumb = bmp
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (thumb != null) {
|
||||
Image(
|
||||
bitmap = thumb!!.asImageBitmap(),
|
||||
contentDescription = "Page ${pageIdx + 1}",
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${pageIdx + 1}",
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(4.dp)
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
RoundedCornerShape(6.dp)
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
repeat(3 - row.size) { Spacer(modifier = Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
listState = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -239,15 +239,45 @@ internal fun BookmarkButton(
|
|||
}
|
||||
|
||||
@Composable
|
||||
internal fun ZoomPercentageIndicator(percentage: Int) {
|
||||
internal fun ZoomPercentageIndicator(
|
||||
percentage: Int,
|
||||
onResetZoomClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f)
|
||||
) {
|
||||
Text(
|
||||
text = "$percentage%",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
androidx.compose.foundation.layout.Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "$percentage%",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Divider
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(16.dp)
|
||||
.background(Color.White.copy(alpha = 0.5f))
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Reset Zoom Button
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.zoom_out),
|
||||
contentDescription = "Reset Zoom",
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onResetZoomClick)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import android.graphics.RectF
|
|||
import android.graphics.Shader
|
||||
import android.util.LruCache
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.ui.graphics.drawscope.withTransform
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
|
|
@ -83,6 +84,9 @@ import androidx.compose.ui.graphics.toArgb
|
|||
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
||||
import androidx.compose.ui.input.pointer.PointerType
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.isPrimaryPressed
|
||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||
import androidx.compose.ui.input.pointer.isTertiaryPressed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
|
|
@ -115,6 +119,7 @@ import androidx.core.graphics.scale
|
|||
import androidx.core.graphics.set
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.ml.SpeechBubble
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.pdf.data.PdfTextBox
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
|
|
@ -187,6 +192,78 @@ data class PageLink(
|
|||
val source: LinkSource
|
||||
)
|
||||
|
||||
private data class ExpandedBubbleRender(
|
||||
val bitmap: Bitmap,
|
||||
val zoomFactor: Float
|
||||
)
|
||||
|
||||
private fun computeDynamicBubbleZoomFactor(
|
||||
bubbleBounds: RectF,
|
||||
viewportWidth: Float,
|
||||
viewportHeight: Float
|
||||
): Float {
|
||||
if (bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) return 1.5f
|
||||
val targetWidth = viewportWidth * 0.6f
|
||||
val targetHeight = viewportHeight * 0.32f
|
||||
return min(targetWidth / bubbleBounds.width(), targetHeight / bubbleBounds.height())
|
||||
.coerceIn(1.35f, 4.25f)
|
||||
}
|
||||
|
||||
private fun isTapInsideBubble(
|
||||
bubble: SpeechBubble,
|
||||
tapX: Float,
|
||||
tapY: Float,
|
||||
hitSlopPx: Float
|
||||
): Boolean {
|
||||
val expandedBounds = RectF(bubble.bounds)
|
||||
expandedBounds.inset(-hitSlopPx, -hitSlopPx)
|
||||
if (!expandedBounds.contains(tapX, tapY)) return false
|
||||
|
||||
val mask = bubble.maskBitmap ?: return true
|
||||
if (!bubble.bounds.contains(tapX, tapY)) return true
|
||||
|
||||
val normalizedX = ((tapX - bubble.bounds.left) / bubble.bounds.width()).coerceIn(0f, 0.999f)
|
||||
val normalizedY = ((tapY - bubble.bounds.top) / bubble.bounds.height()).coerceIn(0f, 0.999f)
|
||||
val maskX = (normalizedX * mask.width).toInt().coerceIn(0, mask.width - 1)
|
||||
val maskY = (normalizedY * mask.height).toInt().coerceIn(0, mask.height - 1)
|
||||
return AndroidColor.alpha(mask.getPixel(maskX, maskY)) > 24
|
||||
}
|
||||
|
||||
private suspend fun renderExpandedBubbleBitmap(
|
||||
document: ReaderDocument,
|
||||
pageIndex: Int,
|
||||
bubbleBounds: RectF,
|
||||
pageWidth: Int,
|
||||
pageHeight: Int,
|
||||
renderScale: Float
|
||||
): Bitmap? = withContext(Dispatchers.IO) {
|
||||
if (pageWidth <= 0 || pageHeight <= 0 || bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val bitmap = createBitmap(cropWidth, cropHeight)
|
||||
|
||||
try {
|
||||
page.renderPageBitmap(
|
||||
bitmap = bitmap,
|
||||
startX = (-bubbleBounds.left * renderScale).roundToInt(),
|
||||
startY = (-bubbleBounds.top * renderScale).roundToInt(),
|
||||
drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth),
|
||||
drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight),
|
||||
renderAnnot = true
|
||||
)
|
||||
bitmap
|
||||
} catch (t: Throwable) {
|
||||
bitmap.recycle()
|
||||
Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object PdfInkGeometry {
|
||||
fun calculateFountainPenPoints(
|
||||
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
|
||||
|
|
@ -394,7 +471,8 @@ internal fun PdfPageComposable(
|
|||
searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
|
||||
searchResultToHighlight: SearchResult?,
|
||||
ocrHoverHighlights: StableHolder<List<RectF>> = StableHolder(emptyList()),
|
||||
onSingleTap: () -> Unit,
|
||||
onPreSingleTap: ((Offset) -> Boolean)? = null,
|
||||
onSingleTap: (Offset?) -> Unit,
|
||||
isProUser: Boolean,
|
||||
onShowDictionaryUpsellDialog: () -> Unit,
|
||||
onWordSelectedForAiDefinition: (String) -> Unit,
|
||||
|
|
@ -415,6 +493,7 @@ internal fun PdfPageComposable(
|
|||
isVerticalScroll: Boolean = false,
|
||||
visualScaleProvider: () -> Float = { 1f },
|
||||
clearSelectionTrigger: Long = 0L,
|
||||
resetZoomTrigger: Long = 0L,
|
||||
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
||||
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
||||
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||
|
|
@ -423,8 +502,8 @@ internal fun PdfPageComposable(
|
|||
isEditMode: Boolean = false,
|
||||
drawingState: PdfDrawingState? = null,
|
||||
pageAnnotations: () -> List<PdfAnnotation> = { emptyList() },
|
||||
onDrawStart: (PdfPoint) -> Unit = {},
|
||||
onDraw: (PdfPoint) -> Unit = {},
|
||||
onDrawStart: (PdfPoint, Boolean) -> Unit = { _, _ -> },
|
||||
onDraw: (PdfPoint, Boolean) -> Unit = { _, _ -> },
|
||||
onDrawEnd: () -> Unit = {},
|
||||
visibleScreenRect: () -> IntRect? = { null },
|
||||
selectedTool: InkType = InkType.PEN,
|
||||
|
|
@ -441,6 +520,7 @@ internal fun PdfPageComposable(
|
|||
isScrollLocked: Boolean = false,
|
||||
isVisible: Boolean = true,
|
||||
isActivePage: Boolean = true,
|
||||
isBubbleZoomModeActive: Boolean = false,
|
||||
isStylusOnlyMode: Boolean = false,
|
||||
isAutoScrollPlaying: Boolean = false,
|
||||
isHighlighterSnapEnabled: Boolean = false,
|
||||
|
|
@ -455,7 +535,7 @@ internal fun PdfPageComposable(
|
|||
onPaletteClick: (() -> Unit)? = null,
|
||||
lockedState: Triple<Float, Float, Float>? = null,
|
||||
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
|
||||
onDetectPanels: suspend (Bitmap) -> List<android.graphics.RectF> = { emptyList() },
|
||||
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() },
|
||||
onShowPanelPopup: (Bitmap) -> Unit = {}
|
||||
) {
|
||||
val pdfDocumentItem = pdfDocument.item
|
||||
|
|
@ -475,10 +555,7 @@ internal fun PdfPageComposable(
|
|||
LocalContext.current
|
||||
val viewConfiguration = LocalViewConfiguration.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Timber.d(
|
||||
"PdfPageComposable recompose: page=$pageIndex, isScrolling=$isScrolling, visualScale=$visualScaleProvider"
|
||||
)
|
||||
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
||||
|
||||
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
|
||||
|
|
@ -493,6 +570,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
|
||||
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
|
||||
val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap)
|
||||
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
|
||||
|
||||
val effectiveScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale
|
||||
|
|
@ -618,9 +696,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
|
||||
LaunchedEffect(centeringOffsetX, centeringOffsetY, pageIndex) {
|
||||
Timber.d(
|
||||
"PdfPageComposable Page $pageIndex | Centering Offset: x=$centeringOffsetX, y=$centeringOffsetY"
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
var showMagnifier by remember { mutableStateOf(false) }
|
||||
|
|
@ -646,6 +722,132 @@ internal fun PdfPageComposable(
|
|||
screenOffset
|
||||
}
|
||||
|
||||
var detectedBubbles by remember(targetPageId) { mutableStateOf<List<SpeechBubble>>(emptyList()) }
|
||||
var expandedBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) }
|
||||
var animatingBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) }
|
||||
val bubbleExpansionProgress = remember(targetPageId) { Animatable(0f) }
|
||||
var isDetectingBubbles by remember(targetPageId) { mutableStateOf(false) }
|
||||
var expandedBubbleRender by remember(targetPageId) { mutableStateOf<ExpandedBubbleRender?>(null) }
|
||||
val currentDetectedBubbles by rememberUpdatedState(detectedBubbles)
|
||||
val currentExpandedBubbleIndex by rememberUpdatedState(expandedBubbleIndex)
|
||||
val currentBubbleZoomModeActive by rememberUpdatedState(isBubbleZoomModeActive)
|
||||
val bubbleTapSlopPx = with(density) { 18.dp.toPx() }
|
||||
|
||||
LaunchedEffect(expandedBubbleIndex) {
|
||||
if (expandedBubbleIndex != -1) {
|
||||
if (animatingBubbleIndex != -1 && animatingBubbleIndex != expandedBubbleIndex) {
|
||||
bubbleExpansionProgress.animateTo(0f, tween(150))
|
||||
}
|
||||
animatingBubbleIndex = expandedBubbleIndex
|
||||
bubbleExpansionProgress.animateTo(1f, tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing))
|
||||
} else {
|
||||
bubbleExpansionProgress.animateTo(0f, tween(200, easing = androidx.compose.animation.core.FastOutLinearInEasing))
|
||||
animatingBubbleIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
isBubbleZoomModeActive,
|
||||
isActivePage,
|
||||
isPdfPage,
|
||||
pdfPageIndex,
|
||||
bitmapState,
|
||||
actualBitmapWidthPx,
|
||||
actualBitmapHeightPx
|
||||
) {
|
||||
Timber.tag("BubbleZoom").d("LaunchedEffect triggered. modeActive=$isBubbleZoomModeActive, activePage=$isActivePage, hasBitmap=${bitmapState != null}, dims=${actualBitmapWidthPx}x${actualBitmapHeightPx}")
|
||||
|
||||
if (isBubbleZoomModeActive && isActivePage && isPdfPage && bitmapState != null && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0) {
|
||||
Timber.tag("BubbleZoom").d("Conditions met. Starting detection...")
|
||||
isDetectingBubbles = true
|
||||
try {
|
||||
val rawBubbles = onDetectBubbles(pdfPageIndex, bitmapState!!)
|
||||
Timber.tag("BubbleZoom").d("Detection complete. Found ${rawBubbles.size} raw bubbles.")
|
||||
|
||||
// NEW: Scale bubbles down from render bitmap space to logical screen space
|
||||
val scaleX = actualBitmapWidthPx.toFloat() / bitmapState!!.width.toFloat()
|
||||
val scaleY = actualBitmapHeightPx.toFloat() / bitmapState!!.height.toFloat()
|
||||
|
||||
val logicalBubbles = rawBubbles.map { b ->
|
||||
b.copy(bounds = android.graphics.RectF(
|
||||
b.bounds.left * scaleX,
|
||||
b.bounds.top * scaleY,
|
||||
b.bounds.right * scaleX,
|
||||
b.bounds.bottom * scaleY
|
||||
))
|
||||
}
|
||||
|
||||
val rowHeight = actualBitmapHeightPx * 0.1f
|
||||
detectedBubbles = logicalBubbles.sortedWith(compareBy<SpeechBubble> { (it.bounds.centerY() / rowHeight).roundToInt() }.thenBy { it.bounds.centerX() })
|
||||
expandedBubbleIndex = -1
|
||||
|
||||
Timber.tag("BubbleZoom").d("Sorted logical bubbles count: ${detectedBubbles.size}")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("BubbleZoom").e(e, "Bubble detection failed with exception")
|
||||
} finally {
|
||||
isDetectingBubbles = false
|
||||
}
|
||||
} else {
|
||||
Timber.tag("BubbleZoom").d("Conditions NOT met or mode disabled. Clearing bubbles.")
|
||||
detectedBubbles = emptyList()
|
||||
expandedBubbleIndex = -1
|
||||
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||
expandedBubbleRender = null
|
||||
if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) {
|
||||
coroutineScope.launch {
|
||||
Animatable(scale).animateTo(1f, tween(300)) {
|
||||
scale = this.value
|
||||
offset = Offset.Zero
|
||||
onScaleChanged(scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
animatingBubbleIndex,
|
||||
detectedBubbles,
|
||||
actualBitmapWidthPx,
|
||||
actualBitmapHeightPx,
|
||||
canvasWidthPx.floatValue,
|
||||
canvasHeightPx.floatValue,
|
||||
isBubbleZoomModeActive,
|
||||
isPdfPage,
|
||||
pdfPageIndex
|
||||
) {
|
||||
val previousRender = expandedBubbleRender
|
||||
expandedBubbleRender = null
|
||||
previousRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||
|
||||
if (!isBubbleZoomModeActive || !isPdfPage || animatingBubbleIndex !in detectedBubbles.indices) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val bubble = detectedBubbles[animatingBubbleIndex]
|
||||
val zoomFactor = computeDynamicBubbleZoomFactor(
|
||||
bubbleBounds = bubble.bounds,
|
||||
viewportWidth = canvasWidthPx.floatValue.coerceAtLeast(actualBitmapWidthPx.toFloat()),
|
||||
viewportHeight = canvasHeightPx.floatValue.coerceAtLeast(actualBitmapHeightPx.toFloat())
|
||||
)
|
||||
val renderScale = (zoomFactor * 1.2f).coerceAtLeast(1.6f)
|
||||
val renderedBubble = renderExpandedBubbleBitmap(
|
||||
document = pdfDocumentItem,
|
||||
pageIndex = pdfPageIndex,
|
||||
bubbleBounds = bubble.bounds,
|
||||
pageWidth = actualBitmapWidthPx,
|
||||
pageHeight = actualBitmapHeightPx,
|
||||
renderScale = renderScale
|
||||
)
|
||||
|
||||
if (renderedBubble != null) {
|
||||
expandedBubbleRender = ExpandedBubbleRender(
|
||||
bitmap = renderedBubble,
|
||||
zoomFactor = zoomFactor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
val currentBitmap = bitmapState
|
||||
|
|
@ -653,6 +855,7 @@ internal fun PdfPageComposable(
|
|||
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
|
||||
currentBitmap.recycle()
|
||||
}
|
||||
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1646,6 +1849,32 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(resetZoomTrigger) {
|
||||
if (resetZoomTrigger != 0L && scale > 1f && isZoomEnabled && !isVerticalScroll && !isScrollLocked) {
|
||||
coroutineScope.launch {
|
||||
val startScale = scale
|
||||
val startOffset = offset
|
||||
Animatable(0f).animateTo(
|
||||
1f, animationSpec = tween(durationMillis = 300)
|
||||
) {
|
||||
val progress = value
|
||||
scale = androidx.compose.ui.util.lerp(
|
||||
startScale, 1f, progress
|
||||
)
|
||||
offset = androidx.compose.ui.geometry.lerp(
|
||||
startOffset, Offset.Zero, progress
|
||||
)
|
||||
onScaleChanged(scale)
|
||||
}
|
||||
if (scale <= 1.05f) {
|
||||
scale = 1f
|
||||
offset = Offset.Zero
|
||||
onScaleChanged(scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val errorSelection = stringResource(R.string.error_selection)
|
||||
val errorOcrSelection = stringResource(R.string.error_ocr_selection)
|
||||
val errorProcessingPage = stringResource(R.string.error_processing_page)
|
||||
|
|
@ -2487,7 +2716,8 @@ internal fun PdfPageComposable(
|
|||
isEditMode,
|
||||
selectedTool,
|
||||
isStylusOnlyMode,
|
||||
userHighlightScreenRects
|
||||
userHighlightScreenRects,
|
||||
bubbleTapSlopPx
|
||||
) {
|
||||
val isTapDetectionAllowed = !isEditMode ||
|
||||
selectedTool == InkType.TEXT ||
|
||||
|
|
@ -2496,9 +2726,48 @@ internal fun PdfPageComposable(
|
|||
if (!isTapDetectionAllowed) return@pointerInput
|
||||
|
||||
detectTapGestures(onTap = { tapOffset ->
|
||||
if (currentOnPreSingleTap?.invoke(tapOffset) == true) {
|
||||
return@detectTapGestures
|
||||
}
|
||||
|
||||
val tapInContentCoords = screenToContentCoordinates(tapOffset)
|
||||
val tapXInBitmap = tapInContentCoords.x
|
||||
val tapYInBitmap = tapInContentCoords.y
|
||||
val isWithinContentBounds =
|
||||
tapXInBitmap in 0f..actualBitmapWidthPx.toFloat() &&
|
||||
tapYInBitmap in 0f..actualBitmapHeightPx.toFloat()
|
||||
|
||||
if (!isWithinContentBounds) {
|
||||
currentOnSingleTap(tapOffset)
|
||||
return@detectTapGestures
|
||||
}
|
||||
|
||||
Timber.tag("BubbleZoom").d("Tap inside bounds. modeActive=$currentBubbleZoomModeActive, detectedBubbles=${currentDetectedBubbles.size}, tapPos=($tapXInBitmap, $tapYInBitmap)")
|
||||
|
||||
if (currentBubbleZoomModeActive && currentDetectedBubbles.isNotEmpty()) {
|
||||
val tappedBubbleIndex = currentDetectedBubbles.indexOfFirst { bubble ->
|
||||
isTapInsideBubble(
|
||||
bubble = bubble,
|
||||
tapX = tapXInBitmap,
|
||||
tapY = tapYInBitmap,
|
||||
hitSlopPx = bubbleTapSlopPx
|
||||
)
|
||||
}
|
||||
|
||||
Timber.tag("BubbleZoom").d("Tapped bubble index: $tappedBubbleIndex (expandedIndex=$currentExpandedBubbleIndex)")
|
||||
|
||||
if (tappedBubbleIndex != -1) {
|
||||
expandedBubbleIndex = if (currentExpandedBubbleIndex == tappedBubbleIndex) {
|
||||
-1
|
||||
} else {
|
||||
tappedBubbleIndex
|
||||
}
|
||||
return@detectTapGestures
|
||||
} else if (currentExpandedBubbleIndex != -1) {
|
||||
expandedBubbleIndex = -1
|
||||
return@detectTapGestures
|
||||
}
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
val nativeResult = withContext(Dispatchers.IO) {
|
||||
|
|
@ -2660,7 +2929,7 @@ internal fun PdfPageComposable(
|
|||
currentPageRotation,
|
||||
)
|
||||
} else {
|
||||
currentOnSingleTap()
|
||||
currentOnSingleTap(tapOffset)
|
||||
}
|
||||
}
|
||||
}, onDoubleTap = { tapOffset ->
|
||||
|
|
@ -2670,37 +2939,6 @@ internal fun PdfPageComposable(
|
|||
val startScale = scale
|
||||
val targetScale = if (startScale > 1.1f) 1f else 2.5f
|
||||
|
||||
if (com.aryan.reader.BuildConfig.DEBUG && startScale <= 1.1f && bitmapState != null) {
|
||||
val tapInContentCoords = screenToContentCoordinates(tapOffset)
|
||||
|
||||
val ratioX = bitmapState!!.width.toFloat() / actualBitmapWidthPx.toFloat()
|
||||
val ratioY = bitmapState!!.height.toFloat() / actualBitmapHeightPx.toFloat()
|
||||
val tapXInBitmap = tapInContentCoords.x * ratioX
|
||||
val tapYInBitmap = tapInContentCoords.y * ratioY
|
||||
|
||||
val panels = onDetectPanels(bitmapState!!)
|
||||
|
||||
val tappedPanel = panels.firstOrNull {
|
||||
it.contains(tapXInBitmap, tapYInBitmap)
|
||||
}
|
||||
|
||||
if (tappedPanel != null) {
|
||||
Timber.d("Popup: Cropping panel $tappedPanel")
|
||||
val left = tappedPanel.left.coerceAtLeast(0f).toInt()
|
||||
val top = tappedPanel.top.coerceAtLeast(0f).toInt()
|
||||
val right = tappedPanel.right.coerceAtMost(bitmapState!!.width.toFloat()).toInt()
|
||||
val bottom = tappedPanel.bottom.coerceAtMost(bitmapState!!.height.toFloat()).toInt()
|
||||
val width = right - left
|
||||
val height = bottom - top
|
||||
|
||||
if (width > 0 && height > 0) {
|
||||
val cropped = android.graphics.Bitmap.createBitmap(bitmapState!!, left, top, width, height)
|
||||
onShowPanelPopup(cropped)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val startOffset = offset
|
||||
val targetOffsetUnbounded = if (targetScale <= 1.1f) {
|
||||
Offset.Zero
|
||||
|
|
@ -3017,12 +3255,20 @@ internal fun PdfPageComposable(
|
|||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
val buttons = currentEvent.buttons
|
||||
Timber.tag("StylusEraserDiagnostic").d(
|
||||
"Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
|
||||
)
|
||||
|
||||
val isEraserOverride = down.type == PointerType.Eraser || (down.type == PointerType.Stylus && currentEvent.buttons.isSecondaryPressed)
|
||||
isStylusEraserOverride = isEraserOverride
|
||||
|
||||
val dragPointerId = down.id
|
||||
val startPos = down.position
|
||||
var dragStarted = false
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
|
||||
if (selectedTool == InkType.ERASER) {
|
||||
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
||||
eraserPosition = down.position
|
||||
}
|
||||
|
||||
|
|
@ -3034,6 +3280,7 @@ internal fun PdfPageComposable(
|
|||
drawingState?.onDrawCancel()
|
||||
}
|
||||
eraserPosition = null
|
||||
isStylusEraserOverride = false
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
|
|
@ -3051,12 +3298,13 @@ internal fun PdfPageComposable(
|
|||
val normY =
|
||||
(contentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
|
||||
|
||||
onDrawStart(PdfPoint(normX, normY))
|
||||
onDrawStart(PdfPoint(normX, normY), isEraserOverride)
|
||||
onDrawEnd()
|
||||
} else {
|
||||
onDrawEnd()
|
||||
}
|
||||
eraserPosition = null
|
||||
isStylusEraserOverride = false
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
|
|
@ -3077,7 +3325,7 @@ internal fun PdfPageComposable(
|
|||
0f, 1f
|
||||
)
|
||||
onDrawStart(
|
||||
PdfPoint(startNormX, startNormY)
|
||||
PdfPoint(startNormX, startNormY), isEraserOverride
|
||||
)
|
||||
|
||||
val currContentPos = screenToContentCoordinates(
|
||||
|
|
@ -3091,9 +3339,9 @@ internal fun PdfPageComposable(
|
|||
(currContentPos.y / actualBitmapHeightPx).coerceIn(
|
||||
0f, 1f
|
||||
)
|
||||
onDraw(PdfPoint(currNormX, currNormY))
|
||||
onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
|
||||
|
||||
if (selectedTool == InkType.ERASER) {
|
||||
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
||||
eraserPosition = change.position
|
||||
}
|
||||
change.consume()
|
||||
|
|
@ -3106,9 +3354,9 @@ internal fun PdfPageComposable(
|
|||
(currContentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f)
|
||||
val currNormY =
|
||||
(currContentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
|
||||
onDraw(PdfPoint(currNormX, currNormY))
|
||||
onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
|
||||
|
||||
if (selectedTool == InkType.ERASER) {
|
||||
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
||||
eraserPosition = change.position
|
||||
}
|
||||
change.consume()
|
||||
|
|
@ -3118,6 +3366,7 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
} finally {
|
||||
eraserPosition = null
|
||||
isStylusEraserOverride = false
|
||||
}
|
||||
}, contentAlignment = Alignment.Center
|
||||
) {
|
||||
|
|
@ -3230,10 +3479,6 @@ internal fun PdfPageComposable(
|
|||
offset = Offset.Zero
|
||||
onScaleChanged(1f)
|
||||
}
|
||||
|
||||
Timber.d(
|
||||
"PdfPageComposable Page $pageIndex initialized/resized/locked. scale=$scale, offset=$offset"
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
|
|
@ -3386,15 +3631,8 @@ internal fun PdfPageComposable(
|
|||
val viewContainerHeightPx =
|
||||
with(density) { currentContainerMaxHeight.toPx().toInt() }
|
||||
|
||||
Timber.d(
|
||||
"PdfPageComposable Page $pageIndex | viewContainerPx: ${viewContainerWidthPx}x${viewContainerHeightPx}"
|
||||
)
|
||||
|
||||
if (viewContainerWidthPx <= 0 || viewContainerHeightPx <= 0) {
|
||||
if (bitmapState == null) isLoadingPage = true
|
||||
Timber.d(
|
||||
"PdfPageComposable: viewContainer dimensions invalid ($viewContainerWidthPx x $viewContainerHeightPx), waiting."
|
||||
)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
|
|
@ -3948,6 +4186,7 @@ internal fun PdfPageComposable(
|
|||
isEditMode = isEditMode,
|
||||
selectedTool = selectedTool,
|
||||
eraserPosition = eraserPosition,
|
||||
isStylusEraserOverride = isStylusEraserOverride,
|
||||
activeToolThickness = activeToolThickness,
|
||||
richTextController = richTextController,
|
||||
textBoxes = textBoxes,
|
||||
|
|
@ -3960,7 +4199,14 @@ internal fun PdfPageComposable(
|
|||
onDragPageTurn = onDragPageTurn,
|
||||
draggingBoxId = draggingBoxId,
|
||||
customHighlightColors = customHighlightColors,
|
||||
onPaletteClick = onPaletteClick
|
||||
onPaletteClick = onPaletteClick,
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
isActivePage = isActivePage,
|
||||
isDetectingBubbles = isDetectingBubbles,
|
||||
detectedBubbles = detectedBubbles,
|
||||
animatingBubbleIndex = animatingBubbleIndex,
|
||||
bubbleExpansionProgress = bubbleExpansionProgress.value,
|
||||
expandedBubbleRender = expandedBubbleRender
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -4080,15 +4326,10 @@ private fun PdfBitmapLayer(
|
|||
|
||||
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
|
||||
imageRects.forEach { imgRect ->
|
||||
val scaledImgRectLeft = (imgRect.left * effectiveScale).roundToInt()
|
||||
val scaledImgRectTop = (imgRect.top * effectiveScale).roundToInt()
|
||||
val scaledImgRectRight = (imgRect.right * effectiveScale).roundToInt()
|
||||
val scaledImgRectBottom = (imgRect.bottom * effectiveScale).roundToInt()
|
||||
|
||||
val intersectLeft = max(scaledImgRectLeft, tile.renderRect.left)
|
||||
val intersectTop = max(scaledImgRectTop, tile.renderRect.top)
|
||||
val intersectRight = min(scaledImgRectRight, tile.renderRect.right)
|
||||
val intersectBottom = min(scaledImgRectBottom, tile.renderRect.bottom)
|
||||
val intersectLeft = max(imgRect.left, tile.renderRect.left)
|
||||
val intersectTop = max(imgRect.top, tile.renderRect.top)
|
||||
val intersectRight = min(imgRect.right, tile.renderRect.right)
|
||||
val intersectBottom = min(imgRect.bottom, tile.renderRect.bottom)
|
||||
|
||||
val iw = intersectRight - intersectLeft
|
||||
val ih = intersectBottom - intersectTop
|
||||
|
|
@ -4151,7 +4392,6 @@ private fun PdfHighlightsLayer(
|
|||
selectionHighlightColor: Color,
|
||||
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap()
|
||||
) {
|
||||
Timber.d("PdfHighlightsLayer Recompose")
|
||||
Canvas(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer()) {
|
||||
|
|
@ -4751,6 +4991,7 @@ private fun PdfPageRenderer(
|
|||
isEditMode: Boolean,
|
||||
selectedTool: InkType,
|
||||
eraserPosition: Offset?,
|
||||
isStylusEraserOverride: Boolean,
|
||||
richTextController: RichTextController?,
|
||||
textBoxes: List<PdfTextBox>,
|
||||
selectedTextBoxId: String?,
|
||||
|
|
@ -4769,6 +5010,13 @@ private fun PdfPageRenderer(
|
|||
onTts: (Int, Int) -> Unit,
|
||||
activeToolThickness: Float,
|
||||
onNote: (String?) -> Unit,
|
||||
isBubbleZoomModeActive: Boolean = false,
|
||||
isActivePage: Boolean = true,
|
||||
isDetectingBubbles: Boolean = false,
|
||||
detectedBubbles: List<SpeechBubble> = emptyList(),
|
||||
animatingBubbleIndex: Int = -1,
|
||||
bubbleExpansionProgress: Float = 0f,
|
||||
expandedBubbleRender: ExpandedBubbleRender? = null
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
|
|
@ -4976,7 +5224,7 @@ private fun PdfPageRenderer(
|
|||
|
||||
val teardropPainter = painterResource(id = R.drawable.teardrop)
|
||||
|
||||
if (isEditMode && selectedTool == InkType.ERASER && eraserPosition != null) {
|
||||
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) {
|
||||
activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale
|
||||
|
|
@ -5203,6 +5451,150 @@ private fun PdfPageRenderer(
|
|||
if (isPerformingOcr && ocrRipplePos != null) {
|
||||
OcrProcessingIndicator(position = ocrRipplePos)
|
||||
}
|
||||
|
||||
if (isBubbleZoomModeActive && isActivePage) {
|
||||
if (isDetectingBubbles) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
} else if (detectedBubbles.isNotEmpty()) {
|
||||
Canvas(modifier = Modifier.fillMaxSize().zIndex(20f)) {
|
||||
// Draw shadow-like hints for unexpanded bubbles
|
||||
detectedBubbles.forEachIndexed { index, bubble ->
|
||||
val hintAlpha = if (index == animatingBubbleIndex) 0.35f * (1f - bubbleExpansionProgress) else 0.35f
|
||||
if (hintAlpha > 0f) {
|
||||
val left = bubble.bounds.left + staticData.centeringOffsetX
|
||||
val top = bubble.bounds.top + staticData.centeringOffsetY
|
||||
val width = bubble.bounds.width()
|
||||
val height = bubble.bounds.height()
|
||||
|
||||
if (bubble.maskBitmap != null) {
|
||||
drawImage(
|
||||
image = bubble.maskBitmap.asImageBitmap(),
|
||||
dstOffset = IntOffset(left.toInt(), top.toInt()),
|
||||
dstSize = IntSize(width.toInt(), height.toInt()),
|
||||
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = hintAlpha)),
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
} else {
|
||||
drawRoundRect(
|
||||
color = Color.Black.copy(alpha = hintAlpha),
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(width, height),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) {
|
||||
val bubble = detectedBubbles[animatingBubbleIndex]
|
||||
val left = bubble.bounds.left + staticData.centeringOffsetX
|
||||
val top = bubble.bounds.top + staticData.centeringOffsetY
|
||||
val logicalWidth = bubble.bounds.width()
|
||||
val logicalHeight = bubble.bounds.height()
|
||||
val pivotX = left + logicalWidth / 2f
|
||||
val pivotY = top + logicalHeight / 2f
|
||||
val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
|
||||
bubbleBounds = bubble.bounds,
|
||||
viewportWidth = staticData.canvasWidth,
|
||||
viewportHeight = staticData.canvasHeight
|
||||
)
|
||||
val zoomFactor = androidx.compose.ui.util.lerp(1f, targetZoomFactor, bubbleExpansionProgress)
|
||||
|
||||
withTransform({
|
||||
scale(zoomFactor, zoomFactor, Offset(pivotX, pivotY))
|
||||
}) {
|
||||
val dstOffset = IntOffset(left.toInt(), top.toInt())
|
||||
val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt())
|
||||
|
||||
val renderScaleX = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat()
|
||||
val renderScaleY = staticData.bitmap.item.height.toFloat() / staticData.targetHeight.toFloat()
|
||||
|
||||
val srcOffset = IntOffset(
|
||||
(bubble.bounds.left * renderScaleX).toInt(),
|
||||
(bubble.bounds.top * renderScaleY).toInt()
|
||||
)
|
||||
val srcSize = IntSize(
|
||||
(logicalWidth * renderScaleX).toInt(),
|
||||
(logicalHeight * renderScaleY).toInt()
|
||||
)
|
||||
|
||||
if (bubble.maskBitmap != null) {
|
||||
drawImage(
|
||||
image = bubble.maskBitmap.asImageBitmap(),
|
||||
dstOffset = IntOffset(left.toInt() + 12, top.toInt() + 12),
|
||||
dstSize = dstSize,
|
||||
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress)),
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
} else {
|
||||
drawRoundRect(
|
||||
color = Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress),
|
||||
topLeft = Offset(left + 12f, top + 12f),
|
||||
size = Size(logicalWidth, logicalHeight),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
|
||||
)
|
||||
}
|
||||
|
||||
if (bubble.maskBitmap != null) {
|
||||
val rect = androidx.compose.ui.geometry.Rect(
|
||||
dstOffset.x.toFloat(),
|
||||
dstOffset.y.toFloat(),
|
||||
dstOffset.x.toFloat() + dstSize.width,
|
||||
dstOffset.y.toFloat() + dstSize.height
|
||||
)
|
||||
drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint())
|
||||
drawImage(
|
||||
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
|
||||
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||
srcSize = if (expandedBubbleRender != null) {
|
||||
IntSize(
|
||||
expandedBubbleRender.bitmap.width,
|
||||
expandedBubbleRender.bitmap.height)
|
||||
} else {
|
||||
srcSize
|
||||
},
|
||||
dstOffset = dstOffset,
|
||||
dstSize = dstSize,
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
drawImage(
|
||||
image = bubble.maskBitmap.asImageBitmap(),
|
||||
dstOffset = dstOffset,
|
||||
dstSize = dstSize,
|
||||
blendMode = BlendMode.DstIn,
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
|
||||
)
|
||||
drawContext.canvas.restore()
|
||||
} else {
|
||||
clipRect(left, top, left + logicalWidth, top + logicalHeight) {
|
||||
drawImage(
|
||||
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
|
||||
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
|
||||
srcSize = if (expandedBubbleRender != null) {
|
||||
IntSize(
|
||||
expandedBubbleRender.bitmap.width,
|
||||
expandedBubbleRender.bitmap.height)
|
||||
} else {
|
||||
srcSize
|
||||
},
|
||||
dstOffset = dstOffset,
|
||||
dstSize = dstSize
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
color = Color.White.copy(alpha = 0.5f * bubbleExpansionProgress),
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(logicalWidth, logicalHeight),
|
||||
style = Stroke(width = 4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5409,4 +5801,4 @@ private fun getNativePointer(obj: Any): Long {
|
|||
} catch (_: Exception) {}
|
||||
|
||||
return 0L
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ enum class PdfReaderTool(val title: String, val category: String) {
|
|||
THEME("Theme Settings", "Top Bar"),
|
||||
LOCK_PANNING("Lock Panning", "Top Bar"),
|
||||
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
||||
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
|
||||
FULL_SCREEN("Full Screen", "Top Bar"),
|
||||
SLIDER("Navigation Slider", "Bottom Bar"),
|
||||
TOC("Sidebar", "Bottom Bar"),
|
||||
|
|
@ -370,4 +371,4 @@ internal fun savePdfDarkMode(context: Context, isDark: Boolean) {
|
|||
internal fun loadPdfDarkMode(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_DARK_MODE_KEY, false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,229 +0,0 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.legere.pdfiumandroid.api.Bookmark
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
|
||||
private const val MAX_FIXED_RECURSION = 128
|
||||
|
||||
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
|
||||
|
||||
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
|
||||
|
||||
/**
|
||||
* Patches the library bug where siblings are truncated due to depth-state leakage.
|
||||
*/
|
||||
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
|
||||
val tag = "PdfTocFix"
|
||||
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
|
||||
|
||||
return try {
|
||||
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
|
||||
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
|
||||
val docUInstance = documentField.get(this) ?: return getTableOfContents()
|
||||
|
||||
// 2. Get the 'nativeDocument' field from PdfDocumentU
|
||||
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
|
||||
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
|
||||
|
||||
// 3. Get the native pointer (long) from PdfDocumentU
|
||||
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
|
||||
val mNativeDocPtr = ptrField.get(docUInstance) as Long
|
||||
|
||||
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
|
||||
val nClass = nativeDocInstance.javaClass
|
||||
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
|
||||
|
||||
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
|
||||
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
|
||||
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
|
||||
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
|
||||
|
||||
val topLevel = mutableListOf<Bookmark>()
|
||||
val visited = mutableSetOf<Long>()
|
||||
|
||||
/**
|
||||
* Corrected traversal: Iterative for siblings, recursive for children.
|
||||
*/
|
||||
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
|
||||
var currentPtr = startPtr
|
||||
var itemIndex = 0
|
||||
|
||||
while (currentPtr != 0L) {
|
||||
if (visited.contains(currentPtr)) break
|
||||
visited.add(currentPtr)
|
||||
|
||||
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
|
||||
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
|
||||
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
|
||||
|
||||
val bookmark = Bookmark().apply {
|
||||
this.mNativePtr = currentPtr
|
||||
this.title = title
|
||||
this.pageIdx = pageIdx
|
||||
}
|
||||
parentList.add(bookmark)
|
||||
|
||||
// Recursive dive into children
|
||||
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
|
||||
walk(bookmark.children, firstChild, level + 1)
|
||||
}
|
||||
|
||||
// Iterative move to next sibling
|
||||
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
|
||||
itemIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Start from the root (Pass 0L as primitive long)
|
||||
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
|
||||
if (firstRoot != 0L) {
|
||||
walk(topLevel, firstRoot, 0)
|
||||
}
|
||||
|
||||
if (topLevel.isEmpty()) {
|
||||
Timber.tag(tag).w("No items found, falling back to library.")
|
||||
getTableOfContents()
|
||||
} else {
|
||||
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
|
||||
topLevel
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(tag).e(e, "Reflection traversal critical error.")
|
||||
this.getTableOfContents()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
|
||||
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
|
||||
val entries = mutableListOf<TocEntry>()
|
||||
for ((index, bookmark) in bookmarks.withIndex()) {
|
||||
val title = bookmark.title ?: "Untitled Chapter"
|
||||
val childCount = bookmark.children.size
|
||||
|
||||
Timber.tag("PdfTocDebug").d(
|
||||
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
|
||||
)
|
||||
|
||||
entries.add(
|
||||
TocEntry(
|
||||
title = title,
|
||||
pageIndex = bookmark.pageIdx.toInt(),
|
||||
nestLevel = level
|
||||
)
|
||||
)
|
||||
|
||||
if (childCount > 0) {
|
||||
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
|
||||
entries.addAll(flattenToc(bookmark.children, level + 1))
|
||||
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
|
||||
if (bookmarksJson.isNullOrBlank()) return emptySet()
|
||||
return try {
|
||||
val jsonArray = JSONArray(bookmarksJson)
|
||||
(0 until jsonArray.length()).mapNotNull { i ->
|
||||
try {
|
||||
val json = jsonArray.getJSONObject(i)
|
||||
PdfBookmark(
|
||||
pageIndex = json.getInt("pageIndex"),
|
||||
title = json.getString("title"),
|
||||
totalPages = json.getInt("totalPages")
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse bookmark from JSON object")
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson")
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PdfTocTreeItem(
|
||||
label: String,
|
||||
nestLevel: Int,
|
||||
isExpanded: Boolean,
|
||||
hasChildren: Boolean,
|
||||
isCurrent: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
|
||||
label = "TocItemBackground"
|
||||
)
|
||||
|
||||
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width((16 * nestLevel).dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clickable(enabled = hasChildren, onClick = onToggleExpand),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasChildren) {
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = contentColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(end = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
|
|
@ -33,6 +34,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.FileType
|
||||
import com.aryan.reader.R
|
||||
|
|
@ -81,6 +83,8 @@ internal fun PdfTopBar(
|
|||
onShowCustomizeTools: () -> Unit,
|
||||
onShowOcrLanguage: () -> Unit,
|
||||
onShowVisualOptions: () -> Unit,
|
||||
tapToNavigateEnabled: Boolean,
|
||||
onToggleTapToNavigate: () -> Unit,
|
||||
onChangeDisplayMode: (DisplayMode) -> Unit,
|
||||
onToggleKeepScreenOn: () -> Unit,
|
||||
onStartAutoScroll: () -> Unit,
|
||||
|
|
@ -94,7 +98,8 @@ internal fun PdfTopBar(
|
|||
onPrint: () -> Unit,
|
||||
onTabClick: (String) -> Unit,
|
||||
onTabClose: (String) -> Unit,
|
||||
onNewTabClick: () -> Unit
|
||||
onNewTabClick: () -> Unit,
|
||||
onGenerateDemoAnnotations: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showStandardBars,
|
||||
|
|
@ -179,6 +184,9 @@ internal fun PdfTopBar(
|
|||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
TooltipIconButton(text = "Demo Annotations", onClick = onGenerateDemoAnnotations) {
|
||||
Icon(Icons.Default.BugReport, contentDescription = "Generate Demo Annotations", tint = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) {
|
||||
Icon(Icons.Default.Star, contentDescription = "Open Pen Playground", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
|
@ -238,6 +246,26 @@ internal fun PdfTopBar(
|
|||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
|
||||
enabled = displayMode == DisplayMode.PAGINATION,
|
||||
onClick = {
|
||||
onToggleTapToNavigate()
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (tapToNavigateEnabled) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = stringResource(R.string.content_desc_enabled)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
|
||||
|
|
@ -433,13 +461,17 @@ fun PdfBottomBar(
|
|||
isEditMode: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
ttsErrorMessage: String?,
|
||||
jumpBackPage: Int?,
|
||||
onJumpBack: () -> Unit,
|
||||
onShowSlider: () -> Unit,
|
||||
onShowToc: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
onToggleHighlights: () -> Unit,
|
||||
onShowAiHub: () -> Unit,
|
||||
onToggleEditMode: () -> Unit,
|
||||
onToggleTts: () -> Unit
|
||||
onToggleTts: () -> Unit,
|
||||
isBubbleZoomModeActive: Boolean,
|
||||
onToggleBubbleZoom: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showStandardBars && !searchStateActive,
|
||||
|
|
@ -456,8 +488,35 @@ fun PdfBottomBar(
|
|||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = bottomBarPadding).height(56.dp).padding(horizontal = 8.dp).horizontalScroll(bottomBarScrollState),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
if (jumpBackPage != null) {
|
||||
TooltipIconButton(
|
||||
text = "Jump Back to Page ${jumpBackPage + 1}",
|
||||
description = "Return to previous page",
|
||||
onClick = onJumpBack
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Undo,
|
||||
contentDescription = "Jump Back",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "${jumpBackPage + 1}",
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_slider),
|
||||
|
|
@ -532,10 +591,24 @@ fun PdfBottomBar(
|
|||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.FLAVOR != "oss") {
|
||||
TooltipIconButton(
|
||||
text = if (isBubbleZoomModeActive) "Exit Smart Zoom" else "Smart Comic Zoom",
|
||||
description = "Toggle Smart Comic Zoom",
|
||||
onClick = onToggleBubbleZoom
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.comic_bubble),
|
||||
contentDescription = "Smart Comic Zoom",
|
||||
tint = if (isBubbleZoomModeActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ttsErrorMessage?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f).padding(start = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
|
|
@ -90,6 +91,9 @@ import androidx.compose.ui.graphics.TransformOrigin
|
|||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerType
|
||||
import androidx.compose.ui.input.pointer.isPrimaryPressed
|
||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||
import androidx.compose.ui.input.pointer.isTertiaryPressed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
|
|
@ -108,6 +112,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.ml.SpeechBubble
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.pdf.data.PdfTextBox
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
|
|
@ -217,8 +222,8 @@ internal fun PdfVerticalReader(
|
|||
isEditMode: Boolean = false,
|
||||
allAnnotations: () -> Map<Int, List<PdfAnnotation>> = { emptyMap() },
|
||||
drawingState: PdfDrawingState,
|
||||
onDrawStart: (Int, PdfPoint) -> Unit,
|
||||
onDraw: (Int, PdfPoint) -> Unit,
|
||||
onDrawStart: (Int, PdfPoint, Boolean) -> Unit,
|
||||
onDraw: (Int, PdfPoint, Boolean) -> Unit,
|
||||
onDrawEnd: () -> Unit,
|
||||
onOcrModelDownloading: () -> Unit = {},
|
||||
selectedTool: InkType,
|
||||
|
|
@ -247,7 +252,10 @@ internal fun PdfVerticalReader(
|
|||
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
||||
onPaletteClick: () -> Unit = {},
|
||||
lockedState: Triple<Float, Float, Float>? = null,
|
||||
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null
|
||||
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
|
||||
resetZoomTrigger: Long = 0L,
|
||||
isBubbleZoomModeActive: Boolean = false,
|
||||
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() }
|
||||
) {
|
||||
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
|
||||
DisposableEffect(state) {
|
||||
|
|
@ -260,6 +268,7 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
}
|
||||
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
||||
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
||||
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
|
||||
val imeInsets = WindowInsets.ime
|
||||
|
|
@ -393,18 +402,17 @@ internal fun PdfVerticalReader(
|
|||
|
||||
val zoomedDocHeight = totalDocHeight * savedScale
|
||||
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
|
||||
val maxPanY = headerHeightPx
|
||||
|
||||
zoomAnimatable.stop()
|
||||
panXAnimatable.stop()
|
||||
panYAnimatable.stop()
|
||||
|
||||
panXAnimatable.updateBounds(minPanX, maxPanX)
|
||||
panYAnimatable.updateBounds(minPanY, maxPanY)
|
||||
panYAnimatable.updateBounds(minPanY, headerHeightPx)
|
||||
|
||||
zoomAnimatable.snapTo(savedScale)
|
||||
panXAnimatable.snapTo(savedPanX)
|
||||
panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, maxPanY))
|
||||
panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, headerHeightPx))
|
||||
|
||||
Timber.tag("PdfLockDiagnostic").d("RESTORE SNAP COMPLETE: Scale=${zoomAnimatable.value}, X=${panXAnimatable.value}, Y=${panYAnimatable.value}")
|
||||
|
||||
|
|
@ -492,6 +500,65 @@ internal fun PdfVerticalReader(
|
|||
return clampValues(targetZoom, targetPanX, targetPanY)
|
||||
}
|
||||
|
||||
LaunchedEffect(resetZoomTrigger) {
|
||||
if (resetZoomTrigger != 0L && zoomAnimatable.value > fitZoom && !isScrollLocked) {
|
||||
scope.launch {
|
||||
zoomAnimatable.stop()
|
||||
panXAnimatable.stop()
|
||||
panYAnimatable.stop()
|
||||
|
||||
val startZoom = zoomAnimatable.value
|
||||
val startPanX = panXAnimatable.value
|
||||
val startPanY = panYAnimatable.value
|
||||
|
||||
val pivotScreenX = screenWidth / 2f
|
||||
val pivotScreenY = screenHeight / 2f
|
||||
|
||||
val pivotContentX = (pivotScreenX - startPanX) / startZoom
|
||||
val pivotContentY = (pivotScreenY - startPanY) / startZoom
|
||||
|
||||
val rawNextPanX = pivotScreenX - (pivotContentX * fitZoom)
|
||||
val rawNextPanY = pivotScreenY - (pivotContentY * fitZoom)
|
||||
|
||||
val (finalZoom, finalX, finalY) = clampCamera(fitZoom, rawNextPanX, rawNextPanY)
|
||||
|
||||
panXAnimatable.updateBounds(
|
||||
lowerBound = minOf(panXAnimatable.lowerBound ?: finalX, finalX, startPanX),
|
||||
upperBound = maxOf(panXAnimatable.upperBound ?: finalX, finalX, startPanX)
|
||||
)
|
||||
panYAnimatable.updateBounds(
|
||||
lowerBound = minOf(panYAnimatable.lowerBound ?: finalY, finalY, startPanY),
|
||||
upperBound = maxOf(panYAnimatable.upperBound ?: finalY, finalY, startPanY)
|
||||
)
|
||||
|
||||
coroutineScope {
|
||||
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
|
||||
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
|
||||
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
|
||||
}
|
||||
|
||||
onZoomChange(zoomAnimatable.value)
|
||||
|
||||
val zoomedDocWidth = screenWidth * finalZoom
|
||||
val finalMinX: Float
|
||||
val finalMaxX: Float
|
||||
if (zoomedDocWidth < screenWidth) {
|
||||
val centeredX = (screenWidth - zoomedDocWidth) / 2f
|
||||
finalMinX = centeredX
|
||||
finalMaxX = centeredX
|
||||
} else {
|
||||
finalMinX = -(zoomedDocWidth - screenWidth)
|
||||
finalMaxX = 0f
|
||||
}
|
||||
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
|
||||
|
||||
val zDocH = totalDocHeight * finalZoom
|
||||
val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx)
|
||||
panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing
|
||||
) {
|
||||
|
|
@ -920,6 +987,15 @@ internal fun PdfVerticalReader(
|
|||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
val buttons = currentEvent.buttons
|
||||
Timber.tag("StylusEraserDiagnostic").d(
|
||||
"VerticalReader | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
|
||||
)
|
||||
|
||||
val isEraserOverride = down.type == PointerType.Eraser ||
|
||||
(down.type == PointerType.Stylus && currentEvent.buttons.isSecondaryPressed)
|
||||
isStylusEraserOverride = isEraserOverride
|
||||
|
||||
fun getPageAndPoint(screenOffset: Offset): Pair<Int, PdfPoint>? {
|
||||
val zoom = zoomAnimatable.value
|
||||
val panX = panXAnimatable.value
|
||||
|
|
@ -943,14 +1019,14 @@ internal fun PdfVerticalReader(
|
|||
var isCanceled = false
|
||||
|
||||
try {
|
||||
if (selectedTool == InkType.ERASER) {
|
||||
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
||||
globalEraserPosition = down.position
|
||||
}
|
||||
|
||||
val startData = getPageAndPoint(down.position)
|
||||
if (startData != null) {
|
||||
val (pageIndex, point) = startData
|
||||
onDrawStart(pageIndex, point)
|
||||
onDrawStart(pageIndex, point, isEraserOverride)
|
||||
down.consume()
|
||||
}
|
||||
|
||||
|
|
@ -969,7 +1045,7 @@ internal fun PdfVerticalReader(
|
|||
if (change == null || !change.pressed) break
|
||||
|
||||
if (change.positionChanged()) {
|
||||
if (selectedTool == InkType.ERASER) {
|
||||
if (selectedTool == InkType.ERASER || isEraserOverride) {
|
||||
globalEraserPosition = change.position
|
||||
}
|
||||
|
||||
|
|
@ -977,11 +1053,11 @@ internal fun PdfVerticalReader(
|
|||
if (dragData != null) {
|
||||
val (pageIndex, point) = dragData
|
||||
|
||||
if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER) {
|
||||
if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER && !isEraserOverride) {
|
||||
onDrawEnd()
|
||||
onDrawStart(pageIndex, point)
|
||||
onDrawStart(pageIndex, point, isEraserOverride)
|
||||
} else {
|
||||
onDraw(pageIndex, point)
|
||||
onDraw(pageIndex, point, isEraserOverride)
|
||||
}
|
||||
lastPageIndex = pageIndex
|
||||
}
|
||||
|
|
@ -993,6 +1069,7 @@ internal fun PdfVerticalReader(
|
|||
onDrawEnd()
|
||||
}
|
||||
globalEraserPosition = null
|
||||
isStylusEraserOverride = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1472,16 +1549,20 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
|
||||
val onDrawStartLambda = remember(page.index, onDrawStart) {
|
||||
{ point: PdfPoint -> onDrawStart(page.index, point) }
|
||||
{ point: PdfPoint, isEraserOverride: Boolean ->
|
||||
onDrawStart(page.index, point, isEraserOverride)
|
||||
}
|
||||
}
|
||||
|
||||
val currentOnDraw by rememberUpdatedState(onDraw)
|
||||
val onDrawLambda = remember(page.index) {
|
||||
{ point: PdfPoint -> currentOnDraw(page.index, point) }
|
||||
{ point: PdfPoint, isEraserOverride: Boolean ->
|
||||
currentOnDraw(page.index, point, isEraserOverride)
|
||||
}
|
||||
}
|
||||
|
||||
val onSingleTapLambda = remember(onPageClick) {
|
||||
{
|
||||
{ _: Offset? ->
|
||||
selectionClearTrigger++
|
||||
onPageClick()
|
||||
}
|
||||
|
|
@ -1759,7 +1840,9 @@ internal fun PdfVerticalReader(
|
|||
draggingBoxId = null
|
||||
}
|
||||
},
|
||||
draggingBoxId = draggingBoxId
|
||||
draggingBoxId = draggingBoxId,
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
onDetectBubbles = onDetectBubbles
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1875,6 +1958,7 @@ internal fun PdfVerticalReader(
|
|||
animationSpec = tween(durationMillis = 300),
|
||||
label = "scrollbarAlpha"
|
||||
)
|
||||
val safeCurrentPage = if (totalPages > 0) state.currentPage.coerceIn(0, totalPages - 1) else 0
|
||||
|
||||
val samsungBlue = Color(0xFF4285F4)
|
||||
val samsungBlueDark = Color(0xFF1976D2)
|
||||
|
|
@ -1934,7 +2018,7 @@ internal fun PdfVerticalReader(
|
|||
.alpha(scrollbarAlpha)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AnimatedVisibility(
|
||||
visible = isDraggingScrollbar,
|
||||
visible = isDraggingScrollbar && totalPages > 0,
|
||||
enter = fadeIn() + androidx.compose.animation.slideInHorizontally {
|
||||
it / 2
|
||||
},
|
||||
|
|
@ -1948,7 +2032,7 @@ internal fun PdfVerticalReader(
|
|||
modifier = Modifier.padding(end = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${state.currentPage + 1}/${totalPages}",
|
||||
text = "${safeCurrentPage + 1}/$totalPages",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = 16.sp, fontWeight = FontWeight.Bold
|
||||
),
|
||||
|
|
@ -2060,7 +2144,7 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
}
|
||||
|
||||
if (isEditMode && selectedTool == InkType.ERASER && globalEraserPosition != null) {
|
||||
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val pos = globalEraserPosition!!
|
||||
val radiusPx = if (activeToolThickness > 0f) {
|
||||
|
|
@ -2127,4 +2211,4 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
|
|
@ -209,11 +210,14 @@ import com.aryan.reader.SearchResult
|
|||
import com.aryan.reader.SummarizationResult
|
||||
import com.aryan.reader.SummaryCacheManager
|
||||
import com.aryan.reader.TtsSettingsSheet
|
||||
import com.aryan.reader.ml.SpeechBubble
|
||||
import com.aryan.reader.epubreader.AutoScrollControls
|
||||
import com.aryan.reader.epubreader.DictionarySettingsDialog
|
||||
import com.aryan.reader.epubreader.ExternalDictionaryHelper
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import com.aryan.reader.epubreader.TtsOverlayControls
|
||||
import com.aryan.reader.epubreader.loadTapToNavigateSetting
|
||||
import com.aryan.reader.epubreader.saveTapToNavigateSetting
|
||||
import com.aryan.reader.fetchAiDefinition
|
||||
import com.aryan.reader.loadCustomThemes
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
|
|
@ -252,6 +256,7 @@ import org.json.JSONObject
|
|||
import timber.log.Timber
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.LinkedHashSet
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.math.PI
|
||||
|
|
@ -290,6 +295,7 @@ fun PdfViewerScreen(
|
|||
val focusManager = LocalFocusManager.current
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
var displayMode by remember { mutableStateOf(loadDisplayMode(context)) }
|
||||
var tapToNavigateEnabled by remember { mutableStateOf(loadTapToNavigateSetting(context)) }
|
||||
var showThemePanel by remember { mutableStateOf(false) }
|
||||
var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) }
|
||||
var excludeImages by remember { mutableStateOf(com.aryan.reader.loadExcludeImages(context)) }
|
||||
|
|
@ -335,9 +341,11 @@ fun PdfViewerScreen(
|
|||
savePdfHiddenTools(context, newSet)
|
||||
}
|
||||
|
||||
val isOss = BuildConfig.FLAVOR == "oss"
|
||||
|
||||
val executeWithOcrCheck = remember(hasSelectedOcrLanguage) {
|
||||
{ action: () -> Unit ->
|
||||
if (hasSelectedOcrLanguage) {
|
||||
if (isOss || hasSelectedOcrLanguage) {
|
||||
action()
|
||||
} else {
|
||||
pendingActionAfterOcrSelection = action
|
||||
|
|
@ -584,6 +592,9 @@ fun PdfViewerScreen(
|
|||
var customHighlightColors by remember { mutableStateOf(loadCustomHighlightColors(context)) }
|
||||
var showHighlightColorPicker by remember { mutableStateOf(false) }
|
||||
var highlightColorPickerInitialSlot by remember { mutableStateOf(PdfHighlightColor.YELLOW) }
|
||||
var isBubbleZoomModeActive by remember { mutableStateOf(false) }
|
||||
var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) }
|
||||
val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState()
|
||||
|
||||
var dockLocation by remember { mutableStateOf(initialDockLocation) }
|
||||
var dockOffset by remember { mutableStateOf(initialDockOffset) }
|
||||
|
|
@ -655,22 +666,11 @@ fun PdfViewerScreen(
|
|||
snapPreviewLocation,
|
||||
isEditMode,
|
||||
isDockDragging,
|
||||
showStandardBars,
|
||||
systemUiMode,
|
||||
statusBarHeightDp
|
||||
) {
|
||||
if (!isEditMode) {
|
||||
var h = 0.dp
|
||||
if (showStandardBars) {
|
||||
h += 56.dp
|
||||
}
|
||||
|
||||
val isStatusBarVisible = systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)
|
||||
|
||||
if (isStatusBarVisible) {
|
||||
h += statusBarHeightDp
|
||||
}
|
||||
h
|
||||
0.dp
|
||||
} else {
|
||||
val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging
|
||||
val isPreviewingTop = snapPreviewLocation == DockLocation.TOP
|
||||
|
|
@ -686,6 +686,31 @@ fun PdfViewerScreen(
|
|||
label = "verticalHeaderHeight"
|
||||
)
|
||||
|
||||
val targetTopOverlayInset = remember(
|
||||
showStandardBars,
|
||||
systemUiMode,
|
||||
statusBarHeightDp
|
||||
) {
|
||||
if (!showStandardBars) {
|
||||
0.dp
|
||||
} else {
|
||||
var inset = 56.dp
|
||||
val isStatusBarVisible =
|
||||
systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)
|
||||
|
||||
if (isStatusBarVisible) {
|
||||
inset += statusBarHeightDp
|
||||
}
|
||||
inset
|
||||
}
|
||||
}
|
||||
|
||||
val topOverlayInset by animateDpAsState(
|
||||
targetValue = targetTopOverlayInset,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "topOverlayInset"
|
||||
)
|
||||
|
||||
val verticalFooterHeight by remember(
|
||||
dockLocation,
|
||||
snapPreviewLocation,
|
||||
|
|
@ -820,9 +845,155 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isDocumentReady by remember { mutableStateOf(false) }
|
||||
|
||||
suspend fun renderSpeechBubblePrefetchBitmap(
|
||||
document: ReaderDocument,
|
||||
sourcePageIndex: Int
|
||||
): Bitmap? = withContext(Dispatchers.IO) {
|
||||
document.openPage(sourcePageIndex)?.use { page ->
|
||||
val pageWidth = page.getPageWidthPoint()
|
||||
val pageHeight = page.getPageHeightPoint()
|
||||
if (pageWidth <= 0 || pageHeight <= 0) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val longEdge = max(pageWidth, pageHeight).toFloat()
|
||||
val targetLongEdge = when (document) {
|
||||
is PdfDocumentWrapper -> 1600f.coerceAtLeast(longEdge)
|
||||
else -> min(longEdge, 1600f)
|
||||
}
|
||||
val renderScale = (targetLongEdge / longEdge).coerceAtLeast(1f)
|
||||
val renderWidth = (pageWidth * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val renderHeight = (pageHeight * renderScale).roundToInt().coerceAtLeast(1)
|
||||
val renderBitmap = Bitmap.createBitmap(renderWidth, renderHeight, Bitmap.Config.ARGB_8888)
|
||||
|
||||
try {
|
||||
page.renderPageBitmap(
|
||||
bitmap = renderBitmap,
|
||||
startX = 0,
|
||||
startY = 0,
|
||||
drawSizeX = renderWidth,
|
||||
drawSizeY = renderHeight,
|
||||
renderAnnot = true
|
||||
)
|
||||
renderBitmap
|
||||
} catch (t: Throwable) {
|
||||
renderBitmap.recycle()
|
||||
Timber.tag("BubbleZoom").w(t, "Failed to render bubble prefetch bitmap for page $sourcePageIndex")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildSpeechBubblePrefetchOrder(): List<Int> {
|
||||
if (totalDisplayPages <= 0) return emptyList()
|
||||
val ordered = LinkedHashSet<Int>()
|
||||
ordered += currentPage.coerceIn(0, totalDisplayPages - 1)
|
||||
for (distance in 1 until totalDisplayPages) {
|
||||
val next = currentPage + distance
|
||||
val previous = currentPage - distance
|
||||
if (next in 0 until totalDisplayPages) ordered += next
|
||||
if (previous in 0 until totalDisplayPages) ordered += previous
|
||||
}
|
||||
return ordered.toList()
|
||||
}
|
||||
|
||||
suspend fun detectSpeechBubblesForPage(
|
||||
sourcePageIndex: Int,
|
||||
fallbackBitmap: Bitmap,
|
||||
allowHighQualityFallback: Boolean = true
|
||||
): List<SpeechBubble> {
|
||||
val document = pdfDocument
|
||||
val shouldUsePrefetchBitmap =
|
||||
allowHighQualityFallback &&
|
||||
document != null &&
|
||||
!viewModel.hasCachedSpeechBubbles(bookId, sourcePageIndex)
|
||||
val detectionBitmap = if (shouldUsePrefetchBitmap) {
|
||||
renderSpeechBubblePrefetchBitmap(document!!, sourcePageIndex) ?: fallbackBitmap
|
||||
} else {
|
||||
fallbackBitmap
|
||||
}
|
||||
val ownsBitmap = detectionBitmap !== fallbackBitmap
|
||||
|
||||
return try {
|
||||
val detected = viewModel.detectSpeechBubblesCached(
|
||||
documentId = bookId,
|
||||
pageIndex = sourcePageIndex,
|
||||
bitmap = detectionBitmap,
|
||||
context = context
|
||||
)
|
||||
if (ownsBitmap) {
|
||||
viewModel.detectSpeechBubblesCached(
|
||||
documentId = bookId,
|
||||
pageIndex = sourcePageIndex,
|
||||
bitmap = fallbackBitmap,
|
||||
context = context
|
||||
)
|
||||
} else {
|
||||
detected
|
||||
}
|
||||
} finally {
|
||||
if (ownsBitmap && !detectionBitmap.isRecycled) {
|
||||
detectionBitmap.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
isBubbleZoomModeActive,
|
||||
isDocumentReady,
|
||||
pdfDocument,
|
||||
bookId,
|
||||
currentPage,
|
||||
totalDisplayPages,
|
||||
virtualPages
|
||||
) {
|
||||
val document = pdfDocument ?: return@LaunchedEffect
|
||||
if (!isBubbleZoomModeActive || !isDocumentReady || totalDisplayPages <= 0) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
for (displayPageIndex in buildSpeechBubblePrefetchOrder()) {
|
||||
if (!isActive) break
|
||||
|
||||
val sourcePageIndex = when (val virtualPage = virtualPages.getOrNull(displayPageIndex)) {
|
||||
is VirtualPage.PdfPage -> virtualPage.pdfIndex
|
||||
null -> displayPageIndex
|
||||
else -> continue
|
||||
}
|
||||
|
||||
if (viewModel.hasCachedSpeechBubbles(bookId, sourcePageIndex)) {
|
||||
continue
|
||||
}
|
||||
|
||||
val prefetchBitmap = renderSpeechBubblePrefetchBitmap(document, sourcePageIndex) ?: continue
|
||||
try {
|
||||
detectSpeechBubblesForPage(
|
||||
sourcePageIndex = sourcePageIndex,
|
||||
fallbackBitmap = prefetchBitmap,
|
||||
allowHighQualityFallback = false
|
||||
)
|
||||
} finally {
|
||||
if (!prefetchBitmap.isRecycled) {
|
||||
prefetchBitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
kotlinx.coroutines.yield()
|
||||
}
|
||||
}
|
||||
|
||||
val jumpHistory = remember { mutableStateListOf<Int>() }
|
||||
var showJumpPill by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(showJumpPill, jumpHistory.size) {
|
||||
if (showJumpPill && jumpHistory.isNotEmpty()) {
|
||||
delay(4000)
|
||||
showJumpPill = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentPage, isDocumentReady, totalPages, initialScrollDone) {
|
||||
if (isDocumentReady && totalPages > 0) {
|
||||
if (initialScrollDone) {
|
||||
|
|
@ -989,6 +1160,7 @@ fun PdfViewerScreen(
|
|||
var isLoadingDocument by remember { mutableStateOf(true) }
|
||||
|
||||
var selectionClearTrigger by remember { mutableLongStateOf(0L) }
|
||||
var resetZoomTrigger by remember { mutableLongStateOf(0L) }
|
||||
|
||||
val displayPageRatios by remember(pageAspectRatios, virtualPages) {
|
||||
derivedStateOf {
|
||||
|
|
@ -1879,7 +2051,6 @@ fun PdfViewerScreen(
|
|||
val onDictionaryLookupStable = remember(executeWithOcrCheck, useOnlineDictionary, selectedDictPackage, uiState.credits, isProUser) {
|
||||
{ text: String ->
|
||||
executeWithOcrCheck {
|
||||
val isOss = BuildConfig.FLAVOR == "oss"
|
||||
val effectiveUseOnline = !isOss && useOnlineDictionary
|
||||
|
||||
if (effectiveUseOnline) {
|
||||
|
|
@ -1954,6 +2125,14 @@ fun PdfViewerScreen(
|
|||
{ targetPage: Int ->
|
||||
coroutineScope.launch {
|
||||
if (targetPage in 0 until totalPages) {
|
||||
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
|
||||
if (current != targetPage) {
|
||||
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
|
||||
jumpHistory.add(current)
|
||||
showJumpPill = true
|
||||
}
|
||||
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.animateScrollToPage(targetPage)
|
||||
} else {
|
||||
|
|
@ -2700,19 +2879,8 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState.isScrollInProgress) {
|
||||
if (pagerState.isScrollInProgress && showBars) {
|
||||
showBars = false
|
||||
Timber.d("Pager scroll detected, hiding bars.")
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState.isScrollInProgress) {
|
||||
if (pagerState.isScrollInProgress) {
|
||||
if (showBars) {
|
||||
showBars = false
|
||||
Timber.d("Pager scroll detected, hiding bars.")
|
||||
}
|
||||
if (displayMode == DisplayMode.PAGINATION && !isAutoPagingForTts && (ttsState.isPlaying || ttsState.isLoading)) {
|
||||
ttsController.stop()
|
||||
}
|
||||
|
|
@ -2986,6 +3154,14 @@ fun PdfViewerScreen(
|
|||
val onInternalLinkNav: (Int) -> Unit = { targetPage ->
|
||||
coroutineScope.launch {
|
||||
if (targetPage in 0 until totalPages) {
|
||||
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
|
||||
if (current != targetPage) {
|
||||
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
|
||||
jumpHistory.add(current)
|
||||
showJumpPill = true
|
||||
}
|
||||
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.animateScrollToPage(targetPage)
|
||||
} else {
|
||||
|
|
@ -3014,16 +3190,24 @@ fun PdfViewerScreen(
|
|||
|
||||
fun navigateToPdfSearchResult(result: SearchResult) {
|
||||
currentPdfSearchResult = result
|
||||
|
||||
searchHighlightTarget = result
|
||||
|
||||
coroutineScope.launch {
|
||||
val targetPage = result.locationInSource
|
||||
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
|
||||
if (current != targetPage) {
|
||||
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
|
||||
jumpHistory.add(current)
|
||||
showJumpPill = true
|
||||
}
|
||||
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
if (pagerState.currentPage != result.locationInSource) {
|
||||
pagerState.scrollToPage(result.locationInSource)
|
||||
if (pagerState.currentPage != targetPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
} else {
|
||||
verticalReaderState.scrollToPage(result.locationInSource)
|
||||
verticalReaderState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3092,13 +3276,23 @@ fun PdfViewerScreen(
|
|||
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
|
||||
PdfNavigationDrawerContent(
|
||||
pdfDocument = pdfDocument,
|
||||
flatTableOfContents = flatTableOfContents,
|
||||
bookmarks = bookmarks,
|
||||
userHighlights = userHighlights,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalDisplayPages,
|
||||
customHighlightColors = customHighlightColors,
|
||||
onPageSelected = { targetPage ->
|
||||
coroutineScope.launch {
|
||||
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
|
||||
if (current != targetPage) {
|
||||
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
|
||||
jumpHistory.add(current)
|
||||
showJumpPill = true
|
||||
}
|
||||
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
} else {
|
||||
|
|
@ -3203,6 +3397,44 @@ fun PdfViewerScreen(
|
|||
val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) }
|
||||
when (displayMode) {
|
||||
DisplayMode.PAGINATION -> {
|
||||
val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset ->
|
||||
val canTurnPagesByTap = tapToNavigateEnabled &&
|
||||
(currentPageScale <= 1.02f || isScrollLocked)
|
||||
|
||||
if (!canTurnPagesByTap) {
|
||||
false
|
||||
} else {
|
||||
val oneQuarterWidthPx = boxMaxWidthFloat / 4f
|
||||
when {
|
||||
tapOffset.x < oneQuarterWidthPx -> {
|
||||
coroutineScope.launch {
|
||||
val targetPage =
|
||||
(pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
if (targetPage != pagerState.currentPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> {
|
||||
coroutineScope.launch {
|
||||
val targetPage =
|
||||
(pagerState.currentPage + 1).coerceAtMost(
|
||||
pagerState.pageCount - 1
|
||||
)
|
||||
if (targetPage != pagerState.currentPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
|
|
@ -3291,9 +3523,10 @@ fun PdfViewerScreen(
|
|||
|
||||
@Suppress("ControlFlowWithEmptyBody") val onDrawPagination =
|
||||
remember(pageIndex) {
|
||||
{ point: PdfPoint ->
|
||||
if (currentSelectedTool == InkType.TEXT) {
|
||||
} else if (currentSelectedTool == InkType.ERASER) {
|
||||
{ point: PdfPoint, isEraserOverride: Boolean ->
|
||||
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
||||
if (effectiveTool == InkType.TEXT) {
|
||||
} else if (effectiveTool == InkType.ERASER) {
|
||||
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
||||
val existing = allAnnotations[pageIndex] ?: emptyList()
|
||||
val toRemove = existing.filter {
|
||||
|
|
@ -3328,12 +3561,13 @@ fun PdfViewerScreen(
|
|||
|
||||
@Suppress("ControlFlowWithEmptyBody") val onDrawStartPagination =
|
||||
remember(pageIndex) {
|
||||
{ point: PdfPoint ->
|
||||
{ point: PdfPoint, isEraserOverride: Boolean ->
|
||||
if (showToolSettings) {
|
||||
showToolSettings = false
|
||||
} else {
|
||||
if (currentSelectedTool == InkType.TEXT) {
|
||||
} else if (currentSelectedTool == InkType.ERASER) {
|
||||
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
||||
if (effectiveTool == InkType.TEXT) {
|
||||
} else if (effectiveTool == InkType.ERASER) {
|
||||
lastEraserPoint = point
|
||||
erasedAnnotationsFromStroke.clear()
|
||||
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
||||
|
|
@ -3362,7 +3596,7 @@ fun PdfViewerScreen(
|
|||
drawingState.onDrawStart(
|
||||
pageIndex,
|
||||
pointWithTime,
|
||||
currentSelectedTool,
|
||||
effectiveTool,
|
||||
currentStrokeColorState,
|
||||
currentStrokeWidthState
|
||||
)
|
||||
|
|
@ -3403,7 +3637,8 @@ fun PdfViewerScreen(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
showAllTextHighlights = showAllTextHighlights,
|
||||
onHighlightLoading = { /* no-op for paginated mode */ },
|
||||
onSingleTap = onSingleTapStable,
|
||||
onPreSingleTap = onPaginationPreSingleTap,
|
||||
onSingleTap = { _ -> onSingleTapStable() },
|
||||
isProUser = isProUser,
|
||||
onShowDictionaryUpsellDialog = {
|
||||
if (useOnlineDictionary) {
|
||||
|
|
@ -3420,6 +3655,7 @@ fun PdfViewerScreen(
|
|||
onBookmarkClick = { onToggleBookmark(pageIndex) },
|
||||
isZoomEnabled = true,
|
||||
clearSelectionTrigger = selectionClearTrigger,
|
||||
resetZoomTrigger = resetZoomTrigger,
|
||||
pageAnnotations = pageAnnotationsProvider,
|
||||
drawingState = drawingState,
|
||||
onDrawStart = onDrawStartPagination,
|
||||
|
|
@ -3470,12 +3706,11 @@ fun PdfViewerScreen(
|
|||
currentActiveOffset = newOffset
|
||||
}
|
||||
},
|
||||
onDetectPanels = { bitmap ->
|
||||
Toast.makeText(context, "Scanning for panels...", Toast.LENGTH_SHORT).show()
|
||||
viewModel.detectComicPanels(bitmap, context)
|
||||
onDetectBubbles = { sourcePageIndex, bitmap ->
|
||||
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
|
||||
},
|
||||
onShowPanelPopup = { croppedBitmap ->
|
||||
poppedUpPanelBitmap = croppedBitmap
|
||||
onShowPanelPopup = { bitmapWithRects ->
|
||||
poppedUpPanelBitmap = bitmapWithRects
|
||||
},
|
||||
onTwoFingerSwipe = { direction ->
|
||||
coroutineScope.launch {
|
||||
|
|
@ -3647,7 +3882,15 @@ fun PdfViewerScreen(
|
|||
paginationDraggingBoxId = null
|
||||
}
|
||||
},
|
||||
onDragPageTurn = { /* Handled in onTextBoxDrag */ },
|
||||
onDragPageTurn = { direction ->
|
||||
coroutineScope.launch {
|
||||
val targetPage = pagerState.currentPage + direction
|
||||
if (targetPage in 0 until totalDisplayPages) {
|
||||
pagerState.animateScrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
},
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
isVisible = isVisiblePage,
|
||||
isActivePage = pagerState.currentPage == pageIndex,
|
||||
isScrolling = pagerState.isScrollInProgress
|
||||
|
|
@ -3718,12 +3961,13 @@ fun PdfViewerScreen(
|
|||
|
||||
@Suppress("ControlFlowWithEmptyBody") val onDrawStartStable =
|
||||
remember {
|
||||
{ pageIndex: Int, point: PdfPoint ->
|
||||
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
|
||||
if (showToolSettings) {
|
||||
showToolSettings = false
|
||||
} else {
|
||||
if (currentSelectedTool == InkType.TEXT) {
|
||||
} else if (currentSelectedTool == InkType.ERASER) {
|
||||
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
||||
if (effectiveTool == InkType.TEXT) {
|
||||
} else if (effectiveTool == InkType.ERASER) {
|
||||
lastEraserPoint = point
|
||||
erasedAnnotationsFromStroke.clear()
|
||||
|
||||
|
|
@ -3753,7 +3997,7 @@ fun PdfViewerScreen(
|
|||
drawingState.onDrawStart(
|
||||
pageIndex,
|
||||
pointWithTime,
|
||||
currentSelectedTool,
|
||||
effectiveTool,
|
||||
currentStrokeColorState,
|
||||
currentStrokeWidthState
|
||||
)
|
||||
|
|
@ -3763,8 +4007,9 @@ fun PdfViewerScreen(
|
|||
}
|
||||
|
||||
val onDrawStable = remember(isHighlighterSnapEnabled, isCurrentToolHighlighter, calculateSnappedPoint) {
|
||||
{ pageIndex: Int, point: PdfPoint ->
|
||||
if (currentSelectedTool == InkType.ERASER) {
|
||||
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
|
||||
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
||||
if (effectiveTool == InkType.ERASER) {
|
||||
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
||||
val existing = allAnnotations[pageIndex] ?: emptyList()
|
||||
val toRemove = existing.filter {
|
||||
|
|
@ -3915,6 +4160,11 @@ fun PdfViewerScreen(
|
|||
onZoomAndPanChanged = { newScale, newOffset ->
|
||||
currentActiveScale = newScale
|
||||
currentActiveOffset = newOffset
|
||||
},
|
||||
resetZoomTrigger = resetZoomTrigger,
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
onDetectBubbles = { sourcePageIndex, bitmap ->
|
||||
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -4108,7 +4358,7 @@ fun PdfViewerScreen(
|
|||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(top = if (showBars) verticalHeaderHeight else 0.dp)
|
||||
.padding(top = topOverlayInset)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Surface(
|
||||
|
|
@ -4138,6 +4388,55 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = bubbleZoomDownloadProgress != null,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
// shift down slightly if the OCR indicator is also showing
|
||||
.padding(top = topOverlayInset + if (isOcrModelDownloading) 64.dp else 0.dp)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
val progress = bubbleZoomDownloadProgress ?: 0f
|
||||
if (progress > 0f) {
|
||||
CircularProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
trackColor = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.2f)
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Downloading Bubble Zoom model... ${(progress * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Slider UI Overlay ---
|
||||
AnimatedVisibility(
|
||||
visible = isPageSliderVisible,
|
||||
|
|
@ -4209,6 +4508,15 @@ fun PdfViewerScreen(
|
|||
scrubDebounceJob.value = coroutineScope.launch {
|
||||
delay(200)
|
||||
if (isActive) {
|
||||
val targetPage = newValue.roundToInt()
|
||||
|
||||
if (targetPage != sliderStartPage) {
|
||||
if (jumpHistory.lastOrNull() != sliderStartPage) {
|
||||
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
|
||||
jumpHistory.add(sliderStartPage)
|
||||
}
|
||||
showJumpPill = true
|
||||
}
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.scrollToPage(
|
||||
newValue.roundToInt()
|
||||
|
|
@ -4418,10 +4726,17 @@ fun PdfViewerScreen(
|
|||
},
|
||||
onShowCustomizeTools = { showCustomizeToolsSheet = true },
|
||||
onShowOcrLanguage = {
|
||||
hasSelectedOcrLanguage = true
|
||||
showOcrLanguageDialog = true
|
||||
if (!isOss) {
|
||||
hasSelectedOcrLanguage = true
|
||||
showOcrLanguageDialog = true
|
||||
}
|
||||
},
|
||||
onShowVisualOptions = { showVisualOptionsSheet = true },
|
||||
tapToNavigateEnabled = tapToNavigateEnabled,
|
||||
onToggleTapToNavigate = {
|
||||
tapToNavigateEnabled = !tapToNavigateEnabled
|
||||
saveTapToNavigateSetting(context, tapToNavigateEnabled)
|
||||
},
|
||||
onChangeDisplayMode = { displayMode = it },
|
||||
onToggleKeepScreenOn = {
|
||||
isKeepScreenOn = !isKeepScreenOn
|
||||
|
|
@ -4481,13 +4796,28 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
},
|
||||
onNewTabClick = { showNewTabSheet = true }
|
||||
onNewTabClick = { showNewTabSheet = true },
|
||||
onGenerateDemoAnnotations = {
|
||||
val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
val demoAnnots = DemoAnnotationGenerator.generateDemoAnnotations(page)
|
||||
|
||||
if (demoAnnots.isNotEmpty()) {
|
||||
Timber.d("Debug: Generating ${demoAnnots.size} demo annotations for page $page")
|
||||
val existing = allAnnotations[page] ?: emptyList()
|
||||
allAnnotations = allAnnotations + (page to (existing + demoAnnots))
|
||||
|
||||
demoAnnots.forEach { annot ->
|
||||
undoStack.add(HistoryAction.Add(page, annot))
|
||||
}
|
||||
redoStack.clear()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ReflowProgressOverlay(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = verticalHeaderHeight)
|
||||
.padding(top = topOverlayInset)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
showStandardBars = showStandardBars,
|
||||
|
|
@ -4502,7 +4832,7 @@ fun PdfViewerScreen(
|
|||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = verticalHeaderHeight)
|
||||
.padding(top = topOverlayInset)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
if (isBackgroundIndexing) {
|
||||
|
|
@ -4673,6 +5003,56 @@ fun PdfViewerScreen(
|
|||
)
|
||||
}
|
||||
|
||||
val effectiveNavBarForPill = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp
|
||||
|
||||
val isBottomBarVisibleForPill = showStandardBars && !searchState.isSearchActive
|
||||
val targetPillBottomPadding = if (isBottomBarVisibleForPill) 56.dp + 16.dp + effectiveNavBarForPill else 16.dp + effectiveNavBarForPill
|
||||
|
||||
val pillBottomPadding by animateDpAsState(
|
||||
targetValue = targetPillBottomPadding,
|
||||
label = "PillBottomPadding"
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showJumpPill && jumpHistory.isNotEmpty(),
|
||||
enter = fadeIn() + slideInVertically { it },
|
||||
exit = fadeOut() + slideOutVertically { it },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(bottom = pillBottomPadding)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
val lastPage = jumpHistory.lastOrNull() ?: 0
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
shadowElevation = 6.dp,
|
||||
onClick = {
|
||||
val target = jumpHistory.removeLastOrNull()
|
||||
if (target != null) {
|
||||
showJumpPill = false
|
||||
coroutineScope.launch {
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.animateScrollToPage(target)
|
||||
} else {
|
||||
verticalReaderState.scrollToPage(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = "Jump Back", modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Back to Pg ${lastPage + 1}", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom Bar
|
||||
PdfBottomBar(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
|
|
@ -4687,6 +5067,20 @@ fun PdfViewerScreen(
|
|||
isEditMode = isEditMode,
|
||||
isTtsSessionActive = isTtsSessionActive,
|
||||
ttsErrorMessage = ttsState.errorMessage,
|
||||
jumpBackPage = jumpHistory.lastOrNull(),
|
||||
onJumpBack = {
|
||||
val target = jumpHistory.removeLastOrNull()
|
||||
if (target != null) {
|
||||
showJumpPill = false
|
||||
coroutineScope.launch {
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.animateScrollToPage(target)
|
||||
} else {
|
||||
verticalReaderState.scrollToPage(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onShowSlider = {
|
||||
val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
|
||||
sliderStartPage = currentPageForSlider
|
||||
|
|
@ -4736,6 +5130,16 @@ fun PdfViewerScreen(
|
|||
} else {
|
||||
startTtsWithPermissionCheck(null, null)
|
||||
}
|
||||
},
|
||||
isBubbleZoomModeActive = isBubbleZoomModeActive,
|
||||
onToggleBubbleZoom = {
|
||||
if (isOss) {
|
||||
coroutineScope.launch { snackbarHostState.showSnackbar("Bubble Zoom is only available in Playstore version of Episteme") }
|
||||
} else if (!isBubbleZoomModeActive && !viewModel.isSpeechBubbleModelAvailable(context)) {
|
||||
showBubbleZoomDownloadDialog = true
|
||||
} else {
|
||||
isBubbleZoomModeActive = !isBubbleZoomModeActive
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -5145,7 +5549,12 @@ fun PdfViewerScreen(
|
|||
exit = fadeOut()
|
||||
) {
|
||||
val percentage = (currentPageScale * 100).roundToInt()
|
||||
ZoomPercentageIndicator(percentage = percentage)
|
||||
ZoomPercentageIndicator(
|
||||
percentage = percentage,
|
||||
onResetZoomClick = {
|
||||
resetZoomTrigger = System.currentTimeMillis()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
|
||||
|
|
@ -5465,7 +5874,7 @@ fun PdfViewerScreen(
|
|||
) {
|
||||
Image(
|
||||
bitmap = poppedUpPanelBitmap!!.asImageBitmap(),
|
||||
contentDescription = "Zoomed Panel",
|
||||
contentDescription = "Annotated Page",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
|
|
@ -5485,7 +5894,7 @@ fun PdfViewerScreen(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close Panel",
|
||||
contentDescription = "Close Image",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
|
@ -5500,6 +5909,30 @@ fun PdfViewerScreen(
|
|||
onConfirm = { password -> documentPassword = password })
|
||||
}
|
||||
|
||||
if (showBubbleZoomDownloadDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showBubbleZoomDownloadDialog = false },
|
||||
icon = { Icon(Icons.Default.Info, contentDescription = null) },
|
||||
title = { Text("Download Bubble Zoom Model") },
|
||||
text = {
|
||||
Text("To use the Bubble Zoom feature, an AI model needs to be downloaded (~134 MB). Do you want to download it now?")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showBubbleZoomDownloadDialog = false
|
||||
viewModel.downloadSpeechBubbleModel(context)
|
||||
}) {
|
||||
Text("Download")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showBubbleZoomDownloadDialog = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showNewTabSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showNewTabSheet = false },
|
||||
|
|
@ -5656,7 +6089,7 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (showOcrLanguageDialog) {
|
||||
if (showOcrLanguageDialog && !isOss) {
|
||||
OcrLanguageSelectionDialog(
|
||||
currentLanguage = ocrLanguage,
|
||||
isFirstRun = !hasSelectedOcrLanguage,
|
||||
|
|
@ -6037,6 +6470,17 @@ fun PdfViewerScreen(
|
|||
currentTtsMode = currentTtsMode,
|
||||
isCollapsed = isTtsCollapsed,
|
||||
onCollapseChange = { isTtsCollapsed = it },
|
||||
onLocateCurrentChunk = {
|
||||
ttsPageData?.pageIndex?.let { targetPage ->
|
||||
coroutineScope.launch {
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
} else {
|
||||
verticalReaderState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenTtsSettings = { showTtsSettingsSheet = true },
|
||||
onClose = {
|
||||
ttsController.stop()
|
||||
|
|
@ -6165,4 +6609,4 @@ fun PdfViewerScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,7 +147,35 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
if (tts == null) return
|
||||
|
||||
try {
|
||||
val preferredVoiceName = loadNativeVoice(context) ?: return
|
||||
val preferredVoiceName = loadNativeVoice(context)
|
||||
|
||||
if (preferredVoiceName.isNullOrBlank()) {
|
||||
val defaultLocale = Locale.getDefault()
|
||||
try {
|
||||
tts?.language = defaultLocale
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "BaseTts: Failed to restore default language")
|
||||
}
|
||||
|
||||
val defaultVoice = try {
|
||||
tts?.defaultVoice ?: tts?.voices?.firstOrNull { voice ->
|
||||
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
|
||||
} ?: tts?.voices?.firstOrNull { voice ->
|
||||
voice.locale == defaultLocale
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "BaseTts: Failed to query default voice")
|
||||
null
|
||||
}
|
||||
|
||||
if (defaultVoice != null && tts?.voice?.name != defaultVoice.name) {
|
||||
Timber.d("BaseTts: Restoring system default voice to ${defaultVoice.name} (${defaultVoice.locale})")
|
||||
tts?.voice = defaultVoice
|
||||
} else {
|
||||
Timber.d("BaseTts: Using engine default voice for locale $defaultLocale")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (tts?.voice?.name == preferredVoiceName) return
|
||||
|
||||
|
|
@ -269,4 +297,4 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
}
|
||||
|
||||
private class ZombieEngineException : Exception("Engine failed to start")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ class TtsController(context: Context) : Player.Listener {
|
|||
bookTitle: String,
|
||||
chapterTitle: String?,
|
||||
coverImageUri: String?,
|
||||
chapterIndex: Int? = null,
|
||||
ttsMode: TtsPlaybackManager.TtsMode,
|
||||
playbackSource: String = "READER",
|
||||
authToken: String? = null
|
||||
|
|
@ -179,6 +180,7 @@ class TtsController(context: Context) : Player.Listener {
|
|||
putString(KEY_BOOK_TITLE, bookTitle)
|
||||
putString(KEY_CHAPTER_TITLE, chapterTitle)
|
||||
putString(KEY_COVER_IMAGE_URI, coverImageUri)
|
||||
chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
|
||||
putString(KEY_TTS_MODE, ttsMode.name)
|
||||
putString(KEY_PLAYBACK_SOURCE, playbackSource)
|
||||
putString(KEY_AUTH_TOKEN, authToken)
|
||||
|
|
@ -252,6 +254,8 @@ class TtsController(context: Context) : Player.Listener {
|
|||
val isLoading = customState.getBoolean("isLoading", false)
|
||||
val sessionFinished = customState.getBoolean("sessionFinished", false)
|
||||
val playbackSource = customState.getString("playbackSource")
|
||||
val serviceBookTitle = customState.getString("bookTitle")
|
||||
val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 }
|
||||
|
||||
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
|
||||
val sourceCfi = mediaItemExtras?.getString("sourceCfi")
|
||||
|
|
@ -270,6 +274,16 @@ class TtsController(context: Context) : Player.Listener {
|
|||
if (isLoading) currentState.currentText else null
|
||||
},
|
||||
errorMessage = customState.getString("errorMessage"),
|
||||
bookTitle = if (isPlaybackActive) {
|
||||
currentMediaItem?.mediaMetadata?.artist?.toString() ?: serviceBookTitle
|
||||
} else {
|
||||
if (isLoading) currentState.bookTitle else serviceBookTitle
|
||||
},
|
||||
chapterIndex = if (isPlaybackActive || isLoading) {
|
||||
serviceChapterIndex ?: currentState.chapterIndex
|
||||
} else {
|
||||
serviceChapterIndex
|
||||
},
|
||||
speakerId = serviceSpeaker,
|
||||
sourceCfi = if (isPlaybackActive) {
|
||||
sourceCfi
|
||||
|
|
@ -338,4 +352,4 @@ fun rememberTtsController(): TtsController {
|
|||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ const val KEY_WORD_TIMESTAMPS = "KEY_WORD_TIMESTAMPS"
|
|||
const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS"
|
||||
const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
|
||||
const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN"
|
||||
const val KEY_CHAPTER_INDEX = "KEY_CHAPTER_INDEX"
|
||||
|
||||
private const val PREFETCH_LOOKAHEAD = 3
|
||||
|
||||
|
|
@ -100,6 +101,8 @@ class TtsPlaybackManager(
|
|||
val isLoading: Boolean = false,
|
||||
val currentText: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val bookTitle: String? = null,
|
||||
val chapterIndex: Int? = null,
|
||||
val speakerId: String = DEFAULT_SPEAKER_ID,
|
||||
val sourceCfi: String? = null,
|
||||
val startOffsetInSource: Int = -1,
|
||||
|
|
@ -189,6 +192,7 @@ class TtsPlaybackManager(
|
|||
val bookTitle = args.getString(KEY_BOOK_TITLE)
|
||||
val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
|
||||
val coverImageUri = args.getString(KEY_COVER_IMAGE_URI)
|
||||
val chapterIndex = args.getInt(KEY_CHAPTER_INDEX, -1).takeIf { it >= 0 }
|
||||
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
|
||||
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
|
||||
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
|
||||
|
|
@ -204,7 +208,7 @@ class TtsPlaybackManager(
|
|||
|
||||
val authToken = args.getString(KEY_AUTH_TOKEN)
|
||||
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
|
||||
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, ttsMode, playbackSource, args)
|
||||
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, ttsMode, playbackSource, args)
|
||||
}
|
||||
STOP_TTS_COMMAND -> {
|
||||
Timber.d("Received STOP command.")
|
||||
|
|
@ -337,6 +341,7 @@ class TtsPlaybackManager(
|
|||
bookTitle: String?,
|
||||
chapterTitle: String?,
|
||||
coverImageUri: String?,
|
||||
chapterIndex: Int?,
|
||||
ttsMode: TtsMode,
|
||||
playbackSource: String?,
|
||||
args: Bundle // Added this parameter
|
||||
|
|
@ -375,6 +380,8 @@ class TtsPlaybackManager(
|
|||
|
||||
_ttsState.value = TtsState(
|
||||
isLoading = true,
|
||||
bookTitle = bookTitle,
|
||||
chapterIndex = chapterIndex,
|
||||
speakerId = speakerId,
|
||||
playbackSource = playbackSource,
|
||||
ttsMode = ttsMode.name
|
||||
|
|
@ -857,6 +864,8 @@ class TtsPlaybackManager(
|
|||
val bundle = Bundle().apply {
|
||||
putBoolean("isLoading", state.isLoading)
|
||||
putString("errorMessage", state.errorMessage)
|
||||
putString("bookTitle", state.bookTitle)
|
||||
putInt("chapterIndex", state.chapterIndex ?: -1)
|
||||
putString("speakerId", state.speakerId)
|
||||
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
|
||||
putString("currentWordSourceCfi", state.currentWordSourceCfi)
|
||||
|
|
@ -897,4 +906,4 @@ class TtsPlaybackManager(
|
|||
}
|
||||
Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
app/src/main/res/drawable-nodpi/comic_bubble.xml
Normal file
10
app/src/main/res/drawable-nodpi/comic_bubble.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M440,157L357,240L240,240L240,357L157,440L240,523L240,640L357,640L440,723L540,623L708,708L622,541L723,440L640,357L640,240L523,240L440,157ZM440,44L556,160L720,160L720,324L836,440L720,556L835,782Q842,795 839,807.5Q836,820 828,828Q820,836 807.5,839Q795,842 782,835L556,720L440,836L324,720L160,720L160,556L44,440L160,324L160,160L324,160L440,44ZM440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440Z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable-nodpi/pin_drop.xml
Normal file
10
app/src/main/res/drawable-nodpi/pin_drop.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,659Q579,579 629.5,505Q680,431 680,366Q680,276 624,218Q568,160 480,160Q392,160 336,218Q280,276 280,366Q280,431 330.5,505Q381,579 480,659ZM480,760Q339,656 269.5,558Q200,460 200,366Q200,241 278,160.5Q356,80 480,80Q604,80 682,160.5Q760,241 760,366Q760,460 690.5,558Q621,656 480,760ZM480,440Q513,440 536.5,416.5Q560,393 560,360Q560,327 536.5,303.5Q513,280 480,280Q447,280 423.5,303.5Q400,327 400,360Q400,393 423.5,416.5Q447,440 480,440ZM200,880L200,800L760,800L760,880L200,880ZM480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable-nodpi/tag.xml
Normal file
10
app/src/main/res/drawable-nodpi/tag.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable-nodpi/zoom_out.xml
Normal file
10
app/src/main/res/drawable-nodpi/zoom_out.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M120,840L120,600L200,600L200,704L324,580L380,636L256,760L360,760L360,840L120,840ZM600,840L600,760L704,760L580,636L636,580L760,704L760,600L840,600L840,840L600,840ZM324,380L200,256L200,360L120,360L120,120L360,120L360,200L256,200L380,324L324,380ZM636,380L580,324L704,200L600,200L600,120L840,120L840,360L760,360L760,256L636,380Z"/>
|
||||
</vector>
|
||||
15
app/src/main/res/values/font_certs.xml
Normal file
15
app/src/main/res/values/font_certs.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<array name="com_google_android_gms_fonts_certs">
|
||||
<item>@array/com_google_android_gms_fonts_certs_dev</item>
|
||||
<item>@array/com_google_android_gms_fonts_certs_prod</item>
|
||||
</array>
|
||||
<string-array name="com_google_android_gms_fonts_certs_dev">
|
||||
<item>
|
||||
+AR+A7/jH//X4ZqHkEQbMv48/pP81n0EEM0O4j2DkIf0Q4zKio2V29y5k2A0RNDK8zF54e1/xQnIfI0+T6Xp/3E/c9vA43T2Z1z7zU3L5+Vb+d0xT3i1oB4x/3/KjDq/Y1yE4j1NfIq1o001zWwN+P1Qx24GjQk4/z8/Ua01mB2I+rTq0L3H6n4wT//5c/R9oP4vM8gT9yD6vE8F+H/T8fQpM+0T6o+0O0/0N+0D7f0z4o+sT1/0T/D/T/O8/3/6/v/0E/5M/k/+z/2//Q/7//g/4//R/2/+f/1/8D/l/wT8/30/1P/d/xT1z/yT/1/X/wP81v/F9Z/wX43/y/4b/Tf4x/6/3T4/+/x+Z+H6y/sP9r/9f/h/8D+w/1P/C/4r/Jv63/e/1b4d3L+kE6qJ9oR+A69M8H+T1z4qE/aH/k/0/1r3xP8j/+P9v/w//P/hP9//3f7z9Z/xPxL9n/P//f9z81n3d/3P/g/6L+H/5v9J/r/9n/wP9b/yf8V9w/t34f+rT3T/R/0X/f/3P4z+QTxz9aP2A+v//X+7/wT9mP43+A/hT5k9T/uX9O/oP7P9sP0D/3P7j+tP03/bPyP+A/+z8U/Qf8Z/9/137X7/X91/qL67/s/3/5N/f/5H7hP6P8b/kE+n1w+yL70P65/f/sP9x+4z6//f/8X9D/wL/o/5//oP65/6P51/aP9J/Q/2P4z99f6U9H/d/6j+uPxf7r/Y/3f+p/+f8X/r/438Z/0/y1/1T/x/2X+Fv7l+K/4h/Vn+4//H91P8L+K/2n/r/6v/aP9J/RfjH3m/3H9mP9A/wD/Gf51/eP4v/P/7b9D/sT9F/k/93+A/2v+O/7X8m/pf7v/S/1v+g/xL9tPxf/2f2D/A/97/T/5//Fv8D+Q/xv9e/Q/7N/tP9l/uX9b+GfxD+e/o37P/B/3n/x/6r/Ff53/S/7b+rP8P/M/9v/A/71/R/wf/1/2X+2Px3/N/8t/q/5/6E9X/9z95P3d/D/+1/d/yf+Xf6L/mP/F/Q/w/wX+g/0v9T99v//+Tf6d/Afxz9z/iP4//r/9n+Bf5N/f/87+X/5P9f/b34X+e/tf9l/+7/Ff+f+Q/l/8v/s/4X+I/t3+2vwv/1f1T+uP0//r/6X/Z/4//C/0r/p/+3/rP1A+oP8A/wv/kPxj9n/b35f/a/7/2//v//z+pP6//S//hP1k/eD6X9rP+D/z3+j/2//sPw//1n+r/yf/D/rT+x/1v8T/t3+P//r9G/iH/aPwA+9L+P/x/+D/yv/Tf5J/aH91/+L/0P6B+Xv4//0n/Gf6f9jfwP8T/m/93/hH/b//z/Xf6f/z/+c/xv+f/3T/Uf1z/7D9x/+H/sP+b/vP63/uP9f/5n9M/wf8m/z/3H9U/1v/h/w/9X/wD+Q/sP8p/i3+0f8t/U/73+Tf0P+FfwH+j/1T/s/+E/7n+M/6H/xP/S/13+1/65/5H9mH7v+8P5z/jT+vfyf/4/+Xv6V/9P/h/6d/S/+F/+T9c/4z/aP4r/t3+d/5n9E/q/+tfxr+Bv8v+a/4f/R3+H/6f7P+qvwz+m//z+xvwB+e33H+Pvz3/7v6v/Qv4v/T/2r/w/55+s/+B/p3/sP2v+Gf17+3P9D/wz/n/4X/tH+z/0T+1v4j+8/0b+1f6r/s39I/4T/oP9b/jH6r/8v6z+sX9b/7f/P/6x/2j/6v63/Ff1b/5P4V/R/+H/rf9c/mX77/uX/7v2d+L/wf9J/4r/Yf7/+/fxb+R/9b99f6v/x3+t/1P+Tf8n/D/4z/s/5n/w/+k/5f+Tf9A/6L/5n9n/+f79+Nn+t/uP7T/uP9r/7T/C/wD/2/2h/c/+P/qD9L/g/+R/8H+Pvw/6z+nfxn8A/0n/1f/n/zP/A/37/Fv5/9H/h/9r//H7w/oT81/y/+g/2D+M/4T/Bv/B/qD+RfxP+8/wv+c/xX+h//f7T//3+s//z9vfy39D/of8T/xf59/p/6r/mH/1v2l/f/85/rf8H/xf+5/wH+j//b9ePwL+rP5H/vT7v+GfxB+4P3f/Wf65/f//n9Yf+j+jfxA/nL9v/7H+9P5J+H/iP+m/xf+D/6P8N/6X/2f4X/zH/k/8D+dPxH+lP5/+R/tD/fP+g/9P6z/b32V/tH/gP/z/D/3//4v6b/b/3v+Gf67+L/2f/uPx//gP81/uX6N/03/E/+d/j3/l/0b/gH9D/g/+k/s7+u/yD8B/+r9aPy/9zP91/wf/xP2v/K/yT8N/kf9T/9P8/Pxn+X/7z+sH7r/s37xP0B+P/yP+R/+D8v/sH67+X/5r+p/8V+R/9b92/1f9K/v/59/j/8s/gH8L/pf97/1/+d/3H80/1H8D//b85/vv65+m/x7/d/xP/S39w/7P+s/0j/c/7T/K/1r/p/+9/5/+f/t/47/A/1H/hPx7/E/+j+Q/xP/A/wf+hP/Z/f/2X/o/65/U36B/g/9gP8//Gf1B+M/x/+ZPxB+2T7L/hL9n/0H+U/8J/Tf+A/w3+6PzT9mP//9F/sf6n/Vn83f6f9lP/D9237B/Pfx/99fy/+xP2j/Xn/n/Z3/h/zj+I/+j+qPwv+m/wf+c/7L9R/4P/D/xP9vfj78rPz79j/rT+s//n9fP6F/tP+S/7n+hPxv/U/4b/Jfy/+kPy//s37r/t/9w/vH79P1R/2r9q/vv8Qf//8G/fH5r+/X1E/2v7GfvN8gP//91//5+L/1j+J//l+Ff6R+vP+v+Yv/v91v8D/f/6T/0j+qfwx/Y392v+v+Z/x7/fH89/lH8A/45/p38hP+/9137H/+r8//9r8n/+X5r/l362PyF+0n5Xf2p/l3+Bf9H+23/1/yv/m38mP///2P/Gf+C/g==</item>
|
||||
</string-array>
|
||||
<string-array name="com_google_android_gms_fonts_certs_prod">
|
||||
<item>
|
||||
+AR+A7/jH//X4ZqHkEQbMv48/pP81n0EEM0O4j2DkIf0Q4zKio2V29y5k2A0RNDK8zF54e1/xQnIfI0+T6Xp/3E/c9vA43T2Z1z7zU3L5+Vb+d0xT3i1oB4x/3/KjDq/Y1yE4j1NfIq1o001zWwN+P1Qx24GjQk4/z8/Ua01mB2I+rTq0L3H6n4wT//5c/R9oP4vM8gT9yD6vE8F+H/T8fQpM+0T6o+0O0/0N+0D7f0z4o+sT1/0T/D/T/O8/3/6/v/0E/5M/k/+z/2//Q/7//g/4//R/2/+f/1/8D/l/wT8/30/1P/d/xT1z/yT/1/X/wP81v/F9Z/wX43/y/4b/Tf4x/6/3T4/+/x+Z+H6y/sP9r/9f/h/8D+w/1P/C/4r/Jv63/e/1b4d3L+kE6qJ9oR+A69M8H+T1z4qE/aH/k/0/1r3xP8j/+P9v/w//P/hP9//3f7z9Z/xPxL9n/P//f9z81n3d/3P/g/6L+H/5v9J/r/9n/wP9b/yf8V9w/t34f+rT3T/R/0X/f/3P4z+QTxz9aP2A+v//X+7/wT9mP43+A/hT5k9T/uX9O/oP7P9sP0D/3n7j+tP03/bPyP+A/+z8U/Qf8Z/9/137X7/X91/qL67/s/3/5N/f/5H7hP6P8b/kE+n1w+yL70P65/f/sP9x+4z6//f/8X9D/wL/o/5//oP65/6P51/aP9J/Q/2P4z99f6U9H/d/6j+uPxf7r/Y/3f+p/+f8X/r/438Z/0/y1/1T/x/2X+Fv7l+K/4h/Vn+4//H91P8L+K/2n/r/6v/aP9J/RfjH3m/3H9mP9A/wD/Gf51/eP4v/P/7b9D/sT9F/k/93+A/2v+O/7X8m/pf7v/S/1v+g/xL9tPxf/2f2D/A/97/T/5//Fv8D+Q/xv9e/Q/7N/tP9l/uX9b+GfxD+e/o37P/B/3n/x/6r/Ff53/S/7b+rP8P/M/9v/A/71/R/wf/1/2X+2Px3/N/8t/q/5/6E9X/9z95P3d/D/+1/d/yf+Xf6L/mP/F/Q/w/wX+g/0v9T99v//+Tf6d/Afxz9z/iP4//r/9n+Bf5N/f/87+X/5P9f/b34X+e/tf9l/+7/Ff+f+Q/l/8v/s/4X+I/t3+2vwv/1f1T+uP0//r/6X/Z/4//C/0r/p/+3/rP1A+oP8A/wv/kPxj9n/b35f/a/7/2//v//z+pP6//S//hP1k/eD6X9rP+D/z3+j/2//sPw//1n+r/yf/D/rT+x/1v8T/t3+P//r9G/iH/aPwA+9L+P/x/+D/yv/Tf5J/aH91/+L/0P6B+Xv4//0n/Gf6f9jfwP8T/m/93/hH/b//z/Xf6f/z/+c/xv+f/3T/Uf1z/7D9x/+H/sP+b/vP63/uP9f/5n9M/wf8m/z/3H9U/1v/h/w/9X/wD+Q/sP8p/i3+0f8t/U/73+Tf0P+FfwH+j/1T/s/+E/7n+M/6H/xP/S/13+1/65/5H9mH7v+8P5z/jT+vfyf/4/+Xv6V/9P/h/6d/S/+F/+T9c/4z/aP4r/t3+d/5n9E/q/+tfxr+Bv8v+a/4f/R3+H/6f7P+qvwz+m//z+xvwB+e33H+Pvz3/7v6v/Qv4v/T/2r/w/55+s/+B/p3/sP2v+Gf17+3P9D/wz/n/4X/tH+z/0T+1v4j+8/0b+1f6r/s39I/4T/oP9b/jH6r/8v6z+sX9b/7f/P/6x/2j/6v63/Ff1b/5P4V/R/+H/rf9c/mX77/uX/7v2d+L/wf9J/4r/Yf7/+/fxb+R/9b99f6v/x3+t/1P+Tf8n/D/4z/s/5n/w/+k/5f+Tf9A/6L/5n9n/+f79+Nn+t/uP7T/uP9r/7T/C/wD/2/2h/c/+P/qD9L/g/+R/8H+Pvw/6z+nfxn8A/0n/1f/n/zP/A/37/Fv5/9H/h/9r//H7w/oT81/y/+g/2D+M/4T/Bv/B/qD+RfxP+8/wv+c/xX+h//f7T//3+s//z9vfy39D/of8T/xf59/p/6r/mH/1v2l/f/85/rf8H/xf+5/wH+j//b9ePwL+rP5H/vT7v+GfxB+4P3f/Wf65/f//n9Yf+j+jfxA/nL9v/7H+9P5J+H/iP+m/xf+D/6P8N/6X/2f4X/zH/k/8D+dPxH+lP5/+R/tD/fP+g/9P6z/b32V/tH/gP/z/D/3//4v6b/b/3v+Gf67+L/2f/uPx//gP81/uX6N/03/E/+d/j3/l/0b/gH9D/g/+k/s7+u/yD8B/+r9aPy/9zP91/wf/xP2v/K/yT8N/kf9T/9P8/Pxn+X/7z+sH7r/s37xP0B+P/yP+R/+D8v/sH67+X/5r+p/8V+R/9b92/1f9K/v/59/j/8s/gH8L/pf97/1/+d/3H80/1H8D//b85/vv65+m/x7/d/xP/S39w/7P+s/0j/c/7T/K/1r/p/+9/5/+f/t/47/A/1H/hPx7/E/+j+Q/xP/A/wf+hP/Z/f/2X/o/65/U36B/g/9gP8//Gf1B+M/x/+ZPxB+2T7L/hL9n/0H+U/8J/Tf+A/w3+6PzT9mP//9F/sf6n/Vn83f6f9lP/D9237B/Pfx/99fy/+xP2j/Xn/n/Z3/h/zj+I/+j+qPwv+m/wf+c/7L9R/4P/D/xP9vfj78rPz79j/rT+s//n9fP6F/tP+S/7n+hPxv/U/4b/Jfy/+kPy//s37r/t/9w/vH79P1R/2r9q/vv8Qf//8G/fH5r+/X1E/2v7GfvN8gP//91//5+L/1j+J//l+Ff6R+vP+v+Yv/v91v8D/f/6T/0j+qfwx/Y392v+v+Z/x7/fH89/lH8A/45/p38hP+/9137H/+r8//9r8n/+X5r/l362PyF+0n5Xf2p/l3+Bf9H+23/1/yv/m38mP///2P/Gf+C/g==</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
|
@ -850,6 +850,9 @@
|
|||
<string name="label_font_size">Font Size</string>
|
||||
<string name="label_line_height">Line Height</string>
|
||||
<string name="label_paragraph_gap">Paragraph Gap</string>
|
||||
<string name="label_image_size">Image Size</string>
|
||||
<string name="label_horizontal_margin">Horizontal Margin</string>
|
||||
<string name="label_none">None</string>
|
||||
<!-- Short label for the "Original" font option in the reader settings. "Orig" is an abbreviation. -->
|
||||
<string name="label_original">Orig</string>
|
||||
<!-- Typographic sample label shown in the font size picker — "Aa" represents text size visually. Do not translate. -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue