diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6f6b402..b10513d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { applicationId = "com.aryan.reader" minSdk = 26 targetSdk = 35 - versionCode = 41 - versionName = "1.0.40" + versionCode = 42 + versionName = "1.0.41" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index fc75007..64d39de 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -588,7 +588,9 @@ 300, ); - if (window.reportScrollState) { + if (window.triggerInitialScrollStateReport) { + window.triggerInitialScrollStateReport(); + } else if (window.reportScrollState) { setTimeout(window.reportScrollState, 60); } }; @@ -693,26 +695,20 @@ window.triggerInitialScrollStateReport = function () { var attempts = 0; - var maxAttempts = 7; - var baseInterval = 100; + var maxAttempts = 15; function tryReport() { attempts++; - window.reportScrollState(); - var currentClientHeight = document.documentElement.clientHeight || window.innerHeight || 0; - - if (currentClientHeight > 0) { - setTimeout(window.reportScrollState, 50); - return; + if (window.reportScrollState) { + window.reportScrollState(); } if (attempts < maxAttempts) { - var retryDelay = baseInterval + attempts * 50; - setTimeout(tryReport, retryDelay); + setTimeout(tryReport, 200); } } - setTimeout(tryReport, baseInterval); + setTimeout(tryReport, 50); }; window.scrollToChapterStart = function () { @@ -1418,223 +1414,131 @@ } function resolveCfiPath(rootElement, path) { - log(`Attempting to resolve path '${path}' from root <$ { - rootElement.tagName - } - - >`); let currentNode = rootElement; const steps = path.substring(1).split("/").map(Number); for (let i = 0; i < steps.length; i++) { const cfiIndex = steps[i]; + if (!currentNode) return null; - if (!currentNode) { - log(`Traversal failed: currentNode became null before step $ { - i + // Handle virtualized content container specially + if (currentNode.id === 'content-container') { + const childNodeIndex = (cfiIndex - 2) / 2; + let chunkIndex = Math.floor(childNodeIndex / 20); + let indexInChunk = childNodeIndex % 20; + + let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`); + if (chunkElement) { + let elementsInChunk = Array.from(chunkElement.childNodes).filter(n => n.nodeType === Node.ELEMENT_NODE); + if (indexInChunk >= 0 && indexInChunk < elementsInChunk.length) { + currentNode = elementsInChunk[indexInChunk]; + continue; + } } - - (CFI index $ { - cfiIndex - }).`); return null; } - const elementChildren = Array.from(currentNode.childNodes).filter((node) => node.nodeType === Node.ELEMENT_NODE); + let elementChildren = Array.from(currentNode.childNodes).filter((node) => node.nodeType === Node.ELEMENT_NODE); const childNodeIndex = (cfiIndex - 2) / 2; if (childNodeIndex >= 0 && childNodeIndex < elementChildren.length) { currentNode = elementChildren[childNodeIndex]; } else { - const childrenTags = elementChildren - .map( - (node) => `<$ { - node.tagName || "TEXT" - } - - >`, - ) - .join(", "); - - log(`Step $ { - i - } - - : FAILED. CFI index $ { - cfiIndex - } - - (child index $ { - childNodeIndex - - }) is out of bounds for $ { - elementChildren.length - } - - element children: [$ { - childrenTags - } - - ]`); - - log(`Parent Node HTML at failure point (<$ { - currentNode.tagName - } - - >): $ { - currentNode.innerHTML.substring(0, 300) - } - - ...`); // ADD THIS LINE - return null; // Path is invalid from this root + return null; } } - return currentNode; } window.getNodeAndOffsetFromCfi = function (cfi) { - log(`getNodeAndOffsetFromCfi called with: $ { - cfi - } - - `); - try { var pathParts = cfi.split(":"); var nodePath = pathParts[0]; var charOffset = pathParts.length > 1 ? parseInt(pathParts[1], 10) : 0; - log(`Parsed CFI: path=$ { - nodePath - } - - , offset=$ { - charOffset - } - - `); - let cfiRoot = document.getElementById("content-container") || document.body; let pathToResolve = nodePath; - const firstChunk = cfiRoot.querySelector("[data-chunk-index]"); - - if (firstChunk && pathToResolve.startsWith("/4/")) { - log(`Paginator CFI detected. Adjusting root to first chunk and stripping '/4' from path.`); - cfiRoot = firstChunk; + // Strip the /4 prefix because cfiRoot (content-container or body) represents /4 + if (pathToResolve.startsWith("/4/")) { pathToResolve = "/" + pathToResolve.substring(3); + } else if (pathToResolve === "/4") { + pathToResolve = ""; } - log(`CFI Root is <$ { - cfiRoot.tagName + if (!pathToResolve) { + return { node: cfiRoot, offset: charOffset }; } - >, Path to resolve is $ { - pathToResolve - } - - `); - let resolvedNode = resolveCfiPath(cfiRoot, pathToResolve); - log(`Resolution attempt #1 (from CFI root) result: $ { - resolvedNode ? resolvedNode.tagName : 'null' - } - - `); - - if (!resolvedNode) { - log("Resolution failed from all roots."); - return null; - } + if (!resolvedNode) return null; let currentNode = resolvedNode; - log(`Successfully resolved containing element: <$ { - currentNode.tagName || 'TEXT_NODE' - } - - >`); - if (currentNode.nodeType === Node.ELEMENT_NODE) { const treeWalker = document.createTreeWalker(currentNode, NodeFilter.SHOW_TEXT, null, false); const firstTextNode = treeWalker.nextNode(); - if (firstTextNode) { - log(`Found first text node inside element to apply offset.`); currentNode = firstTextNode; - } else { - log(`Could not find a text node inside the target element. Using the element itself.`); } } return { node: currentNode, offset: charOffset }; } catch (e) { - log(`ERROR in getNodeAndOffsetFromCfi: $ { - e.message - } - - `); return null; } }; window.getCfiPathForElement = function (element, charOffset) { const logStack = []; - try { - var path = []; + var path =[]; var currentNode = element; if (currentNode.nodeType === Node.TEXT_NODE) { - logStack.push(`Initial node is a TEXT_NODE. Calculating cumulative offset.`); - - // --- FIX: Accumulate offsets from previous siblings --- var accumulatedOffset = charOffset || 0; var sibling = currentNode.previousSibling; - while (sibling) { if (sibling.nodeType === Node.TEXT_NODE) { accumulatedOffset += sibling.nodeValue.length; } else if (sibling.nodeType === Node.ELEMENT_NODE) { - // Elements like , contribute text content to the flow accumulatedOffset += (sibling.textContent || "").length; } - sibling = sibling.previousSibling; } - - logStack.push(`Original offset: $ { - charOffset - } - - , Cumulative offset: $ { - accumulatedOffset - } - - `); charOffset = accumulatedOffset; - // ----------------------------------------------------- - - logStack.push(`Using its parent <$ { - currentNode.parentNode.tagName - } - - > for path generation.`); currentNode = currentNode.parentNode; } const root = document.getElementById("content-container") || document.body; - logStack.push(`Using <$ { - root.tagName - } - - id='${root.id}' class='${root.className}' > as the consistent CFI root.`); - while (currentNode && currentNode !== root && currentNode.parentNode) { - const parentNode = currentNode.parentNode; + let parentNode = currentNode.parentNode; + + if (parentNode.classList && parentNode.classList.contains('chunk-container')) { + let trueParent = parentNode.parentNode; + let elementSiblingsInChunk = Array.from(parentNode.childNodes).filter(node => node.nodeType === Node.ELEMENT_NODE); + let indexInChunk = elementSiblingsInChunk.indexOf(currentNode); + + if (indexInChunk === -1) { + currentNode = trueParent; + continue; + } + + let chunkIndex = parseInt(parentNode.dataset.chunkIndex, 10); + let elementsInPrecedingChunks = chunkIndex * 20; + + let trueIndex = elementsInPrecedingChunks + indexInChunk; + let cfiIndex = trueIndex * 2 + 2; + path.unshift(cfiIndex); + + logStack.push(`Chunk ${chunkIndex}, IdxInChunk ${indexInChunk}, TrueIdx ${trueIndex} -> CFI /${cfiIndex}`); + + currentNode = trueParent; + continue; + } + const elementSiblings = Array.from(parentNode.childNodes).filter((node) => node.nodeType === Node.ELEMENT_NODE); const nodeIndex = elementSiblings.indexOf(currentNode); @@ -1645,110 +1549,56 @@ const cfiIndex = nodeIndex * 2 + 2; path.unshift(cfiIndex); - - const childrenTags = elementSiblings - .map( - (node) => `<$ { - node.tagName || "TEXT" - } - - >`, - ) - .join(", "); - - logStack.push(`GENERATION: Parent <$ { - parentNode.tagName - } - - > has $ { - elementSiblings.length - } - - element children: [$ { - childrenTags - } - - ]. Current node <$ { - currentNode.tagName - } - - > is at index $ { - nodeIndex - } - - , becoming CFI step $ { - cfiIndex - } - - . Path: /$ { - path.join('/') - } - - `); - currentNode = parentNode; } - var cfi = `/` + path.join("/"); - + var cfi = "/4"; + if (path.length > 0) { + cfi += "/" + path.join("/"); + } if (charOffset !== undefined && charOffset > 0) { cfi += ":" + charOffset; } - - logStack.push(`Final CFI generated: $ { - cfi - } - - `); - + logStack.push(`Generated CFI: ${cfi}`); return { cfi: cfi, log: logStack }; } catch (e) { - logStack.push(`ERROR in getCfiPathForElement: $ { - e.message - } - - `); - - return { cfi: `/2`, log: logStack }; + logStack.push("Error: " + e.message); + return { cfi: "/4/2", log: logStack }; } }; window.getCurrentCfi = function() { - const debugLog = []; - let finalCfi = "/2"; // Fallback to root - - function logCfi(m) { debugLog.push(m); } + const debugLog =[]; + let finalCfi = "/4/2"; try { const viewportX = window.innerWidth / 2; - // Probe slightly further down to avoid headers/padding issues - const viewportY = window.VIEWPORT_PADDING_TOP + 50; + let viewportY = window.VIEWPORT_PADDING_TOP + 5; + let topElement = null; - logCfi("Probing for CFI at " + viewportX + "," + viewportY); + for (let i = 0; i < 10; i++) { + let el = document.elementFromPoint(viewportX, viewportY); + if (el && el.id !== 'content-container' && + !(el.classList && el.classList.contains('chunk-container') && el.innerText.trim() === "")) { + topElement = el; + break; + } + viewportY += 15; + } - let topElement = document.elementFromPoint(viewportX, viewportY); - - // FIX: Ignore empty chunk containers or the container wrapper itself - if (topElement && (topElement.id === 'content-container' || - (topElement.classList && topElement.classList.contains('chunk-container') && topElement.innerText.trim() === ""))) { - - logCfi("Hit empty chunk or container. scanning for first visible content..."); - // Fallback: Query specifically for content elements - const elements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, span, div.chunk-container'); + if (!topElement || topElement.id === 'content-container' || (topElement.classList && topElement.classList.contains('chunk-container'))) { + const elements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, span'); for (let i = 0; i < elements.length; i++) { const el = elements[i]; const rect = el.getBoundingClientRect(); - // Find first element that is effectively visible below the top padding if (rect.bottom > window.VIEWPORT_PADDING_TOP && el.innerText.trim().length > 0) { topElement = el; - logCfi("Found alternative content element: <" + el.tagName + ">"); break; } } } if (!topElement) { - logCfi("No element found. Fallback to body first child."); topElement = document.body.firstElementChild; } @@ -1756,9 +1606,6 @@ return JSON.stringify({ cfi: finalCfi, log: debugLog }); } - logCfi("Selected Reference Element: <" + topElement.tagName + "> ID:" + topElement.id); - - // Try caret range for precision let range = null; if (document.caretRangeFromPoint) { range = document.caretRangeFromPoint(viewportX, viewportY); @@ -1770,27 +1617,22 @@ if (range && range.startContainer && range.startContainer.nodeType === Node.TEXT_NODE) { nodeForCfi = range.startContainer; offsetForCfi = range.startOffset; - logCfi("Precision match via caretRangeFromPoint."); + debugLog.push(`Caret range hit successfully at offset ${offsetForCfi}`); } else { - // Walker fallback const treeWalker = document.createTreeWalker(topElement, NodeFilter.SHOW_TEXT, null, false); let firstTextNode = treeWalker.nextNode(); nodeForCfi = (firstTextNode && firstTextNode.textContent.trim().length > 0) ? firstTextNode : topElement; offsetForCfi = 0; - logCfi("Fallback match via TreeWalker."); } const cfiResult = window.getCfiPathForElement(nodeForCfi, offsetForCfi); finalCfi = cfiResult.cfi; - // Merge logs if (cfiResult.log) debugLog.push(...cfiResult.log); } catch (e) { debugLog.push("Error in getCurrentCfi: " + e.message); } - // We intentionally do not use TAG_BM here as this is called frequently on scroll. - // The CfiBridge in Kotlin logs this separately. return JSON.stringify({ cfi: finalCfi, log: debugLog }); }; @@ -1801,15 +1643,15 @@ } window.scrollToCfi = function(cfi) { - logBm("scrollToCfi called with: " + cfi); let cleanCfi = cfi; if (cfi && cfi.includes('@')) { cleanCfi = cfi.substring(cfi.indexOf('@') + 1); } + console.log("PosSaveDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi); + if (!cleanCfi || !cleanCfi.startsWith('/')) { - logBm("Invalid CFI format, aborting scroll."); if (window.CfiBridge && window.CfiBridge.onScrollFinished) { window.CfiBridge.onScrollFinished(false); } @@ -1818,77 +1660,103 @@ let attempts = 0; const maxAttempts = 20; + let stabilizingFrames = 0; + const maxStabilizingFrames = 8; function attemptScroll() { attempts++; - logBm("Scroll Attempt " + attempts + "/" + maxAttempts + " for " + cleanCfi); try { const location = window.getNodeAndOffsetFromCfi(cleanCfi); if (location && location.node) { - logBm("Target node FOUND. Node: " + location.node.nodeName); - if (!document.body.contains(location.node)) { - logBm("Node found but detached. Retrying..."); if (attempts < maxAttempts) setTimeout(attemptScroll, 100); return; } + let targetScrollY = 0; if (location.node.nodeType === Node.TEXT_NODE && location.offset > 0) { try { + console.log("PosSaveDiag: Initial text node length=" + location.node.nodeValue.length + ", location.offset=" + location.offset); + let currentNode = location.node; + let remainingOffset = location.offset; + + // Traverse sibling text nodes to find the exact offset + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + walker.currentNode = currentNode; + + while (currentNode && remainingOffset > currentNode.nodeValue.length) { + remainingOffset -= currentNode.nodeValue.length; + const next = walker.nextNode(); + if (!next) { + console.log("PosSaveDiag: Reached end of text nodes while traversing."); + break; + } + currentNode = next; + } + + console.log("PosSaveDiag: Traversed to node with text: '" + currentNode.nodeValue.substring(0, 20) + "', remainingOffset=" + remainingOffset + ", actual length=" + currentNode.nodeValue.length); + const range = document.createRange(); - const validOffset = Math.min(location.offset, location.node.nodeValue.length); - range.setStart(location.node, validOffset); + const validOffset = Math.min(remainingOffset, currentNode.nodeValue.length); + range.setStart(currentNode, validOffset); range.collapse(true); const rect = range.getBoundingClientRect(); if (rect.top !== 0 || rect.bottom !== 0) { - const targetScrollY = window.scrollY + rect.top - window.VIEWPORT_PADDING_TOP; - window.scrollTo({ top: targetScrollY, behavior: 'auto' }); - setTimeout(() => { - window.reportScrollState(); - if (window.CfiBridge && window.CfiBridge.onScrollFinished) { - window.CfiBridge.onScrollFinished(true); - } - }, 150); - return; + targetScrollY = window.scrollY + rect.top - (window.VIEWPORT_PADDING_TOP + 5); } } catch (e) { - logBm("Precise scroll failed: " + e.message); + console.log("PosSaveDiag: Error calculating range bounding rect: " + e.message); } } - const targetElement = (location.node.nodeType === Node.TEXT_NODE) ? location.node.parentNode : location.node; - targetElement.scrollIntoView({ behavior: 'auto', block: 'start', inline: 'nearest' }); + if (targetScrollY === 0) { + const targetElement = (location.node.nodeType === Node.TEXT_NODE) ? location.node.parentNode : location.node; + const rect = targetElement.getBoundingClientRect(); + targetScrollY = window.scrollY + rect.top - (window.VIEWPORT_PADDING_TOP + 5); + } - setTimeout(() => { - if (window.VIEWPORT_PADDING_TOP > 0) window.scrollBy(0, -window.VIEWPORT_PADDING_TOP); - window.reportScrollState(); - if (window.CfiBridge && window.CfiBridge.onScrollFinished) { - window.CfiBridge.onScrollFinished(true); - } - }, 150); + if (Math.abs(window.scrollY - targetScrollY) > 1) { + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + } + + stabilizingFrames++; + if (stabilizingFrames < maxStabilizingFrames) { + setTimeout(attemptScroll, 100); + } else { + setTimeout(() => { + window.reportScrollState(); + if (window.CfiBridge && window.CfiBridge.onScrollFinished) { + window.CfiBridge.onScrollFinished(true); + } + }, 50); + } } else { if (attempts < maxAttempts) { setTimeout(attemptScroll, 100); } else { - logBm("Max attempts reached. Scroll failed."); if (window.CfiBridge && window.CfiBridge.onScrollFinished) { window.CfiBridge.onScrollFinished(false); } } } } catch (e) { - logBm("Fatal error: " + e.message); if (window.CfiBridge && window.CfiBridge.onScrollFinished) { window.CfiBridge.onScrollFinished(false); } } } - attemptScroll(); + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(function() { + attemptScroll(); + }); + } else { + attemptScroll(); + } }; window.getElementByCfi = function(cfi) { @@ -1927,6 +1795,15 @@ const treeWalker = document.createTreeWalker(location.node, NodeFilter.SHOW_TEXT, null, false); textNode = treeWalker.nextNode(); offset = 0; + } else if (offset > 0) { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + walker.currentNode = textNode; + while (textNode && offset > textNode.nodeValue.length) { + offset -= textNode.nodeValue.length; + const next = walker.nextNode(); + if (!next) break; + textNode = next; + } } if (textNode) { @@ -2026,10 +1903,9 @@ if (container) { container.querySelectorAll(".chunk-container").forEach((div) => { let idx = parseInt(div.dataset.chunkIndex, 10); - let content = div.innerHTML; // Don't trim, keep original HTML structure + let content = div.innerHTML; if (content.trim().length > 0) { - // FIX: Assign to specific index, don't overwrite the whole array this.chunksData[idx] = content; this.chunkHeights[idx] = div.getBoundingClientRect().height; } @@ -2045,6 +1921,7 @@ this.observer = new IntersectionObserver( (entries) => { let scrollAdjust = 0; + let domChanged = false; entries.forEach((entry) => { let div = entry.target; @@ -2062,6 +1939,7 @@ let newHeight = div.getBoundingClientRect().height; this.chunkHeights[idx] = newHeight; + domChanged = true; if (div.getBoundingClientRect().top < 0) { scrollAdjust += (newHeight - oldHeight); @@ -2076,6 +1954,7 @@ this.chunkHeights[idx] = oldHeight; div.style.height = oldHeight + "px"; div.innerHTML = ""; + domChanged = true; } } }); @@ -2083,8 +1962,12 @@ if (scrollAdjust !== 0) { window.scrollBy(0, scrollAdjust); } + + if (domChanged && window.reportScrollState) { + setTimeout(window.reportScrollState, 50); + } }, - { rootMargin: "2500px 0px" } // Keep large margin for smooth scrolling + { rootMargin: "2500px 0px" } ); document.querySelectorAll(".chunk-container").forEach((div) => { @@ -2114,6 +1997,11 @@ if (div.getBoundingClientRect().bottom < 0) { window.scrollBy(0, newHeight - oldHeight); } + + if (window.reportScrollState) { + setTimeout(window.reportScrollState, 50); + } + if (window.CURRENT_HIGHLIGHTS) { window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS); } diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index c87a614..07b05ba 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -205,6 +205,9 @@ class FolderSyncWorker( val file = fileQueue.removeAt(0) if (file.isDirectory) { + if (file.name?.startsWith(".") == true) { + continue + } file.listFiles().let { fileQueue.addAll(it) } } else if (file.isFile) { val name = file.name ?: "" diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index d2ed9be..4ac97ca 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -630,21 +630,15 @@ fun RecentFileCard( .fallback(placeholder).crossfade(true).build(), contentDescription = item.displayName, contentScale = ContentScale.Crop, - modifier = Modifier - .height(160.dp) - .fillMaxWidth(), + modifier = Modifier.height(160.dp).fillMaxWidth(), ) if (item.sourceFolderUri != null) { Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(8.dp) - .background( + modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background( color = MaterialTheme.colorScheme.secondaryContainer, shape = CircleShape - ) - .padding(4.dp) + ).padding(4.dp) ) { Icon( imageVector = Icons.Default.Folder, @@ -658,14 +652,10 @@ fun RecentFileCard( val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true if (isOpdsStream) { Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(8.dp) - .background( + modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background( color = MaterialTheme.colorScheme.tertiaryContainer, shape = CircleShape - ) - .padding(4.dp) + ).padding(4.dp) ) { Icon( imageVector = Icons.Default.Cloud, @@ -678,14 +668,10 @@ fun RecentFileCard( if (isPinned) { Box( - modifier = Modifier - .align(Alignment.TopStart) - .padding(8.dp) - .background( + modifier = Modifier.align(Alignment.TopStart).padding(8.dp).background( color = MaterialTheme.colorScheme.primaryContainer, shape = CircleShape - ) - .padding(4.dp) + ).padding(4.dp) ) { Icon( imageVector = Icons.Default.PushPin, @@ -715,6 +701,11 @@ fun RecentFileCard( } } } + Box( + modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp) + ) { + FileTypeBadge(type = item.type, overlay = true) + } } Column( diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index a20b19b..ed8a89b 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -1404,13 +1404,25 @@ private fun LibraryListItem( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - item.progressPercentage?.let { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "${it.toInt()}% complete", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) + + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + FileTypeBadge(type = item.type, overlay = false) + + item.progressPercentage?.let { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "•", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${it.toInt()}% complete", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } } } } @@ -1743,6 +1755,7 @@ private fun FolderCard( } } +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable private fun EditFolderFiltersDialog( folder: SyncedFolder, @@ -1753,44 +1766,74 @@ private fun EditFolderFiltersDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.filter_file_types)) }, - text = { + title = { Column { Text( - stringResource(R.string.filter_file_types_desc), - style = MaterialTheme.typography.bodyMedium + text = stringResource(R.string.filter_file_types), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold ) - Spacer(modifier = Modifier.height(8.dp)) - FileType.entries.forEach { type -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { - selectedTypes = if (type in selectedTypes) selectedTypes - type else selectedTypes + type - } - .padding(vertical = 4.dp) - ) { - androidx.compose.material3.Checkbox( - checked = type in selectedTypes, - onCheckedChange = { checked -> - selectedTypes = if (checked) selectedTypes + type else selectedTypes - type - } + Text( + text = stringResource(R.string.filter_file_types_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) + + androidx.compose.foundation.layout.FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + FileType.entries.forEach { type -> + val isSelected = type in selectedTypes + FilterChip( + selected = isSelected, + onClick = { + selectedTypes = if (isSelected) { + selectedTypes - type + } else { + selectedTypes + type + } + }, + label = { + Text( + text = type.name, + style = MaterialTheme.typography.labelLarge + ) + }, + leadingIcon = if (isSelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } else null, + shape = MaterialTheme.shapes.medium ) - Spacer(modifier = Modifier.width(8.dp)) - Text(type.name) } } } }, confirmButton = { - TextButton( + androidx.compose.material3.Button( onClick = { onConfirm(selectedTypes) }, - enabled = selectedTypes.isNotEmpty() - ) { Text(stringResource(R.string.action_save)) } + enabled = selectedTypes.isNotEmpty(), + shape = MaterialTheme.shapes.medium + ) { + Text(stringResource(R.string.action_save)) + } }, dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } } ) } diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 4621c2a..a3e6e56 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -765,7 +765,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio remoteConfigRepository.init() if (_internalState.value.syncedFolders.isNotEmpty()) { - syncFolderMetadata() + triggerFolderSyncWorker(metadataOnly = false, showFeedback = false) } sweepOrphanedCache() diff --git a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt index 0c8028d..e2bf647 100644 --- a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt +++ b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt @@ -21,263 +21,305 @@ class Fb2Parser(private val context: Context) { bookId: String, originalBookNameHint: String, parseContent: Boolean = true - ): EpubBook { + ): EpubBook = withContext(Dispatchers.IO) { val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply { if (!exists()) mkdirs() } var streamToParse = inputStream - if (originalBookNameHint.endsWith(".zip", ignoreCase = true)) { - val zis = ZipInputStream(inputStream) - var entry = zis.nextEntry - while (entry != null) { - if (entry.name.endsWith(".fb2", ignoreCase = true)) { - break + try { + if (originalBookNameHint.endsWith(".zip", ignoreCase = true)) { + val zis = ZipInputStream(inputStream) + var entry = zis.nextEntry + while (entry != null) { + if (entry.name.endsWith(".fb2", ignoreCase = true)) { + break + } + entry = zis.nextEntry + } + if (entry != null) { + streamToParse = zis + } else { + throw Exception("No .fb2 file found inside the ZIP archive.") } - entry = zis.nextEntry } - if (entry != null) { - streamToParse = zis - } else { - throw Exception("No .fb2 file found inside the ZIP archive.") - } - } - val parser = Xml.newPullParser() - parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) - parser.setInput(streamToParse, null) + val parser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(streamToParse, null) - var title = originalBookNameHint.substringBeforeLast(".") - var author = "Unknown" - var coverImageId: String? = null - var coverBytes: ByteArray? = null + var title = originalBookNameHint.substringBeforeLast(".") + var author = "Unknown" + var coverImageId: String? = null + var coverBytes: ByteArray? = null - val chapters = mutableListOf() - val images = mutableListOf() // Keep track of extracted images + val chapters = mutableListOf() + val images = mutableListOf() // Keep track of extracted images - var currentChapterHtml = StringBuilder() - var currentChapterTitle = "Chapter" - var chapterCount = 0 - var inSection = false - var inBody = false - var inTitle = false - var skipElement = false - val titleBuilder = java.lang.StringBuilder() // Buffer to handle

tags inside + var currentChapterHtml = StringBuilder() + var currentChapterTitle = "Chapter 1" + var chapterCount = 0 + var inSection = false + var inBody = false + var inTitle = false + val titleBuilder = java.lang.StringBuilder() // Buffer to handle <p> tags inside <title> - val cssStyle = """ - body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; } - p { margin-bottom: 1em; text-indent: 1.5em; text-align: justify; } - h1, h2, h3, h4 { text-align: center; margin-top: 1.5em; margin-bottom: 1em; } - .empty-line { height: 1.5em; } - img { max-width: 100%; height: auto; display: block; margin: 1em auto; } - .epigraph { margin-left: 2em; font-style: italic; margin-bottom: 1.5em; } - """.trimIndent() - - fun saveChapter() { - if (!parseContent || currentChapterHtml.isEmpty()) return - chapterCount++ - val fileName = "chapter_$chapterCount.html" - val file = File(extractionDir, fileName) - - val fullHtml = """ - <!DOCTYPE html> - <html> - <head> - <title>${currentChapterTitle.replace("\"", """)} - - - - $currentChapterHtml - - + val cssStyle = """ + body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; } + p { margin-bottom: 1em; text-indent: 1.5em; text-align: justify; } + h1, h2, h3, h4 { text-align: center; margin-top: 1.5em; margin-bottom: 1em; } + .empty-line { height: 1.5em; } + img { max-width: 100%; height: auto; display: block; margin: 1em auto; } + .epigraph { margin-left: 2em; font-style: italic; margin-bottom: 1.5em; } + .cite { border-left: 4px solid currentColor; padding-left: 1em; margin-left: 0; opacity: 0.8; font-style: italic; } + .poem { margin: 1.5em 0; padding-left: 2em; } + .stanza { margin-bottom: 1em; } """.trimIndent() - FileOutputStream(file).use { it.write(fullHtml.toByteArray()) } - val plainText = Jsoup.parse(fullHtml).text() + fun saveChapter() { + if (!parseContent || currentChapterHtml.isEmpty()) return + chapterCount++ + val fileName = "chapter_$chapterCount.html" + val file = File(extractionDir, fileName) - chapters.add( - EpubChapter( - chapterId = "${bookId}_${chapterCount}", - absPath = fileName, - title = currentChapterTitle, - htmlFilePath = fileName, - plainTextContent = plainText, - htmlContent = "", - depth = 0, - isInToc = true + val fullHtml = """ + + + + ${currentChapterTitle.replace("\"", """)} + + + + $currentChapterHtml + + + """.trimIndent() + + FileOutputStream(file).use { it.write(fullHtml.toByteArray()) } + val plainText = Jsoup.parse(fullHtml).text() + + chapters.add( + EpubChapter( + chapterId = "${bookId}_${chapterCount}", + absPath = fileName, + title = currentChapterTitle, + htmlFilePath = fileName, + plainTextContent = plainText, + htmlContent = "", + depth = 0, + isInToc = true + ) ) - ) - currentChapterHtml.clear() - currentChapterTitle = "Chapter ${chapterCount + 1}" - } + currentChapterHtml.clear() + currentChapterTitle = "Chapter ${chapterCount + 1}" + } - var eventType = parser.eventType + var eventType = parser.eventType - while (eventType != XmlPullParser.END_DOCUMENT) { - when (eventType) { - XmlPullParser.START_TAG -> { - val name = parser.name.lowercase() - when (name) { - "book-title" -> { - title = parser.nextText().trim() - } - "first-name", "last-name", "middle-name" -> { - val namePart = parser.nextText().trim() - if (namePart.isNotBlank()) { - if (author == "Unknown") author = namePart else author += " $namePart" + while (eventType != XmlPullParser.END_DOCUMENT) { + when (eventType) { + XmlPullParser.START_TAG -> { + val name = parser.name.lowercase() + when (name) { + "book-title" -> { + title = parser.nextText().trim() } - } - "body" -> { - val nameAttr = parser.getAttributeValue(null, "name") - if (nameAttr == "notes" || nameAttr == "comments") { - skipElement = true - } else { + "first-name", "last-name", "middle-name" -> { + val namePart = parser.nextText().trim() + if (namePart.isNotBlank()) { + if (author == "Unknown") author = namePart else author += " $namePart" + } + } + "body" -> { inBody = true } - } - "section" -> { - if (inBody && !skipElement) { - if (currentChapterHtml.isNotBlank()) { - saveChapter() + "section" -> { + if (inBody) { + if (currentChapterHtml.isNotBlank()) { + saveChapter() + } + inSection = true } - inSection = true } - } - "title" -> { - if (inSection && currentChapterHtml.isEmpty()) { - inTitle = true - titleBuilder.clear() + "title" -> { + if (inSection && currentChapterHtml.isEmpty()) { + inTitle = true + titleBuilder.clear() + } + currentChapterHtml.append("

") } - currentChapterHtml.append("

") - } - "p" -> if (!inTitle) currentChapterHtml.append("

") - "v" -> if (!inTitle) currentChapterHtml.append("

") - "subtitle" -> currentChapterHtml.append("

") - "empty-line" -> currentChapterHtml.append("
") - "strong" -> currentChapterHtml.append("") - "emphasis" -> currentChapterHtml.append("") - "strikethrough" -> currentChapterHtml.append("") - "sup" -> currentChapterHtml.append("") - "sub" -> currentChapterHtml.append("") - "epigraph" -> currentChapterHtml.append("
") - "image" -> { - // Safely extract href checking all possible namespace stripped versions - val href = parser.getAttributeValue(null, "l:href") - ?: parser.getAttributeValue(null, "xlink:href") - ?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href") - ?: parser.getAttributeValue(null, "href") + "p" -> { + if (!inTitle) { + currentChapterHtml.append("

") + } else if (titleBuilder.isNotEmpty()) { + titleBuilder.append(" ") + currentChapterHtml.append("
") + } + } + "v" -> { + if (!inTitle) { + currentChapterHtml.append("

") + } else if (titleBuilder.isNotEmpty()) { + titleBuilder.append(" ") + currentChapterHtml.append("
") + } + } + "subtitle" -> currentChapterHtml.append("

") + "empty-line" -> { + if (!inTitle) { + currentChapterHtml.append("
") + } else if (titleBuilder.isNotEmpty()) { + titleBuilder.append(" ") + currentChapterHtml.append("
") + } + } + "strong" -> currentChapterHtml.append("") + "emphasis" -> currentChapterHtml.append("") + "strikethrough" -> currentChapterHtml.append("") + "sup" -> currentChapterHtml.append("") + "sub" -> currentChapterHtml.append("") + "epigraph" -> currentChapterHtml.append("
") + "cite" -> currentChapterHtml.append("
") + "poem" -> currentChapterHtml.append("
") + "stanza" -> currentChapterHtml.append("
") + "a" -> { + val href = parser.getAttributeValue(null, "l:href") + ?: parser.getAttributeValue(null, "xlink:href") + ?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href") + if (!inTitle) { + if (href != null) { + currentChapterHtml.append("") + } else { + currentChapterHtml.append("") + } + } + } + "image" -> { + // Safely extract href checking all possible namespace stripped versions + val href = parser.getAttributeValue(null, "l:href") + ?: parser.getAttributeValue(null, "xlink:href") + ?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href") + ?: parser.getAttributeValue(null, "href") - if (href != null) { - val id = href.removePrefix("#") - if (!inBody) { - coverImageId = id - } else { - currentChapterHtml.append("") + if (href != null) { + val id = href.removePrefix("#") + if (!inBody) { + if (coverImageId == null) coverImageId = id + } else { + currentChapterHtml.append("") + } } } - } - "binary" -> { - val id = parser.getAttributeValue(null, "id") - if (id != null) { - val base64Data = parser.nextText() - try { - val bytes = Base64.decode(base64Data, Base64.DEFAULT) - if (parseContent) { - val imgFile = File(extractionDir, id) - withContext(Dispatchers.IO) { + "binary" -> { + val id = parser.getAttributeValue(null, "id") + if (id != null) { + val base64Data = parser.nextText() + try { + val bytes = Base64.decode(base64Data, Base64.DEFAULT) + if (parseContent) { + val imgFile = File(extractionDir, id) FileOutputStream(imgFile).use { it.write(bytes) } } - } - images.add(EpubImage(absPath = id)) + images.add(EpubImage(absPath = id)) - if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) { - coverBytes = bytes - coverImageId = id + if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) { + coverBytes = bytes + coverImageId = id + } + } catch (e: Exception) { + Timber.e(e, "Failed to decode binary image $id") } - } catch (e: Exception) { - Timber.e(e, "Failed to decode binary image $id") } } } } - } - XmlPullParser.TEXT -> { - val text = parser.text?.replace("&", "&")?.replace("<", "<")?.replace(">", ">") - if (!text.isNullOrBlank()) { - if (inTitle) { - titleBuilder.append(text) // Append to buffer since it could be split by

tags - currentChapterHtml.append(text) - } else if (inBody && !skipElement) { - currentChapterHtml.append(text) - } - } - } - XmlPullParser.END_TAG -> { - val name = parser.name.lowercase() - when (name) { - "body" -> { - skipElement = false - inBody = false - } - "title" -> { + XmlPullParser.TEXT -> { + val text = parser.text?.replace("&", "&")?.replace("<", "<")?.replace(">", ">") + if (!text.isNullOrBlank()) { if (inTitle) { - currentChapterTitle = titleBuilder.toString().trim() - inTitle = false + titleBuilder.append(text) // Append to buffer since it could be split by

tags + currentChapterHtml.append(text) + } else if (inBody) { + currentChapterHtml.append(text) } - currentChapterHtml.append("

\n") } - "p", "v" -> if (!inTitle) currentChapterHtml.append("

\n") - "subtitle" -> currentChapterHtml.append("

\n") - "strong" -> currentChapterHtml.append("") - "emphasis" -> currentChapterHtml.append("") - "strikethrough" -> currentChapterHtml.append("") - "sup" -> currentChapterHtml.append("") - "sub" -> currentChapterHtml.append("") - "epigraph" -> currentChapterHtml.append("\n") } + XmlPullParser.END_TAG -> { + val name = parser.name.lowercase() + when (name) { + "body" -> { + inBody = false + } + "title" -> { + if (inTitle) { + currentChapterTitle = titleBuilder.toString().replace("\\s+".toRegex(), " ").trim() + if (currentChapterTitle.isBlank()) { + currentChapterTitle = "Chapter ${chapterCount + 1}" + } + inTitle = false + } + currentChapterHtml.append("\n") + } + "p", "v" -> if (!inTitle) currentChapterHtml.append("

\n") + "subtitle" -> currentChapterHtml.append("\n") + "strong" -> currentChapterHtml.append("
") + "emphasis" -> currentChapterHtml.append("") + "strikethrough" -> currentChapterHtml.append("") + "sup" -> currentChapterHtml.append("") + "sub" -> currentChapterHtml.append("") + "epigraph" -> currentChapterHtml.append("\n") + "cite" -> currentChapterHtml.append("\n") + "poem", "stanza" -> currentChapterHtml.append("\n") + "a" -> if (!inTitle) currentChapterHtml.append("") + } + } + } + + if (eventType != XmlPullParser.END_DOCUMENT) { + eventType = parser.next() } } - // Calling nextText() moves the parser directly to END_TAG. - // We ensure we don't accidentally read past the EOF. - if (eventType != XmlPullParser.END_DOCUMENT) { - eventType = parser.next() + saveChapter() + + if (chapters.isEmpty() && parseContent) { + if (currentChapterHtml.isNotBlank()) { + saveChapter() + } else { + throw Exception("No valid content found in FB2 file.") + } } - } - saveChapter() - - if (chapters.isEmpty() && parseContent) { - if (currentChapterHtml.isNotBlank()) { - saveChapter() - } else { - throw Exception("No valid content found in FB2 file.") + val coverBitmap = coverBytes?.let { + try { + BitmapFactory.decodeByteArray(it, 0, it.size) + } catch (e: Exception) { + Timber.e(e, "Failed to decode cover bitmap for FB2") + null + } } - } - val coverBitmap = coverBytes?.let { + return@withContext EpubBook( + fileName = originalBookNameHint, + title = title, + author = author, + language = "en", + coverImage = coverBitmap, + chapters = chapters, + chaptersForPagination = chapters, + images = images, + pageList = emptyList(), + tableOfContents = emptyList(), + extractionBasePath = extractionDir.absolutePath, + css = emptyMap() + ) + } finally { try { - BitmapFactory.decodeByteArray(it, 0, it.size) + streamToParse.close() } catch (e: Exception) { - Timber.e(e, "Failed to decode cover bitmap for FB2") - null + Timber.e(e, "Error closing FB2 stream") } } - - return EpubBook( - fileName = originalBookNameHint, - title = title, - author = author, - language = "en", - coverImage = coverBitmap, - chapters = chapters, - chaptersForPagination = chapters, - images = images, // Extracted images attached! - pageList = emptyList(), - tableOfContents = emptyList(), - extractionBasePath = extractionDir.absolutePath, - css = emptyMap() - ) } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt index 13b5b18..e8ea9b9 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -306,41 +306,42 @@ class SingleFileImporter(private val context: Context) { .replace(">", ">") } - val reader = inputStream.bufferedReader() var inParagraph = false - while (true) { - val line = reader.readLine() - if (line == null) { - if (inParagraph) { - currentChapterContent.append("

\n") - } - break - } - - val trimmed = line.trim() - if (trimmed.isEmpty()) { - if (inParagraph) { - currentChapterContent.append("

\n") - inParagraph = false + inputStream.bufferedReader().use { reader -> + while (true) { + val line = reader.readLine() + if (line == null) { + if (inParagraph) { + currentChapterContent.append("

\n") + } + break } - if (currentChapterContent.length >= chapterTargetSize) { - flushChapter() - } - } else { - if (!inParagraph) { - currentChapterContent.append("

") - inParagraph = true + val trimmed = line.trim() + if (trimmed.isEmpty()) { + if (inParagraph) { + currentChapterContent.append("

\n") + inParagraph = false + } + + if (currentChapterContent.length >= chapterTargetSize) { + flushChapter() + } } else { - currentChapterContent.append(" ") - } - currentChapterContent.append(escapeHtml(trimmed)) + if (!inParagraph) { + currentChapterContent.append("

") + inParagraph = true + } else { + currentChapterContent.append(" ") + } + currentChapterContent.append(escapeHtml(trimmed)) - if (currentChapterContent.length >= chapterTargetSize * 2) { - currentChapterContent.append("

\n") - flushChapter() - inParagraph = false + if (currentChapterContent.length >= chapterTargetSize * 2) { + currentChapterContent.append("

\n") + flushChapter() + inParagraph = false + } } } } @@ -590,9 +591,10 @@ class SingleFileImporter(private val context: Context) { val parseStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint") - val converter = DocumentConverter() - val result = converter.convertToHtml(inputStream) - val htmlContent = result.value ?: "" + 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") diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index 3b5d860..05beb3b 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -560,6 +560,11 @@ fun ChapterWebView( ) } + message.startsWith("PosSaveDiag:") -> { + Timber.tag("PosSaveDiag") + .d("JS -> ${message.substringAfter("PosSaveDiag: ")}") + } + message.startsWith("HIGHLIGHT_DEBUG:") -> { Timber.d( "JS -> ${message.substringAfter("HIGHLIGHT_DEBUG: ")}" diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 0ac6a33..c6b605e 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -483,7 +483,7 @@ fun EpubReaderHost( var showJustifyWarningDialog by remember { mutableStateOf(false) } var isNavigatingByToc by remember { mutableStateOf(false) } - var chunkTargetOverride by remember { mutableStateOf(null) } + var chunkTargetOverride by remember { mutableStateOf(initialLocator?.let { it.blockIndex / 20 }) } val snackbarHostState = remember { SnackbarHostState() } @@ -957,6 +957,8 @@ fun EpubReaderHost( skipChapterRequest = false if (ttsShouldStartOnChapterLoad && currentChapterIndex < chapters.size - 1) { Timber.d("Executing skip chapter request for continuous TTS.") + currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex++ } else { ttsShouldStartOnChapterLoad = false @@ -1212,6 +1214,7 @@ fun EpubReaderHost( initialScrollTargetForChapter = ChapterScrollPosition.START cfiToLoad = null currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex = nextIndex }, onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart }, @@ -1362,7 +1365,17 @@ fun EpubReaderHost( chapterHead = result.head chapterChunks = result.chunks isChapterParsing = false - loadUpToChunkIndex = result.startChunkIndex + + if (initialScrollTargetForChapter == ChapterScrollPosition.END) { + loadUpToChunkIndex = max(0, result.chunks.size - 1) + loadedChunkCount = result.chunks.size + topVisibleChunkIndex = loadUpToChunkIndex + } else { + loadUpToChunkIndex = result.startChunkIndex + loadedChunkCount = min(result.chunks.size, result.startChunkIndex + 2) + topVisibleChunkIndex = 0 + } + Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: loadChapterContent finished. chapterChunks.size=${chapterChunks.size}, isChapterParsing=$isChapterParsing") if (chunkTargetOverride != null) { @@ -1371,9 +1384,6 @@ fun EpubReaderHost( if (isInitialCfiLoad) { isInitialCfiLoad = false } - - loadedChunkCount = 1 - topVisibleChunkIndex = 0 } EpubReaderSystemUiController( @@ -1604,6 +1614,8 @@ fun EpubReaderHost( coroutineScope = scope, onVerticalChapterChange = { chapterIdx, chunkIdx, result -> initialScrollTargetForChapter = ChapterScrollPosition.START + currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex = chapterIdx searchHighlightTarget = result loadUpToChunkIndex = chunkIdx @@ -1674,6 +1686,7 @@ fun EpubReaderHost( if (targetChapterIndex != currentChapterIndex) { initialScrollTargetForChapter = null currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex = targetChapterIndex } else { if (entry.fragmentId != null) { @@ -1718,6 +1731,7 @@ fun EpubReaderHost( if (index != currentChapterIndex) { initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex = index pullToNextProgress = 0f pullToPrevProgress = 0f @@ -1773,6 +1787,8 @@ fun EpubReaderHost( } else { 0 } + currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex = bookmark.chapterIndex } else { @@ -1876,6 +1892,8 @@ fun EpubReaderHost( if (highlight.chapterIndex != currentChapterIndex) { chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) targetChunk else 0 + currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex = highlight.chapterIndex } else { if (targetChunk != null && targetChunk >= 0) { @@ -2069,7 +2087,8 @@ fun EpubReaderHost( onNavigateChapter = { offset, target -> scope.launch { initialScrollTargetForChapter = target - if (target == ChapterScrollPosition.START) currentScrollYPosition = 0 + currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex += offset } }, @@ -2147,19 +2166,19 @@ fun EpubReaderHost( CircularProgressIndicator() } } else if (chapterChunks.isNotEmpty()) { - val initialContentToLoad = - remember(loadUpToChunkIndex, chapterChunks) { - val startIdx = maxOf(0, loadUpToChunkIndex - 1) - val endIdx = minOf(chapterChunks.lastIndex, loadUpToChunkIndex + 1) + val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) { + val targetIdx = loadUpToChunkIndex + val startIdx = maxOf(0, targetIdx - 1) + val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1) - chapterChunks.indices.joinToString(separator = "\n") { index -> - if (index in startIdx..endIdx) { - "
${chapterChunks[index]}
" - } else { - "
" - } + chapterChunks.indices.joinToString(separator = "\n") { index -> + if (index in startIdx..endIdx) { + "
${chapterChunks[index]}
" + } else { + "
" } } + } val initialHtml = """ @@ -2364,6 +2383,7 @@ fun EpubReaderHost( Timber.d("Screen: Moving to next chapter (${currentChapterIndex + 1}).") initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex++ isAutoScrollPlaying = true } else { @@ -2384,6 +2404,8 @@ fun EpubReaderHost( scope.launch { delay(20) initialScrollTargetForChapter = ChapterScrollPosition.END + currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex-- if (showBars) showBars = false delay(300) @@ -2405,6 +2427,7 @@ fun EpubReaderHost( delay(20) initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex++ if (showBars) showBars = false delay(300) @@ -2423,12 +2446,12 @@ fun EpubReaderHost( ) scope.launch { delay(50) - initialScrollTargetForChapter = - ChapterScrollPosition.END + initialScrollTargetForChapter = ChapterScrollPosition.END + currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex-- if (showBars) showBars = false - Timber.d("Changed to previous chapter: $currentChapterIndex, will scroll to END" - ) + Timber.d("Changed to previous chapter: $currentChapterIndex, will scroll to END") } } pullToPrevProgress = 0f @@ -2443,9 +2466,9 @@ fun EpubReaderHost( ) scope.launch { delay(50) - initialScrollTargetForChapter = - ChapterScrollPosition.START + initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex++ if (showBars) showBars = false } @@ -2604,7 +2627,7 @@ fun EpubReaderHost( showDictionaryUpsellDialog = true }, onCfiGenerated = { cfi -> - Timber.tag("POS_DIAG").d("JS generated CFI: '$cfi'") + Timber.tag("PosSaveDiag").d("JS generated CFI string: '$cfi'") if (cfi.isBlank() || !cfi.startsWith('/')) { if (isSavingAndExiting) { @@ -2623,6 +2646,7 @@ fun EpubReaderHost( ) if (locator != null) { + Timber.tag("PosSaveDiag").d("✅ Converted CFI to Locator successfully: chapter=${locator.chapterIndex}, block=${locator.blockIndex}, charOffset=${locator.charOffset}") lastKnownLocator = locator val progressWithinChapter = @@ -3131,8 +3155,11 @@ fun EpubReaderHost( val chapterTitle = chapters.getOrNull(currentChapterIndex)?.title?.take(30)?.trim() ?: "Chapter" + + val displayPageInfo = if (currentScrollHeightValue <= 0 || isChapterParsing) "" else " ($currentPageInChapter/$totalPagesInCurrentChapter)" + Text( - text = "$chapterTitle ($currentPageInChapter/$totalPagesInCurrentChapter)", + text = "$chapterTitle$displayPageInfo", style = MaterialTheme.typography.bodySmall, color = effectiveText.copy(alpha = 0.8f), textAlign = TextAlign.Center, @@ -3143,7 +3170,7 @@ fun EpubReaderHost( .padding(horizontal = 48.dp) ) - if (totalBookLengthChars > 0) { + if (totalBookLengthChars > 0 && currentScrollHeightValue > 0 && !isChapterParsing) { Text( text = "%.1f%%".format(currentBookProgress), style = MaterialTheme.typography.bodySmall, @@ -3484,10 +3511,14 @@ fun EpubReaderHost( val targetChunk = locator.blockIndex / 20 chunkTargetOverride = targetChunk if (currentChapterIndex != locator.chapterIndex) { + currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex = locator.chapterIndex } cfiToLoad = cfi } else { + currentScrollYPosition = 0 + currentScrollHeightValue = 0 currentChapterIndex = locator.chapterIndex cfiToLoad = null } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt index 755b602..024e1be 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt @@ -167,12 +167,14 @@ class LocatorConverter( val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath) if (bestMatch != null) { + Timber.tag("PosSaveDiag").d("Found best match for baseCfiPath $baseCfiPath -> blockIndex=${bestMatch.blockIndex}, actualBlockCfi=${bestMatch.cfi}") Locator( chapterIndex = chapterIndex, blockIndex = bestMatch.blockIndex, charOffset = charOffset ) } else { + Timber.tag("PosSaveDiag").e("No semantic block match found for baseCfiPath $baseCfiPath inside ${allBlocks.size} parsed blocks") null } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index 3be70aa..5ce8874 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -432,6 +432,7 @@ internal fun PdfPageComposable( draggingBoxId: String? = null, isScrollLocked: Boolean = false, isVisible: Boolean = true, + isActivePage: Boolean = true, isStylusOnlyMode: Boolean = false, isHighlighterSnapEnabled: Boolean = false, userHighlights: List = emptyList(), @@ -442,7 +443,9 @@ internal fun PdfPageComposable( onTts: (Int, Int) -> Unit = { _, _ -> }, activeToolThickness: Float = 0f, customHighlightColors: Map = emptyMap(), - onPaletteClick: (() -> Unit)? = null + onPaletteClick: (() -> Unit)? = null, + lockedState: Triple? = null, + onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null ) { val pdfDocumentItem = pdfDocument.item var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) } @@ -474,6 +477,10 @@ internal fun PdfPageComposable( var scale by remember { mutableFloatStateOf(1f) } var offset by remember { mutableStateOf(Offset.Zero) } + LaunchedEffect(scale, offset) { + onZoomAndPanChanged?.invoke(scale, offset) + } + val currentOnSingleTap by rememberUpdatedState(onSingleTap) val currentOnDoubleTap by rememberUpdatedState(onDoubleTap) @@ -720,12 +727,6 @@ internal fun PdfPageComposable( } } - LaunchedEffect(pageIndex) { - scale = 1f - offset = Offset.Zero - onScaleChanged(1f) - } - LaunchedEffect(isPerformingOcrForSelection) { onOcrStateChange(isPerformingOcrForSelection) } LaunchedEffect( @@ -1067,9 +1068,10 @@ internal fun PdfPageComposable( canvasHeightPx.floatValue, isVerticalScroll, isScrolling, - virtualPage + virtualPage, + isActivePage ) { - val needsTiling = effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000 + val needsTiling = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage) if (!needsTiling) { if (tiles.isNotEmpty()) { val oldTiles = tiles @@ -2594,7 +2596,7 @@ internal fun PdfPageComposable( } } }, onDoubleTap = { tapOffset -> - if (isZoomEnabled && !isVerticalScroll) { + if (isZoomEnabled && !isVerticalScroll && !isScrollLocked) { if (actualBitmapWidthPx == 0) return@detectTapGestures coroutineScope.launch { val startScale = scale @@ -2689,7 +2691,11 @@ internal fun PdfPageComposable( if (!canceled) { val rawPanChange = event.calculatePan() - val panChange = if (isScrollLocked) Offset(0f, rawPanChange.y) else rawPanChange + val panChange = if (isScrollLocked && pointerCount == 1) { + if (isVerticalScroll) Offset(0f, rawPanChange.y) else Offset.Zero + } else { + rawPanChange + } val zoomChange = event.calculateZoom() if (scale > 1f) { @@ -3112,14 +3118,21 @@ internal fun PdfPageComposable( } LaunchedEffect( - this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight + pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight, + isScrollLocked, lockedState ) { - scale = 1f - offset = Offset.Zero - onScaleChanged(1f) + if (isScrollLocked && !isVerticalScroll && lockedState != null) { + scale = lockedState.first + offset = Offset(lockedState.second, lockedState.third) + onScaleChanged(scale) + } else if (!isScrollLocked && !isVerticalScroll) { + scale = 1f + offset = Offset.Zero + onScaleChanged(1f) + } Timber.d( - "PdfPageComposable Page $pageIndex | Constraints: maxWidth=${this@BoxWithConstraints.maxWidth}, maxHeight=${this@BoxWithConstraints.maxHeight}" + "PdfPageComposable Page $pageIndex initialized/resized/locked. scale=$scale, offset=$offset" ) } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index e71c165..4112bc1 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -234,7 +234,9 @@ internal fun PdfVerticalReader( onTts: (Int, Int) -> Unit = { _, _ -> }, activeToolThickness: Float = 0f, customHighlightColors: Map = emptyMap(), - onPaletteClick: () -> Unit = {} + onPaletteClick: () -> Unit = {}, + lockedState: Triple? = null, + onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null ) { SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") } DisposableEffect(state) { @@ -336,6 +338,10 @@ internal fun PdfVerticalReader( val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) } val panYAnimatable = remember { Animatable(0f) } + LaunchedEffect(zoomAnimatable.value, panXAnimatable.value, panYAnimatable.value) { + onZoomAndPanChanged?.invoke(zoomAnimatable.value, Offset(panXAnimatable.value, panYAnimatable.value)) + } + var isResizing by remember { mutableStateOf(false) } var previousScreenWidth by remember { mutableFloatStateOf(0f) } var previousScreenHeight by remember { mutableFloatStateOf(0f) } @@ -401,6 +407,12 @@ internal fun PdfVerticalReader( delay(50) isResizing = false targetPageDuringResize.intValue = -1 + } else if (isScrollLocked && lockedState != null) { + val (savedScale, savedPanX, _) = lockedState + coroutineScope { + launch { zoomAnimatable.snapTo(savedScale) } + launch { panXAnimatable.snapTo(savedPanX) } + } } isInitialLayout = false } @@ -768,63 +780,65 @@ internal fun PdfVerticalReader( } val onDoubleTapToZoom: (Offset) -> Unit = { tapScreenOffset -> - val currentZoom = zoomAnimatable.value + if (!isScrollLocked) { + val currentZoom = zoomAnimatable.value - val targetZoom = when { - currentZoom < 0.95f -> 1f - currentZoom < 2.45f -> 2.5f - else -> fitZoom - } - - val startPanX = panXAnimatable.value - val startPanY = panYAnimatable.value - - scope.launch { - zoomAnimatable.stop() - panXAnimatable.stop() - panYAnimatable.stop() - - val pivotContentX = (tapScreenOffset.x - startPanX) / currentZoom - val pivotContentY = (tapScreenOffset.y - startPanY) / currentZoom - - val rawNextPanX = tapScreenOffset.x - (pivotContentX * targetZoom) - val rawNextPanY = tapScreenOffset.y - (pivotContentY * targetZoom) - - val (finalZoom, finalX, finalY) = clampCamera(targetZoom, 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)) } + val targetZoom = when { + currentZoom < 0.95f -> 1f + currentZoom < 2.45f -> 2.5f + else -> fitZoom } - onZoomChange(zoomAnimatable.value) + val startPanX = panXAnimatable.value + val startPanY = panYAnimatable.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 + scope.launch { + zoomAnimatable.stop() + panXAnimatable.stop() + panYAnimatable.stop() + + val pivotContentX = (tapScreenOffset.x - startPanX) / currentZoom + val pivotContentY = (tapScreenOffset.y - startPanY) / currentZoom + + val rawNextPanX = tapScreenOffset.x - (pivotContentX * targetZoom) + val rawNextPanY = tapScreenOffset.y - (pivotContentY * targetZoom) + + val (finalZoom, finalX, finalY) = clampCamera(targetZoom, 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) } - panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX) - - val zDocH = totalDocHeight * finalZoom - val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx) - panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx) } } @@ -1042,7 +1056,7 @@ internal fun PdfVerticalReader( val zoomChange = event.calculateZoom() val rawPanChange = event.calculatePan() - val panChange = if (isScrollLocked) Offset(0f, rawPanChange.y) else rawPanChange + val panChange = if (isScrollLocked && !isMultiTouch) Offset(0f, rawPanChange.y) else rawPanChange val centroid = event.calculateCentroid(useCurrent = false) val panMagnitude = panChange.getDistance() diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index ec83f40..0c459fe 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -583,6 +583,25 @@ private fun getSuggestedFilename(originalName: String?, isAnnotated: Boolean): S return "${safeBase}${suffix}_${shortId}.pdf" } +private fun savePdfLockedState(context: Context, bookId: String, scale: Float, offsetX: Float, offsetY: Float) { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { + putFloat("pdf_locked_scale_$bookId", scale) + putFloat("pdf_locked_offset_x_$bookId", offsetX) + putFloat("pdf_locked_offset_y_$bookId", offsetY) + } +} + +private fun loadPdfLockedState(context: Context, bookId: String): Triple? { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + if (!prefs.contains("pdf_locked_scale_$bookId")) return null + return Triple( + prefs.getFloat("pdf_locked_scale_$bookId", 1f), + prefs.getFloat("pdf_locked_offset_x_$bookId", 0f), + prefs.getFloat("pdf_locked_offset_y_$bookId", 0f) + ) +} + private enum class SaveMode { ORIGINAL, ANNOTATED } @@ -1181,6 +1200,9 @@ fun PdfViewerScreen( var documentPassword by rememberSaveable { mutableStateOf(null) } var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) } var isScrollLocked by remember { mutableStateOf(false) } + var lockedState by remember { mutableStateOf?>(null) } + var currentActiveScale by remember { mutableFloatStateOf(1f) } + var currentActiveOffset by remember { mutableStateOf(Offset.Zero) } var showPasswordDialog by remember { mutableStateOf(false) } var isPasswordError by remember { mutableStateOf(false) } LocalView.current @@ -1239,6 +1261,7 @@ fun PdfViewerScreen( LaunchedEffect(bookId) { isScrollLocked = loadPdfScrollLocked(context, bookId) isFullScreen = loadPdfFullScreen(context, bookId) + lockedState = loadPdfLockedState(context, bookId) } var isAutoScrollModeActive by remember { mutableStateOf(false) } @@ -1504,6 +1527,14 @@ fun PdfViewerScreen( LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) } + LaunchedEffect(currentActiveScale, currentActiveOffset, isScrollLocked) { + if (isScrollLocked) { + delay(500) + lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) + savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) + } + } + val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) } val toolSettings by annotationSettingsRepo.settings.collectAsState() var showToolSettings by rememberSaveable { mutableStateOf(false) } @@ -4367,7 +4398,7 @@ fun PdfViewerScreen( key = { it }, beyondViewportPageCount = dynamicBeyondViewportPageCount, userScrollEnabled = run { - val enabled = currentPageScale == 1f && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null + val enabled = (currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null SideEffect { Timber.tag("PdfZoomDebug").v("Pager Scroll Enabled: $enabled (Scale: $currentPageScale, Playing: ${ttsState.isPlaying}, Slider: $isPageSliderVisible, DraggingBox: $paginationDraggingBoxId)") } @@ -4619,6 +4650,13 @@ fun PdfViewerScreen( onNoteRequested = onNoteRequested, onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) }, activeToolThickness = currentStrokeWidthState, + lockedState = lockedState, + onZoomAndPanChanged = { newScale, newOffset -> + if (pagerState.currentPage == pageIndex) { + currentActiveScale = newScale + currentActiveOffset = newOffset + } + }, onTwoFingerSwipe = { direction -> coroutineScope.launch { val targetPage = @@ -4774,6 +4812,8 @@ fun PdfViewerScreen( }, onDragPageTurn = { /* Handled in onTextBoxDrag */ }, isVisible = isVisiblePage, + isActivePage = pagerState.currentPage == pageIndex, + isScrolling = pagerState.isScrollInProgress ) } @@ -5025,7 +5065,12 @@ fun PdfViewerScreen( isAutoScrollPlaying = isAutoScrollPlaying, isAutoScrollTempPaused = isAutoScrollTempPaused, autoScrollSpeed = autoScrollSpeed * 0.5f, - onInteractionListener = onAutoScrollInteraction + onInteractionListener = onAutoScrollInteraction, + lockedState = lockedState, + onZoomAndPanChanged = { newScale, newOffset -> + currentActiveScale = newScale + currentActiveOffset = newOffset + } ) } } @@ -5529,6 +5574,10 @@ fun PdfViewerScreen( onClick = { isScrollLocked = !isScrollLocked savePdfScrollLocked(context, bookId, isScrollLocked) + if (isScrollLocked) { + savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) + lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y) + } }) { Icon( imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen,