Update v1.0.49 (#330)
* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE * Refactored desktop reader screens and state management into specialized components * Added image gallery to reader sidebar and refactored desktop PDF UI components * Implemented EPUB image gallery * Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic * Standardized UI styling and refactored app navigation layout * Implemented auto-hiding reader chrome and activity tracking in desktop * Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support. * Added PPTX support for desktop and refactored parsing into a shared module * Implement paid AI features and account management for the desktop application. * Implement AI Hub and enhanced Cloud TTS integration for Desktop * Implement streaming support for AI definition and summarization features * Implement support for password-protected PDFs and file actions in the desktop reader. * Implement cloud synchronization for desktop using Firestore and Google Drive * Implement PDF reflow and "Text View" for the desktop reader * Refactor OPDS logic to use SharedOpdsController * Optimize PDF tile rendering performance * Implement two-page spread support for PDF pagination * Implement two-page spread support for the PDF viewer * Improved shared spread zoom in PDF viewer * Improve PDF spread navigation with fling support and configurable page gaps * Add brightness control to PDF and EPUB readers * Refactor folder synchronization to use shared logic engine * Implement safe string formatting and validation for localized resources * Implement TTS chunk skip navigation * Implement deep-linking and playback controls for TTS media sessions * Implement start index for TTS playback * Improve TTS navigation, prefetching, and notification duration reporting * Implement TTS mini playback bar for background reading * Implement multi-window reader support for the desktop application * Improve desktop modal window management and visibility syncing * Implement localized string support for Desktop and shared UI * Implement language selection and persistence for Desktop * Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml * Implement localized banner messages and UI strings using resource-backed SharedText * Implement compact badge styling for small book covers * Refactor PDF native interaction and improve HTML import memory safety * fix language persistence * Refactor reader overflow menus to use section-based logic * Refactor PDF layout remapping and improve text box interaction * Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets * Centralize PDF annotation export mapping and improve metadata handling * Add support for threaded comments in PDF highlight annotations * Flatten highlight comments into a single thread for PDF export and allow author editing * Integrate page slider into reader chrome and persist toggle state * Handle fragments and queries in EPUB chapter paths * Implement dynamic, theme-aware coloring for the reader slider * Implement customizable app-wide font preference * Implement one-hand zoom gestures in the PDF viewer * Implement File Information dialog for PDF and EPUB readers * Bump version to 1.0.49 (53) * Refactor PDF reader logic into modular components * Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens * Add option to use PDF filenames as display names * Fix preservation of PDF filename display preference in library projection
This commit is contained in:
parent
dc5196526f
commit
9510293ac3
245 changed files with 37538 additions and 12460 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,3 +25,4 @@ kcef-bundle/
|
|||
kcef-bundle-linux-x64/
|
||||
cache/
|
||||
worker/
|
||||
output/
|
||||
|
|
@ -57,8 +57,8 @@ android {
|
|||
applicationId = "com.aryan.reader"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 52
|
||||
versionName = "1.0.48"
|
||||
versionCode = 53
|
||||
versionName = "1.0.49"
|
||||
|
||||
resourceConfigurations += configuredAppLocaleTags()
|
||||
.map { it.toAndroidResourceConfiguration() }
|
||||
|
|
|
|||
18
app/proguard-rules.pro
vendored
18
app/proguard-rules.pro
vendored
|
|
@ -67,6 +67,24 @@
|
|||
|
||||
-keep class com.aryan.reader.paginatedreader.Woff2Converter { *; }
|
||||
|
||||
# R8 can fold the large EPUB Scaffold content lambda into ChapterWebViewKt with
|
||||
# hundreds of captured Compose state parameters. ART rejects that release-only
|
||||
# synthetic method when opening EPUBs, so keep these file facades out of those
|
||||
# optimizer passes while leaving the rest of the app minified.
|
||||
-keep class com.aryan.reader.epubreader.EpubReaderScreenKt { *; }
|
||||
-keep class com.aryan.reader.epubreader.EpubReaderScreenKt$* { *; }
|
||||
-keep class com.aryan.reader.epubreader.ChapterWebViewKt { *; }
|
||||
-keep class com.aryan.reader.epubreader.ChapterWebViewKt$* { *; }
|
||||
|
||||
# The PDF reader is another very large Compose surface. Keeping its file facade
|
||||
# out of release optimizer folding avoids ART's compiler instruction-limit path
|
||||
# and preserves the private pdfium wrapper fields read for native pointer access.
|
||||
-keep class com.aryan.reader.pdf.PdfViewerScreenKt { *; }
|
||||
-keep class com.aryan.reader.pdf.PdfViewerScreenKt$* { *; }
|
||||
-keep class com.aryan.reader.pdf.PdfPageComposableKt { *; }
|
||||
-keep class com.aryan.reader.pdf.PdfPageComposableKt$* { *; }
|
||||
-keep class io.legere.pdfiumandroid.** { *; }
|
||||
|
||||
-dontwarn com.gemalto.jp2.**
|
||||
|
||||
# Flexmark Markdown parser rules
|
||||
|
|
|
|||
|
|
@ -239,6 +239,15 @@
|
|||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
|
|
|
|||
|
|
@ -2043,6 +2043,56 @@
|
|||
`);
|
||||
}
|
||||
|
||||
function parseReaderChunkInt(value, fallback) {
|
||||
const parsed = parseInt(value, 10);
|
||||
return isNaN(parsed) ? fallback : parsed;
|
||||
}
|
||||
|
||||
function getReaderChunkIndex(chunkElement) {
|
||||
return parseReaderChunkInt(chunkElement && chunkElement.dataset ? chunkElement.dataset.chunkIndex : null, 0);
|
||||
}
|
||||
|
||||
function getReaderChunkElementStartIndex(chunkElement) {
|
||||
const chunkIndex = getReaderChunkIndex(chunkElement);
|
||||
return parseReaderChunkInt(
|
||||
chunkElement && chunkElement.dataset ? chunkElement.dataset.elementStartIndex : null,
|
||||
chunkIndex * 20
|
||||
);
|
||||
}
|
||||
|
||||
function getReaderChunkElementCount(chunkElement) {
|
||||
return parseReaderChunkInt(
|
||||
chunkElement && chunkElement.dataset ? chunkElement.dataset.elementCount : null,
|
||||
20
|
||||
);
|
||||
}
|
||||
|
||||
function findReaderChunkForElementIndex(container, childNodeIndex) {
|
||||
const chunks = Array.from(container.querySelectorAll(".chunk-container"));
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunkElement = chunks[i];
|
||||
const elementStartIndex = getReaderChunkElementStartIndex(chunkElement);
|
||||
const elementCount = getReaderChunkElementCount(chunkElement);
|
||||
if (elementCount <= 0) continue;
|
||||
if (childNodeIndex >= elementStartIndex && childNodeIndex < elementStartIndex + elementCount) {
|
||||
return {
|
||||
chunkElement: chunkElement,
|
||||
chunkIndex: getReaderChunkIndex(chunkElement),
|
||||
indexInChunk: childNodeIndex - elementStartIndex
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackChunkIndex = Math.floor(childNodeIndex / 20);
|
||||
const fallbackChunkElement = container.querySelector(`.chunk-container[data-chunk-index="${fallbackChunkIndex}"]`);
|
||||
if (!fallbackChunkElement) return null;
|
||||
return {
|
||||
chunkElement: fallbackChunkElement,
|
||||
chunkIndex: fallbackChunkIndex,
|
||||
indexInChunk: childNodeIndex % 20
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCfiPath(rootElement, path, requestChunkIfMissing = false) {
|
||||
let currentNode = rootElement;
|
||||
const steps = path.substring(1).split("/").map(Number);
|
||||
|
|
@ -2054,10 +2104,12 @@
|
|||
// 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;
|
||||
const chunkLookup = findReaderChunkForElementIndex(currentNode, childNodeIndex);
|
||||
if (!chunkLookup) return null;
|
||||
|
||||
let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`);
|
||||
let chunkIndex = chunkLookup.chunkIndex;
|
||||
let indexInChunk = chunkLookup.indexInChunk;
|
||||
let chunkElement = chunkLookup.chunkElement;
|
||||
if (chunkElement) {
|
||||
if (chunkElement.innerHTML === "") {
|
||||
if (window.virtualization && window.virtualization.chunksData[chunkIndex]) {
|
||||
|
|
@ -2179,8 +2231,8 @@
|
|||
continue;
|
||||
}
|
||||
|
||||
let chunkIndex = parseInt(parentNode.dataset.chunkIndex, 10);
|
||||
let elementsInPrecedingChunks = chunkIndex * 20;
|
||||
let chunkIndex = getReaderChunkIndex(parentNode);
|
||||
let elementsInPrecedingChunks = getReaderChunkElementStartIndex(parentNode);
|
||||
|
||||
let trueIndex = elementsInPrecedingChunks + indexInChunk;
|
||||
let cfiIndex = trueIndex * 2 + 2;
|
||||
|
|
@ -2295,6 +2347,119 @@
|
|||
console.log(TAG_BM + ": " + msg);
|
||||
}
|
||||
|
||||
function normalizeReaderImageSourceForMatch(value) {
|
||||
if (!value) return "";
|
||||
var normalized = String(value).split("#")[0].split("?")[0].replace(/\\/g, "/");
|
||||
try {
|
||||
normalized = decodeURIComponent(normalized);
|
||||
} catch (e) {}
|
||||
if (normalized.indexOf("file://") === 0) {
|
||||
normalized = normalized.substring("file://".length);
|
||||
}
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
function getReaderImageSourceCandidates(element) {
|
||||
if (!element) return [];
|
||||
var values = [
|
||||
element.currentSrc,
|
||||
element.src,
|
||||
element.href && element.href.baseVal,
|
||||
element.getAttribute && element.getAttribute("src"),
|
||||
element.getAttribute && element.getAttribute("href"),
|
||||
element.getAttribute && element.getAttribute("xlink:href"),
|
||||
element.getAttribute && element.getAttribute("data-src"),
|
||||
];
|
||||
return values.filter(function (value, index, array) {
|
||||
return value && array.indexOf(value) === index;
|
||||
});
|
||||
}
|
||||
|
||||
function readerImageCandidateMatches(candidate, normalizedTargets) {
|
||||
var normalizedCandidate = normalizeReaderImageSourceForMatch(candidate);
|
||||
if (!normalizedCandidate) return false;
|
||||
var candidateName = normalizedCandidate.substring(normalizedCandidate.lastIndexOf("/") + 1);
|
||||
|
||||
return normalizedTargets.some(function (target) {
|
||||
if (!target) return false;
|
||||
var targetName = target.substring(target.lastIndexOf("/") + 1);
|
||||
return (
|
||||
normalizedCandidate === target ||
|
||||
normalizedCandidate.endsWith("/" + targetName) ||
|
||||
target.endsWith("/" + candidateName) ||
|
||||
(candidateName && targetName && candidateName === targetName)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function findReaderImageElementsBySource(source, originalSource) {
|
||||
var normalizedTargets = [source, originalSource]
|
||||
.map(normalizeReaderImageSourceForMatch)
|
||||
.filter(Boolean);
|
||||
return getReaderImageElements().filter(function (element) {
|
||||
return getReaderImageSourceCandidates(element).some(function (candidate) {
|
||||
return readerImageCandidateMatches(candidate, normalizedTargets);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findReaderImageChunkIndex(source, originalSource) {
|
||||
if (!window.virtualization || !window.virtualization.chunksData) return -1;
|
||||
var normalizedTargets = [source, originalSource]
|
||||
.map(normalizeReaderImageSourceForMatch)
|
||||
.filter(Boolean);
|
||||
var targetNames = normalizedTargets
|
||||
.map(function (target) {
|
||||
return target.substring(target.lastIndexOf("/") + 1);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (var i = 0; i < window.virtualization.chunksData.length; i++) {
|
||||
var chunkHtml = window.virtualization.chunksData[i];
|
||||
if (!chunkHtml) continue;
|
||||
var normalizedChunk = normalizeReaderImageSourceForMatch(chunkHtml);
|
||||
if (
|
||||
normalizedTargets.some(function (target) {
|
||||
return normalizedChunk.indexOf(target) !== -1;
|
||||
}) ||
|
||||
targetNames.some(function (name) {
|
||||
return normalizedChunk.indexOf(name) !== -1;
|
||||
})
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
window.scrollToReaderImageSource = function(source, ordinal, originalSource) {
|
||||
var safeOrdinal = Math.max(0, parseInt(ordinal || 0, 10) || 0);
|
||||
var matches = findReaderImageElementsBySource(source, originalSource);
|
||||
|
||||
if (!matches.length) {
|
||||
var chunkIndex = findReaderImageChunkIndex(source, originalSource);
|
||||
if (chunkIndex >= 0) {
|
||||
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 = "";
|
||||
}
|
||||
matches = findReaderImageElementsBySource(source, originalSource);
|
||||
}
|
||||
}
|
||||
|
||||
var target = matches[Math.min(safeOrdinal, Math.max(0, matches.length - 1))];
|
||||
if (!target) return false;
|
||||
|
||||
var rect = target.getBoundingClientRect();
|
||||
var targetScrollY = window.scrollY + rect.top - (window.VIEWPORT_PADDING_TOP + 10);
|
||||
window.scrollTo({ top: Math.max(0, targetScrollY), behavior: "auto" });
|
||||
setTimeout(function () {
|
||||
if (window.reportScrollState) window.reportScrollState();
|
||||
}, 80);
|
||||
return true;
|
||||
};
|
||||
|
||||
window.scrollToCfi = function(cfi) {
|
||||
let cleanCfi = cfi;
|
||||
|
||||
|
|
|
|||
|
|
@ -155,9 +155,11 @@ static FPDFAnnot_SetFlags_t set_annot_flags_func = nullptr;
|
|||
static FPDFAnnot_GetFormFieldName_t get_form_field_name_func = nullptr;
|
||||
|
||||
typedef void* (*FPDFAnnot_GetLinkedAnnot_t)(void* annot, const char* key);
|
||||
typedef int (*FPDFAnnot_SetLinkedAnnot_t)(void* annot, const char* key, void* linked_annot);
|
||||
typedef void (*FPDFPage_CloseAnnot_t)(void* annot);
|
||||
|
||||
static FPDFAnnot_GetLinkedAnnot_t get_linked_annot_func = nullptr;
|
||||
static FPDFAnnot_SetLinkedAnnot_t set_linked_annot_func = nullptr;
|
||||
static FPDFPage_CloseAnnot_t close_annot_func = nullptr;
|
||||
static FPDF_LoadDocument_t load_document_func = nullptr;
|
||||
static FPDF_CloseDocument_t close_document_func = nullptr;
|
||||
|
|
@ -219,6 +221,7 @@ static bool init_pdfium() {
|
|||
get_annot_string_func = (FPDFAnnot_GetStringValue_t) dlsym(pdfium_handle, "FPDFAnnot_GetStringValue");
|
||||
get_annot_color_func = (FPDFAnnot_GetColor_t) dlsym(pdfium_handle, "FPDFAnnot_GetColor");
|
||||
get_linked_annot_func = (FPDFAnnot_GetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_GetLinkedAnnot");
|
||||
set_linked_annot_func = (FPDFAnnot_SetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_SetLinkedAnnot");
|
||||
close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot");
|
||||
get_annot_flags_func = (FPDFAnnot_GetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_GetFlags");
|
||||
set_annot_flags_func = (FPDFAnnot_SetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_SetFlags");
|
||||
|
|
@ -605,6 +608,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jcl
|
|||
return result;
|
||||
}
|
||||
|
||||
static constexpr int kPdfAnnotText = 1;
|
||||
static constexpr int kPdfAnnotHighlight = 9;
|
||||
static constexpr int kPdfAnnotInk = 15;
|
||||
static constexpr int kAnnotColor = 0;
|
||||
|
|
@ -669,6 +673,10 @@ static std::vector<std::string> read_string_array(JNIEnv* env, jobjectArray arra
|
|||
return values;
|
||||
}
|
||||
|
||||
static int object_array_length(JNIEnv* env, jobjectArray array) {
|
||||
return array ? env->GetArrayLength(array) : 0;
|
||||
}
|
||||
|
||||
static bool set_annot_string_from_jstring(JNIEnv* env, void* annot, const char* key, jstring value) {
|
||||
if (!set_annot_string_value_func || !annot || !key || !value) return false;
|
||||
jsize length = env->GetStringLength(value);
|
||||
|
|
@ -685,6 +693,22 @@ static bool set_annot_string_from_jstring(JNIEnv* env, void* annot, const char*
|
|||
return set_annot_string_value_func(annot, key, wide.data()) != 0;
|
||||
}
|
||||
|
||||
static bool set_annot_string_from_array(JNIEnv* env, void* annot, const char* key, jobjectArray array, size_t index) {
|
||||
if (!array) return false;
|
||||
jsize length = env->GetArrayLength(array);
|
||||
if (index >= static_cast<size_t>(length)) return false;
|
||||
|
||||
auto value = static_cast<jstring>(env->GetObjectArrayElement(array, static_cast<jsize>(index)));
|
||||
if (!value) return false;
|
||||
if (env->GetStringLength(value) <= 0) {
|
||||
env->DeleteLocalRef(value);
|
||||
return false;
|
||||
}
|
||||
bool result = set_annot_string_from_jstring(env, annot, key, value);
|
||||
env->DeleteLocalRef(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool set_annot_string_from_ascii(void* annot, const char* key, const std::string& value) {
|
||||
if (!set_annot_string_value_func || !annot || !key) return false;
|
||||
std::vector<unsigned short> wide(value.size() + 1);
|
||||
|
|
@ -718,6 +742,20 @@ static FS_RECTF_BRIDGE make_pdf_rect(float left, float top, float right, float b
|
|||
return FS_RECTF_BRIDGE{l, t, r, b};
|
||||
}
|
||||
|
||||
static FS_RECTF_BRIDGE make_pdf_comment_rect(
|
||||
float anchorRight,
|
||||
float anchorTop,
|
||||
float pageWidth,
|
||||
float pageHeight,
|
||||
int commentIndex) {
|
||||
float iconSize = std::min(18.0f, std::max(10.0f, pageWidth * 0.03f));
|
||||
float left = std::min(std::max(anchorRight + 2.0f, 0.0f), std::max(0.0f, pageWidth - iconSize));
|
||||
float top = anchorTop - static_cast<float>(commentIndex) * (iconSize + 2.0f);
|
||||
if (top > pageHeight) top = pageHeight;
|
||||
if (top - iconSize < 0.0f) top = std::min(pageHeight, iconSize);
|
||||
return make_pdf_rect(left, top, left + iconSize, top - iconSize, 0.0f);
|
||||
}
|
||||
|
||||
static float get_page_width_bridge(void* page) {
|
||||
if (get_page_width_func) return get_page_width_func(page);
|
||||
if (get_page_width_double_func) return static_cast<float>(get_page_width_double_func(page));
|
||||
|
|
@ -1066,6 +1104,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
|
|||
jintArray inkPointOffsetsArray,
|
||||
jintArray inkPointCountsArray,
|
||||
jfloatArray inkPointsArray,
|
||||
jobjectArray inkNamesArray,
|
||||
jobjectArray inkContentsArray,
|
||||
jintArray textPageIndicesArray,
|
||||
jfloatArray textBoundsArray,
|
||||
jintArray textColorsArray,
|
||||
|
|
@ -1086,7 +1126,16 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
|
|||
jintArray highlightRectOffsetsArray,
|
||||
jintArray highlightRectCountsArray,
|
||||
jfloatArray highlightRectsArray,
|
||||
jobjectArray highlightContentsArray) {
|
||||
jobjectArray highlightNamesArray,
|
||||
jobjectArray highlightContentsArray,
|
||||
jintArray highlightCommentOffsetsArray,
|
||||
jintArray highlightCommentCountsArray,
|
||||
jintArray highlightCommentParentIndicesArray,
|
||||
jobjectArray highlightCommentNamesArray,
|
||||
jobjectArray highlightCommentAuthorsArray,
|
||||
jobjectArray highlightCommentContentsArray,
|
||||
jobjectArray highlightCommentCreatedDatesArray,
|
||||
jobjectArray highlightCommentModifiedDatesArray) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||
|
||||
if (!init_pdfium() || !validate_export_functions()) {
|
||||
|
|
@ -1142,6 +1191,14 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
|
|||
std::vector<jint> highlightRectOffsets = read_int_array(env, highlightRectOffsetsArray);
|
||||
std::vector<jint> highlightRectCounts = read_int_array(env, highlightRectCountsArray);
|
||||
std::vector<jfloat> highlightRects = read_float_array(env, highlightRectsArray);
|
||||
std::vector<jint> highlightCommentOffsets = read_int_array(env, highlightCommentOffsetsArray);
|
||||
std::vector<jint> highlightCommentCounts = read_int_array(env, highlightCommentCountsArray);
|
||||
std::vector<jint> highlightCommentParentIndices = read_int_array(env, highlightCommentParentIndicesArray);
|
||||
const int highlightCommentNamesLength = object_array_length(env, highlightCommentNamesArray);
|
||||
const int highlightCommentAuthorsLength = object_array_length(env, highlightCommentAuthorsArray);
|
||||
const int highlightCommentContentsLength = object_array_length(env, highlightCommentContentsArray);
|
||||
const int highlightCommentCreatedDatesLength = object_array_length(env, highlightCommentCreatedDatesArray);
|
||||
const int highlightCommentModifiedDatesLength = object_array_length(env, highlightCommentModifiedDatesArray);
|
||||
|
||||
void* document = load_document_func(source.c_str(), nullptr);
|
||||
if (!document) {
|
||||
|
|
@ -1247,7 +1304,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
|
|||
hadFailure = true;
|
||||
}
|
||||
|
||||
set_annot_string_from_ascii(annot, "Contents", "Ink");
|
||||
set_annot_string_from_array(env, annot, "NM", inkNamesArray, i);
|
||||
set_annot_string_from_array(env, annot, "Contents", inkContentsArray, i);
|
||||
if (generate_content_func) generate_content_func(page);
|
||||
close_annot_func(annot);
|
||||
close_page_func(page);
|
||||
|
|
@ -1268,6 +1326,20 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
|
|||
continue;
|
||||
}
|
||||
|
||||
void* page = load_page_func(document, pageIndex);
|
||||
if (!page) {
|
||||
hadFailure = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
float pageWidth = get_page_width_bridge(page);
|
||||
float pageHeight = get_page_height_bridge(page);
|
||||
if (pageWidth <= 0.0f || pageHeight <= 0.0f) {
|
||||
close_page_func(page);
|
||||
hadFailure = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<FS_QUADPOINTSF_BRIDGE> quads;
|
||||
quads.reserve(static_cast<size_t>(rectCount));
|
||||
float unionLeft = 0.0f;
|
||||
|
|
@ -1279,31 +1351,32 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
|
|||
int sourceIndex = (rectOffset + j) * 4;
|
||||
float left = std::min(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]);
|
||||
float right = std::max(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]);
|
||||
float top = std::max(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]);
|
||||
float bottom = std::min(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]);
|
||||
if (right <= left || top <= bottom) continue;
|
||||
float top = std::min(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]);
|
||||
float bottom = std::max(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]);
|
||||
if (right <= left || bottom <= top) continue;
|
||||
|
||||
quads.push_back(FS_QUADPOINTSF_BRIDGE{left, top, right, top, left, bottom, right, bottom});
|
||||
float pdfLeft = clamp_unit(left) * pageWidth;
|
||||
float pdfRight = clamp_unit(right) * pageWidth;
|
||||
float pdfTop = (1.0f - clamp_unit(top)) * pageHeight;
|
||||
float pdfBottom = (1.0f - clamp_unit(bottom)) * pageHeight;
|
||||
if (pdfRight <= pdfLeft || pdfTop <= pdfBottom) continue;
|
||||
|
||||
quads.push_back(FS_QUADPOINTSF_BRIDGE{pdfLeft, pdfTop, pdfRight, pdfTop, pdfLeft, pdfBottom, pdfRight, pdfBottom});
|
||||
if (quads.size() == 1) {
|
||||
unionLeft = left;
|
||||
unionRight = right;
|
||||
unionTop = top;
|
||||
unionBottom = bottom;
|
||||
unionLeft = pdfLeft;
|
||||
unionRight = pdfRight;
|
||||
unionTop = pdfTop;
|
||||
unionBottom = pdfBottom;
|
||||
} else {
|
||||
unionLeft = std::min(unionLeft, left);
|
||||
unionRight = std::max(unionRight, right);
|
||||
unionTop = std::max(unionTop, top);
|
||||
unionBottom = std::min(unionBottom, bottom);
|
||||
unionLeft = std::min(unionLeft, pdfLeft);
|
||||
unionRight = std::max(unionRight, pdfRight);
|
||||
unionTop = std::max(unionTop, pdfTop);
|
||||
unionBottom = std::min(unionBottom, pdfBottom);
|
||||
}
|
||||
}
|
||||
|
||||
if (quads.empty()) {
|
||||
hadFailure = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
void* page = load_page_func(document, pageIndex);
|
||||
if (!page) {
|
||||
close_page_func(page);
|
||||
hadFailure = true;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1329,15 +1402,66 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
|
|||
set_annot_color_func(annot, kAnnotColor, r, g, b, a);
|
||||
if (set_annot_flags_func) set_annot_flags_func(annot, kAnnotFlagPrint);
|
||||
|
||||
if (highlightContentsArray && i < static_cast<size_t>(env->GetArrayLength(highlightContentsArray))) {
|
||||
auto content = static_cast<jstring>(env->GetObjectArrayElement(highlightContentsArray, static_cast<jsize>(i)));
|
||||
if (content) {
|
||||
set_annot_string_from_jstring(env, annot, "Contents", content);
|
||||
env->DeleteLocalRef(content);
|
||||
set_annot_string_from_array(env, annot, "NM", highlightNamesArray, i);
|
||||
set_annot_string_from_array(env, annot, "Contents", highlightContentsArray, i);
|
||||
|
||||
int commentOffset = i < highlightCommentOffsets.size() ? highlightCommentOffsets[i] : 0;
|
||||
int commentCount = i < highlightCommentCounts.size() ? highlightCommentCounts[i] : 0;
|
||||
bool commentPayloadValid = commentCount <= 0 ||
|
||||
(commentOffset >= 0 &&
|
||||
commentOffset + commentCount <= static_cast<int>(highlightCommentParentIndices.size()) &&
|
||||
commentOffset + commentCount <= highlightCommentNamesLength &&
|
||||
commentOffset + commentCount <= highlightCommentAuthorsLength &&
|
||||
commentOffset + commentCount <= highlightCommentContentsLength &&
|
||||
commentOffset + commentCount <= highlightCommentCreatedDatesLength &&
|
||||
commentOffset + commentCount <= highlightCommentModifiedDatesLength);
|
||||
if (!commentPayloadValid) {
|
||||
hadFailure = true;
|
||||
commentCount = 0;
|
||||
}
|
||||
|
||||
std::vector<void*> commentAnnots;
|
||||
commentAnnots.resize(static_cast<size_t>(std::max(0, commentCount)), nullptr);
|
||||
for (int commentIndex = 0; commentIndex < commentCount; commentIndex++) {
|
||||
int globalCommentIndex = commentOffset + commentIndex;
|
||||
void* commentAnnot = create_annot_func(page, kPdfAnnotText);
|
||||
if (!commentAnnot) {
|
||||
hadFailure = true;
|
||||
continue;
|
||||
}
|
||||
commentAnnots[static_cast<size_t>(commentIndex)] = commentAnnot;
|
||||
|
||||
FS_RECTF_BRIDGE commentRect = make_pdf_comment_rect(
|
||||
unionRight,
|
||||
unionTop,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
0
|
||||
);
|
||||
set_annot_rect_func(commentAnnot, &commentRect);
|
||||
set_annot_color_func(commentAnnot, kAnnotColor, r, g, b, 255);
|
||||
if (set_annot_flags_func) set_annot_flags_func(commentAnnot, kAnnotFlagPrint);
|
||||
set_annot_string_from_array(env, commentAnnot, "NM", highlightCommentNamesArray, globalCommentIndex);
|
||||
set_annot_string_from_array(env, commentAnnot, "T", highlightCommentAuthorsArray, globalCommentIndex);
|
||||
set_annot_string_from_array(env, commentAnnot, "Contents", highlightCommentContentsArray, globalCommentIndex);
|
||||
set_annot_string_from_array(env, commentAnnot, "CreationDate", highlightCommentCreatedDatesArray, globalCommentIndex);
|
||||
set_annot_string_from_array(env, commentAnnot, "M", highlightCommentModifiedDatesArray, globalCommentIndex);
|
||||
|
||||
int parentIndex = highlightCommentParentIndices[static_cast<size_t>(globalCommentIndex)];
|
||||
void* parentAnnot = annot;
|
||||
if (parentIndex >= 0 && parentIndex < commentIndex) {
|
||||
void* candidate = commentAnnots[static_cast<size_t>(parentIndex)];
|
||||
if (candidate) parentAnnot = candidate;
|
||||
}
|
||||
if (set_linked_annot_func && parentAnnot) {
|
||||
set_linked_annot_func(commentAnnot, "IRT", parentAnnot);
|
||||
}
|
||||
}
|
||||
|
||||
if (generate_content_func) generate_content_func(page);
|
||||
for (void* commentAnnot : commentAnnots) {
|
||||
if (commentAnnot) close_annot_func(commentAnnot);
|
||||
}
|
||||
close_annot_func(annot);
|
||||
close_page_func(page);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ fun androidSettingsHubInput(
|
|||
isDebugBuild = isDebugBuild,
|
||||
isSignedIn = uiState.currentUser != null,
|
||||
isProUser = uiState.isProUser,
|
||||
accountAvailable = supportsSync,
|
||||
syncAvailable = supportsSync,
|
||||
folderSyncAvailable = supportsSync,
|
||||
aiSettingsAvailable = supportsOssAiKeys,
|
||||
|
|
@ -36,6 +37,7 @@ fun androidSettingsHubInput(
|
|||
includeRecentLimit = true,
|
||||
includeCustomFonts = true,
|
||||
includeStrictFileFilter = true,
|
||||
includePdfFileNameDisplayName = true,
|
||||
includeHideReaderAi = !isOfflineBuild,
|
||||
includeCloudLocalDataClear = supportsSync,
|
||||
supportProjectAvailable = isOssBuild,
|
||||
|
|
@ -43,6 +45,7 @@ fun androidSettingsHubInput(
|
|||
isSyncEnabled = uiState.isSyncEnabled,
|
||||
isFolderSyncEnabled = uiState.isFolderSyncEnabled,
|
||||
useStrictFileFilter = uiState.useStrictFileFilter,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
isScreenCaptureProtectionEnabled = uiState.isScreenCaptureProtectionEnabled,
|
||||
hideReaderAi = hideReaderAi
|
||||
)
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ internal object AndroidSharedStateBridge {
|
|||
val reduced = current.toBridgeSharedState(projectedState).reduce(action)
|
||||
return current.copy(
|
||||
searchQuery = reduced.searchQuery,
|
||||
sortOrder = reduced.sortOrder.toAndroidSortOrder(),
|
||||
libraryFilters = reduced.libraryFilters.toAndroidLibraryFilters(),
|
||||
sortOrder = reduced.sortOrder,
|
||||
libraryFilters = reduced.libraryFilters,
|
||||
contextualActionItems = reduced.selectedBookIds.mapNotNullTo(mutableSetOf()) { androidBooksById[it] },
|
||||
contextualActionShelfIds = reduced.selectedShelfIds,
|
||||
libraryScreenStartPage = reduced.libraryScreenStartPage,
|
||||
|
|
@ -87,12 +87,13 @@ internal object AndroidSharedStateBridge {
|
|||
): ReaderScreenState {
|
||||
val reduced = current.toBridgeSharedState(projectedState).reduce(action)
|
||||
return current.copy(
|
||||
appThemeMode = reduced.appThemeMode.toAndroidAppThemeMode(),
|
||||
appContrastOption = reduced.appContrastOption.toAndroidAppContrastOption(),
|
||||
appThemeMode = reduced.appThemeMode,
|
||||
appContrastOption = reduced.appContrastOption,
|
||||
appTextDimFactorLight = reduced.appTextDimFactorLight,
|
||||
appTextDimFactorDark = reduced.appTextDimFactorDark,
|
||||
appSeedColor = reduced.appSeedColor,
|
||||
customAppThemes = reduced.customAppThemes.map { it.toAndroidCustomAppTheme() }
|
||||
appFontPreference = reduced.appFontPreference,
|
||||
customAppThemes = reduced.customAppThemes
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
22
app/src/main/java/com/aryan/reader/AppFontResolver.kt
Normal file
22
app/src/main/java/com/aryan/reader/AppFontResolver.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import com.aryan.reader.data.CustomFontEntity
|
||||
import java.io.File
|
||||
|
||||
fun AppFontPreference.toAndroidAppFontFamily(customFonts: List<CustomFontEntity>): FontFamily? {
|
||||
val sanitized = sanitized()
|
||||
return when (sanitized.kind) {
|
||||
AppFontPreferenceKind.SYSTEM -> null
|
||||
AppFontPreferenceKind.SERIF -> FontFamily.Serif
|
||||
AppFontPreferenceKind.SANS_SERIF -> FontFamily.SansSerif
|
||||
AppFontPreferenceKind.MONOSPACE -> FontFamily.Monospace
|
||||
AppFontPreferenceKind.CUSTOM -> {
|
||||
val fontId = sanitized.customFontId ?: return null
|
||||
val font = customFonts.firstOrNull { it.id == fontId && !it.isDeleted } ?: return null
|
||||
val file = File(font.path).takeIf { it.isFile } ?: return null
|
||||
runCatching { FontFamily(Font(file)) }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,13 +22,21 @@ package com.aryan.reader
|
|||
import android.os.Build
|
||||
import timber.log.Timber
|
||||
import androidx.annotation.RequiresApi
|
||||
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.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
|
|
@ -54,6 +62,10 @@ import com.aryan.reader.epubreader.EpubReaderScreen
|
|||
import com.aryan.reader.feedback.FeedbackScreen
|
||||
import com.aryan.reader.feedback.SupportProjectScreen
|
||||
import com.aryan.reader.pdf.PdfViewerScreen
|
||||
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||
import com.aryan.reader.tts.ReaderTtsMiniBar
|
||||
import com.aryan.reader.tts.readerTtsMiniBarBottomPaddingDp
|
||||
import com.aryan.reader.tts.shouldShowReaderTtsMiniBar
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
object AppDestinations {
|
||||
|
|
@ -143,18 +155,30 @@ fun AppNavigation(
|
|||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = currentBackStackEntry?.destination?.route
|
||||
val ttsController = viewModel.ttsController
|
||||
val ttsState by ttsController.ttsState.collectAsStateWithLifecycle()
|
||||
val isOnReaderRoute = currentRoute == AppDestinations.PDF_VIEWER_ROUTE ||
|
||||
currentRoute == AppDestinations.EPUB_READER_ROUTE
|
||||
val showTtsMiniBar = shouldShowReaderTtsMiniBar(
|
||||
ttsState = ttsState,
|
||||
isOnReaderRoute = isOnReaderRoute
|
||||
)
|
||||
val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp(
|
||||
isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE
|
||||
).dp
|
||||
|
||||
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
||||
if (!uiState.isLoading) {
|
||||
when (uiState.selectedFileType) {
|
||||
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX -> {
|
||||
when (uiState.selectedFileType?.readerSurfaceOnAndroid()) {
|
||||
ReaderFeatureSurface.PDF_VIEWER -> {
|
||||
if (uiState.selectedPdfUri != null) {
|
||||
if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) {
|
||||
navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> {
|
||||
ReaderFeatureSurface.EPUB_READER,
|
||||
ReaderFeatureSurface.TEXT_READER -> {
|
||||
if (uiState.selectedEpubBook != null) {
|
||||
if (currentRoute != AppDestinations.EPUB_READER_ROUTE) {
|
||||
navController.syncRouteTo(AppDestinations.EPUB_READER_ROUTE)
|
||||
|
|
@ -166,16 +190,12 @@ fun AppNavigation(
|
|||
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
|
||||
}
|
||||
}
|
||||
FileType.UNKNOWN -> {
|
||||
if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) {
|
||||
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
|
||||
composable(AppDestinations.MAIN_ROUTE) {
|
||||
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
|
||||
MainScreen(
|
||||
|
|
@ -379,5 +399,33 @@ fun AppNavigation(
|
|||
onBackClick = { navController.popBackStackIfReady() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showTtsMiniBar,
|
||||
enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)),
|
||||
exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = miniBarBottomPadding)
|
||||
) {
|
||||
ReaderTtsMiniBar(
|
||||
ttsController = ttsController,
|
||||
ttsState = ttsState,
|
||||
onOpenReader = {
|
||||
ttsState.bookId?.let { bookId ->
|
||||
viewModel.openTtsNotificationTarget(
|
||||
bookId = bookId,
|
||||
sourceCfi = ttsState.sourceCfi,
|
||||
startOffset = ttsState.startOffsetInSource.takeIf { it >= 0 },
|
||||
chapterIndex = ttsState.chapterIndex,
|
||||
pageIndex = ttsState.pageIndex
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ typealias BannerMessage = com.aryan.reader.shared.BannerMessage
|
|||
typealias UserData = com.aryan.reader.shared.UserData
|
||||
typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode
|
||||
typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption
|
||||
typealias AppFontPreference = com.aryan.reader.shared.AppFontPreference
|
||||
typealias AppFontPreferenceKind = com.aryan.reader.shared.AppFontPreferenceKind
|
||||
typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme
|
||||
|
||||
data class ImportResult(
|
||||
|
|
@ -50,6 +52,8 @@ data class ReaderScreenState(
|
|||
val initialBookmarksJson: String? = null,
|
||||
val initialHighlightsJson: String? = null,
|
||||
val initialPageInBook: Int? = null,
|
||||
val initialPageInBookIsExplicit: Boolean = false,
|
||||
val isOpeningFromTtsNotification: Boolean = false,
|
||||
val shelves: List<Shelf> = emptyList(),
|
||||
val viewingShelfId: String? = null,
|
||||
val isAddingBooksToShelf: Boolean = false,
|
||||
|
|
@ -95,11 +99,13 @@ data class ReaderScreenState(
|
|||
val showExternalFileSavePromptFor: String? = null,
|
||||
val externalFileBehavior: String = "ASK",
|
||||
val useStrictFileFilter: Boolean = false,
|
||||
val usePdfFileNameAsDisplayName: Boolean = false,
|
||||
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
||||
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
|
||||
val appTextDimFactorLight: Float = 1.0f,
|
||||
val appTextDimFactorDark: Float = 1.0f,
|
||||
val appSeedColor: androidx.compose.ui.graphics.Color? = null,
|
||||
val appFontPreference: AppFontPreference = AppFontPreference.System,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val allTags: List<TagEntity> = emptyList(),
|
||||
val showTagSelectionDialogFor: Set<String> = emptySet(),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import java.io.FileOutputStream
|
|||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
import androidx.core.net.toUri
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
|
||||
private const val BOOKS_DIR = "books"
|
||||
|
||||
|
|
@ -103,15 +104,18 @@ class BookImporter(private val context: Context) {
|
|||
}
|
||||
|
||||
private fun getFileExtension(uri: Uri): String {
|
||||
val path = uri.path ?: return "tmp"
|
||||
return File(path).extension.lowercase().ifEmpty {
|
||||
// Fallback for URIs that don't have a clear extension in the path
|
||||
when (context.contentResolver.getType(uri)) {
|
||||
"application/pdf" -> "pdf"
|
||||
"application/epub+zip" -> "epub"
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation" -> "pptx"
|
||||
else -> "tmp"
|
||||
}
|
||||
}
|
||||
val path = uri.path
|
||||
val pathExtension = path
|
||||
?.let(::File)
|
||||
?.extension
|
||||
?.lowercase()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (pathExtension != null) return pathExtension
|
||||
|
||||
val metadataType = SharedFileCapabilities.resolveFileTypeForMetadata(
|
||||
fileName = uri.lastPathSegment ?: path,
|
||||
mimeType = context.contentResolver.getType(uri)
|
||||
)
|
||||
return metadataType?.let(SharedFileCapabilities::primaryExtensionFor) ?: "tmp"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,11 @@ import com.aryan.reader.epubreader.PREF_CUSTOM_THEMES
|
|||
import com.aryan.reader.epubreader.PREF_READER_THEME
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
import com.aryan.reader.pdf.PdfHighlightColor
|
||||
import com.aryan.reader.shared.BuiltInReaderThemes
|
||||
import com.aryan.reader.shared.ReaderTextureFilePrefix
|
||||
import com.aryan.reader.shared.normalizeReaderTextureExtension
|
||||
import com.aryan.reader.shared.readerTextureDisplayName as sharedReaderTextureDisplayName
|
||||
import com.aryan.reader.shared.readerTextureMimeTypeForExtension
|
||||
import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
|
||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||
import com.aryan.reader.tts.TtsCacheManager
|
||||
|
|
@ -212,6 +217,9 @@ import kotlin.math.min
|
|||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
typealias ReaderTexture = com.aryan.reader.shared.ReaderTexture
|
||||
typealias ReaderTheme = com.aryan.reader.shared.ReaderTheme
|
||||
|
||||
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
|
||||
const val summarizeEndpoint = "/summarize"
|
||||
const val summarizationUrl = aiServerBasePath + summarizeEndpoint
|
||||
|
|
@ -465,18 +473,9 @@ data class SearchResult(
|
|||
val chunkIndex: Int
|
||||
)
|
||||
|
||||
data class AiDefinitionResult(
|
||||
val definition: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
typealias AiDefinitionResult = com.aryan.reader.shared.AiDefinitionResult
|
||||
|
||||
data class SummarizationResult(
|
||||
val summary: String? = null,
|
||||
val error: String? = null,
|
||||
val cost: Double? = null,
|
||||
val freeRemaining: Int? = null,
|
||||
val isCacheHit: Boolean = false
|
||||
)
|
||||
typealias SummarizationResult = com.aryan.reader.shared.SummarizationResult
|
||||
|
||||
data class CachedSummaryItem(
|
||||
val chapterIndex: Int,
|
||||
|
|
@ -2449,51 +2448,26 @@ fun ColorComparePill(
|
|||
}
|
||||
}
|
||||
|
||||
enum class ReaderTexture(val id: String, val displayName: String, val assetPath: String? = null, val resId: Int? = null) {
|
||||
NATURAL_WHITE("asset:ep_naturalwhite.webp", "Natural White", "textures/ep_naturalwhite.webp"),
|
||||
NATURAL_BLACK("asset:ep_naturalblack.webp", "Natural Black", "textures/ep_naturalblack.webp"),
|
||||
LIGHT_VENEER("asset:light-veneer.webp", "Light Veneer", "textures/light-veneer.webp"),
|
||||
RETINA_WOOD("asset:retina_wood.webp", "Retina Wood", "textures/retina_wood.webp"),
|
||||
GREY_WASH("asset:grey_wash_wall.webp", "Grey Wash", "textures/grey_wash_wall.webp"),
|
||||
CLASSY_FABRIC("asset:classy_fabric.webp", "Classy Fabric", "textures/classy_fabric.webp"),
|
||||
RETRO_INTRO("asset:retro_intro.webp", "Retro Intro", "textures/retro_intro.webp"),
|
||||
PAPER("paper", "Paper", resId = R.drawable.texture_paper),
|
||||
CANVAS("canvas", "Canvas", resId = R.drawable.texture_canvas),
|
||||
EINK("eink", "E-Ink", resId = R.drawable.texture_eink),
|
||||
SLATE("slate", "Slate", resId = R.drawable.texture_slate)
|
||||
}
|
||||
|
||||
private const val TEXTURE_FILE_PREFIX = "file:"
|
||||
private const val READER_TEXTURE_DIR = "reader_textures"
|
||||
|
||||
fun readerTextureDisplayName(textureId: String?): String {
|
||||
if (textureId == null) return "None"
|
||||
return ReaderTexture.entries.find { it.id == textureId }?.displayName
|
||||
?: File(textureId.removePrefix(TEXTURE_FILE_PREFIX)).nameWithoutExtension.ifBlank { "Custom Image" }
|
||||
return sharedReaderTextureDisplayName(textureId)
|
||||
}
|
||||
|
||||
fun importReaderTexture(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
val extension = context.contentResolver.getType(uri)
|
||||
?.substringAfterLast('/')
|
||||
?.lowercase(Locale.ROOT)
|
||||
?.let {
|
||||
when (it) {
|
||||
"jpeg", "jpg" -> "jpg"
|
||||
"png", "webp", "gif", "bmp" -> it
|
||||
else -> null
|
||||
}
|
||||
} ?: uri.lastPathSegment
|
||||
?.substringAfterLast('.', "")
|
||||
?.lowercase(Locale.ROOT)
|
||||
?.takeIf { it in setOf("jpg", "jpeg", "png", "webp", "gif", "bmp") }
|
||||
val extension = normalizeReaderTextureExtension(
|
||||
context.contentResolver.getType(uri)?.substringAfterLast('/')
|
||||
) ?: normalizeReaderTextureExtension(
|
||||
uri.lastPathSegment?.substringAfterLast('.', "")
|
||||
)
|
||||
?: "img"
|
||||
val dir = File(context.filesDir, READER_TEXTURE_DIR).apply { mkdirs() }
|
||||
val output = File(dir, "texture_${System.currentTimeMillis()}.$extension")
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
output.outputStream().use { out -> input.copyTo(out) }
|
||||
} ?: return null
|
||||
TEXTURE_FILE_PREFIX + output.absolutePath
|
||||
ReaderTextureFilePrefix + output.absolutePath
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to import reader texture")
|
||||
null
|
||||
|
|
@ -2564,17 +2538,17 @@ private fun calculateBitmapSampleSize(width: Int, height: Int, maxDimension: Int
|
|||
fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? {
|
||||
if (textureId == null) return null
|
||||
return try {
|
||||
val bitmap = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) {
|
||||
val bitmap = if (textureId.startsWith(ReaderTextureFilePrefix)) {
|
||||
decodeSampledBitmapFile(
|
||||
path = textureId.removePrefix(TEXTURE_FILE_PREFIX),
|
||||
path = textureId.removePrefix(ReaderTextureFilePrefix),
|
||||
maxDimension = MAX_READER_TEXTURE_DIMENSION_PX
|
||||
)
|
||||
} else {
|
||||
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
|
||||
val resourceId = texture.androidTextureResourceId()
|
||||
when {
|
||||
texture.assetPath != null -> context.assets.open(texture.assetPath).use(BitmapFactory::decodeStream)
|
||||
texture.resId != null -> BitmapFactory.decodeResource(context.resources, texture.resId)
|
||||
else -> null
|
||||
resourceId != null -> BitmapFactory.decodeResource(context.resources, resourceId)
|
||||
else -> context.assets.open(texture.assetPath).use(BitmapFactory::decodeStream)
|
||||
}
|
||||
}
|
||||
val safeBitmap = bitmap?.scaledToCanvasLimit(
|
||||
|
|
@ -2595,8 +2569,8 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? {
|
|||
if (textureId == null) return null
|
||||
return try {
|
||||
var mimeType = "image/png"
|
||||
val bytes = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) {
|
||||
val file = File(textureId.removePrefix(TEXTURE_FILE_PREFIX))
|
||||
val bytes = if (textureId.startsWith(ReaderTextureFilePrefix)) {
|
||||
val file = File(textureId.removePrefix(ReaderTextureFilePrefix))
|
||||
mimeType = "image/png"
|
||||
val decodedBitmap = decodeSampledBitmapFile(file.absolutePath, MAX_READER_TEXTURE_DIMENSION_PX)
|
||||
?: return null
|
||||
|
|
@ -2617,19 +2591,19 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? {
|
|||
}
|
||||
} else {
|
||||
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
|
||||
val resourceId = texture.androidTextureResourceId()
|
||||
when {
|
||||
texture.assetPath != null -> {
|
||||
mimeType = imageMimeTypeForExtension(texture.assetPath.substringAfterLast('.', "png"))
|
||||
context.assets.open(texture.assetPath).use { it.readBytes() }
|
||||
}
|
||||
texture.resId != null -> {
|
||||
val bitmap = BitmapFactory.decodeResource(context.resources, texture.resId)
|
||||
resourceId != null -> {
|
||||
val bitmap = BitmapFactory.decodeResource(context.resources, resourceId)
|
||||
ByteArrayOutputStream().use { out ->
|
||||
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
|
||||
out.toByteArray()
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
else -> {
|
||||
mimeType = readerTextureMimeTypeForExtension(texture.assetPath.substringAfterLast('.', "png"))
|
||||
context.assets.open(texture.assetPath).use { it.readBytes() }
|
||||
}
|
||||
}
|
||||
} ?: return null
|
||||
"data:$mimeType;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
|
|
@ -2639,40 +2613,17 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? {
|
|||
}
|
||||
}
|
||||
|
||||
private fun imageMimeTypeForExtension(extension: String): String {
|
||||
return when (extension.lowercase(Locale.ROOT)) {
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"webp" -> "image/webp"
|
||||
"gif" -> "image/gif"
|
||||
"bmp" -> "image/bmp"
|
||||
else -> "image/png"
|
||||
private fun ReaderTexture.androidTextureResourceId(): Int? {
|
||||
return when (this) {
|
||||
ReaderTexture.PAPER -> R.drawable.texture_paper
|
||||
ReaderTexture.CANVAS -> R.drawable.texture_canvas
|
||||
ReaderTexture.EINK -> R.drawable.texture_eink
|
||||
ReaderTexture.SLATE -> R.drawable.texture_slate
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderTheme(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val backgroundColor: Color,
|
||||
val textColor: Color,
|
||||
val isDark: Boolean,
|
||||
val textureId: String? = null,
|
||||
val isCustom: Boolean = false
|
||||
)
|
||||
|
||||
val BuiltInThemes = listOf(
|
||||
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false),
|
||||
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
|
||||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
|
||||
ReaderTheme("natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
|
||||
ReaderTheme("retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
|
||||
ReaderTheme("veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
|
||||
ReaderTheme("grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
|
||||
ReaderTheme("fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
|
||||
ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||
)
|
||||
val BuiltInThemes = BuiltInReaderThemes
|
||||
|
||||
fun saveReaderThemeId(context: Context, themeId: String) {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
|
|
@ -2700,7 +2651,10 @@ fun getImportedTextures(context: Context): List<String> {
|
|||
return try {
|
||||
val dir = File(context.filesDir, READER_TEXTURE_DIR)
|
||||
if (!dir.exists()) emptyList()
|
||||
else dir.listFiles()?.map { TEXTURE_FILE_PREFIX + it.absolutePath } ?: emptyList()
|
||||
else dir.listFiles()
|
||||
?.filter { it.isFile }
|
||||
?.map { ReaderTextureFilePrefix + it.absolutePath }
|
||||
?: emptyList()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
|
@ -3181,7 +3135,7 @@ private fun TexturePickerSection(
|
|||
)
|
||||
TextureChoice(
|
||||
label = stringResource(R.string.theme_texture_upload),
|
||||
textureId = selectedTextureId?.takeIf { it.startsWith(TEXTURE_FILE_PREFIX) },
|
||||
textureId = selectedTextureId?.takeIf { it.startsWith(ReaderTextureFilePrefix) },
|
||||
selectedTextureId = selectedTextureId,
|
||||
onTextureSelected = { onImportTexture() },
|
||||
isUpload = true,
|
||||
|
|
@ -3189,7 +3143,7 @@ private fun TexturePickerSection(
|
|||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReaderTexture.entries.filter { it.assetPath != null }.chunked(2).forEach { rowTextures ->
|
||||
ReaderTexture.entries.filter { it.androidTextureResourceId() == null }.chunked(2).forEach { rowTextures ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
rowTextures.forEach { texture ->
|
||||
TextureChoice(
|
||||
|
|
@ -3234,7 +3188,7 @@ private fun TextureChoice(
|
|||
) {
|
||||
val context = LocalContext.current
|
||||
val textureBitmap = remember(textureId) { loadReaderTextureBitmap(context, textureId) }
|
||||
val selected = if (isUpload) selectedTextureId?.startsWith(TEXTURE_FILE_PREFIX) == true else selectedTextureId == textureId
|
||||
val selected = if (isUpload) selectedTextureId?.startsWith(ReaderTextureFilePrefix) == true else selectedTextureId == textureId
|
||||
Surface(
|
||||
onClick = { onTextureSelected(textureId) },
|
||||
modifier = modifier.height(52.dp),
|
||||
|
|
@ -3254,7 +3208,7 @@ private fun TextureChoice(
|
|||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (isUpload && selectedTextureId?.startsWith(TEXTURE_FILE_PREFIX) == true) {
|
||||
text = if (isUpload && selectedTextureId?.startsWith(ReaderTextureFilePrefix) == true) {
|
||||
readerTextureDisplayName(selectedTextureId)
|
||||
} else label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
|
|
@ -3868,8 +3822,11 @@ fun AiResultContentView(
|
|||
|
||||
val showUsageBadge = result?.isCacheHit == true || (BuildConfig.FLAVOR != "oss" && (result?.cost != null || isLoading))
|
||||
if (result != null && showUsageBadge && (!result.summary.isNullOrBlank() || isLoading)) {
|
||||
val cost = result.cost
|
||||
val freeRemaining = result.freeRemaining
|
||||
val isFreeGeneratedResult = cost == 0.0 && freeRemaining != null
|
||||
Surface(
|
||||
color = if (result.isCacheHit || (result.cost == 0.0 && result.freeRemaining != null)) Color(
|
||||
color = if (result.isCacheHit || isFreeGeneratedResult) Color(
|
||||
0xFF4CAF50
|
||||
).copy(alpha = 0.2f) else MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
|
|
@ -3877,19 +3834,19 @@ fun AiResultContentView(
|
|||
Text(
|
||||
text = if (result.isCacheHit) {
|
||||
stringResource(R.string.ai_cache_hit_free)
|
||||
} else if (result.cost != null) {
|
||||
if (result.cost == 0.0 && result.freeRemaining != null) {
|
||||
stringResource(R.string.ai_generated_free_remaining,
|
||||
result.freeRemaining
|
||||
} else if (cost != null) {
|
||||
if (isFreeGeneratedResult) {
|
||||
safeStringResource(R.string.ai_generated_free_remaining,
|
||||
freeRemaining
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.ai_generated_cost, result.cost.toString())
|
||||
safeStringResource(R.string.ai_generated_cost, cost.toString())
|
||||
}
|
||||
} else {
|
||||
stringResource(R.string.ai_generating_cost_calculating)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (result.isCacheHit || (result.cost == 0.0 && result.freeRemaining != null)) Color(
|
||||
color = if (result.isCacheHit || isFreeGeneratedResult) Color(
|
||||
0xFF388E3C
|
||||
) else MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
|
|
|
|||
|
|
@ -7,42 +7,7 @@ internal fun resolveFileTypeFromName(fileName: String?): FileType? {
|
|||
}
|
||||
|
||||
internal fun resolveFileTypeFromMetadata(fileName: String?, mimeType: String?): FileType? {
|
||||
val normalizedMimeType = mimeType
|
||||
?.substringBefore(';')
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
return when (normalizedMimeType) {
|
||||
"application/vnd.oasis.opendocument.text" -> FileType.ODT
|
||||
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation" -> FileType.PPTX
|
||||
"application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
|
||||
when {
|
||||
fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
|
||||
fileName?.endsWith(".fb2.zip", ignoreCase = true) == true -> FileType.FB2
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
"application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> {
|
||||
if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null
|
||||
}
|
||||
"application/x-cb7", "application/x-7z-compressed" -> {
|
||||
if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null
|
||||
}
|
||||
"application/pdf" -> FileType.PDF
|
||||
"application/epub+zip" -> FileType.EPUB
|
||||
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2
|
||||
"application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
|
||||
"text/markdown", "text/x-markdown" -> FileType.MD
|
||||
"text/html", "application/xhtml+xml" -> FileType.HTML
|
||||
"text/csv", "text/comma-separated-values", "text/tab-separated-values",
|
||||
"application/json", "application/xml", "text/xml",
|
||||
"text/x-java-source", "text/x-python", "text/x-kotlin",
|
||||
"text/javascript", "application/javascript",
|
||||
"text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML
|
||||
"text/plain" -> resolveFileTypeFromName(fileName) ?: FileType.TXT
|
||||
else -> resolveFileTypeFromName(fileName)
|
||||
}
|
||||
return SharedFileCapabilities.resolveFileTypeForMetadata(fileName, mimeType)
|
||||
}
|
||||
|
||||
internal fun isCodeOrDataFileName(fileName: String): Boolean {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,16 @@ import kotlinx.coroutines.withContext
|
|||
import androidx.core.content.edit
|
||||
import com.aryan.reader.data.LocalSyncUtils
|
||||
import com.aryan.reader.data.FolderBookMetadata
|
||||
import com.aryan.reader.data.toSharedFolderBookMetadata
|
||||
import com.aryan.reader.shared.BookItem as SharedBookItem
|
||||
import com.aryan.reader.shared.EpubAnnotationSerializer
|
||||
import com.aryan.reader.shared.EpubBookmark
|
||||
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||
import com.aryan.reader.shared.LocalFolderSyncEngine
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.SharedFolderScannedFile
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import java.io.File
|
||||
import android.provider.DocumentsContract
|
||||
|
||||
|
|
@ -53,7 +63,6 @@ class FolderSyncWorker(
|
|||
const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
|
||||
const val KEY_METADATA_ONLY = "key_metadata_only"
|
||||
const val KEY_TARGET_FOLDER_URI = "key_target_folder_uri"
|
||||
private const val SCAN_DB_BATCH_SIZE = 600
|
||||
private val syncMutex = Mutex()
|
||||
}
|
||||
|
||||
|
|
@ -151,11 +160,7 @@ class FolderSyncWorker(
|
|||
var dirsScanned = 0
|
||||
var filesSeen = 0
|
||||
var supportedBooksSeen = 0
|
||||
var newBooks = 0
|
||||
var updatedBooks = 0
|
||||
var unchangedBooks = 0
|
||||
var dbFlushes = 0
|
||||
var scanDbFlushes = 0
|
||||
var sidecarsImported = 0
|
||||
var stoppedForUnlinkedFolder = false
|
||||
|
||||
|
|
@ -179,7 +184,7 @@ class FolderSyncWorker(
|
|||
return false
|
||||
}
|
||||
|
||||
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration skipped")
|
||||
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration mapped-to-shared")
|
||||
|
||||
val folderMetadataMap = ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase metadata-sidecars",
|
||||
|
|
@ -192,324 +197,148 @@ class FolderSyncWorker(
|
|||
"FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString"
|
||||
)
|
||||
|
||||
val preloadedSidecars = mutableMapOf<String, Pair<Long, String>>()
|
||||
val existingFolderBooks = ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase load-existing-db",
|
||||
minLogMs = 25L
|
||||
) {
|
||||
recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||
}
|
||||
val existingFolderBooksById = existingFolderBooks.associateBy { it.bookId }
|
||||
val remoteMetadataUpdates = mutableListOf<RecentFileItem>()
|
||||
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
|
||||
|
||||
folderMetadataMap.forEach { (bookId, remoteMeta) ->
|
||||
val existingItem = existingFolderBooksById[bookId]
|
||||
val scanResult = if (metadataOnly) {
|
||||
AndroidFolderScanResult()
|
||||
} else {
|
||||
ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase scan-folder",
|
||||
minLogMs = 25L
|
||||
) {
|
||||
scanFolderFiles(
|
||||
folderUri = folderUri,
|
||||
folderUriString = folderUriString,
|
||||
allowedFileTypes = allowedFileTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
dirsScanned = scanResult.dirsScanned
|
||||
filesSeen = scanResult.filesSeen
|
||||
supportedBooksSeen = scanResult.files.size
|
||||
stoppedForUnlinkedFolder = scanResult.stoppedForUnlinkedFolder
|
||||
|
||||
if (existingItem != null) {
|
||||
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
|
||||
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,
|
||||
lastPositionCfi = remoteMeta.lastPositionCfi,
|
||||
progressPercentage = remoteMeta.progressPercentage,
|
||||
bookmarksJson = remoteMeta.bookmarksJson,
|
||||
highlightsJson = remoteMeta.highlightsJson,
|
||||
customName = remoteMeta.customName,
|
||||
locatorBlockIndex = remoteMeta.locatorBlockIndex,
|
||||
locatorCharOffset = remoteMeta.locatorCharOffset,
|
||||
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
|
||||
isRecent = remoteMeta.isRecent || existingItem.isRecent,
|
||||
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
|
||||
)
|
||||
remoteMetadataUpdates.add(itemToUpdate)
|
||||
} else {
|
||||
Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}")
|
||||
}
|
||||
if (isStopped || stoppedForUnlinkedFolder) {
|
||||
ReaderPerfLog.w(
|
||||
"FolderSync folder aborted before shared engine stopped=$isStopped " +
|
||||
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
val nowMillis = System.currentTimeMillis()
|
||||
val folder = SyncedFolder(
|
||||
uriString = folderUriString,
|
||||
name = documentTree.name ?: "Local Folder",
|
||||
lastScanTime = nowMillis,
|
||||
allowedFileTypes = allowedFileTypes
|
||||
)
|
||||
val sharedState = SharedReaderScreenState(
|
||||
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
|
||||
syncedFolders = listOf(folder)
|
||||
)
|
||||
val syncResult = LocalFolderSyncEngine.syncFolder(
|
||||
state = sharedState,
|
||||
folder = folder,
|
||||
files = scanResult.files,
|
||||
remoteMetadata = folderMetadataMap.mapValues { it.value.toSharedFolderBookMetadata() },
|
||||
nowMillis = nowMillis,
|
||||
metadataOnly = metadataOnly
|
||||
)
|
||||
|
||||
if (syncResult.idMigrations.isNotEmpty()) {
|
||||
val preloadedSidecars = ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase migration-sidecars",
|
||||
minLogMs = 25L
|
||||
) {
|
||||
LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri).toMutableMap()
|
||||
}
|
||||
syncResult.idMigrations.forEach { (oldId, newId) ->
|
||||
Timber.tag("FolderSync").i("Migrating folder book ID via shared engine $oldId -> $newId")
|
||||
migrateFolderBookId(
|
||||
folderUriString = folderUriString,
|
||||
oldId = oldId,
|
||||
newId = newId,
|
||||
folderMetadataMap = folderMetadataMap,
|
||||
preloadedSidecars = preloadedSidecars,
|
||||
existingItemsMap = existingItemsMap
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteMetadataUpdates.isNotEmpty()) {
|
||||
recentFilesRepository.addRecentFiles(remoteMetadataUpdates)
|
||||
if (!isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync folder abort: folder unlinked before DB write folder=$folderUriString")
|
||||
stoppedForUnlinkedFolder = true
|
||||
return true
|
||||
}
|
||||
|
||||
val scannedFilesById = scanResult.files.associateBy { it.stableBookId }
|
||||
val syncedItems = syncResult.state.rawLibraryBooks.map { book ->
|
||||
val existing = existingItemsMap[book.id]
|
||||
val metadata = appliedMetadataFor(
|
||||
book = book,
|
||||
existing = existing,
|
||||
metadata = folderMetadataMap[book.id]
|
||||
)
|
||||
book.toFolderSyncRecentFileItem(
|
||||
existing = existing,
|
||||
appliedMetadata = metadata,
|
||||
scannedFile = scannedFilesById[book.id],
|
||||
nowMillis = nowMillis
|
||||
)
|
||||
}
|
||||
val changedItems = syncedItems.filter { item -> existingItemsMap[item.bookId] != item }
|
||||
|
||||
changedItems
|
||||
.filter { item ->
|
||||
val previous = existingItemsMap[item.bookId]
|
||||
previous != null && folderFileContentChanged(previous, item)
|
||||
}
|
||||
.forEach { item ->
|
||||
Timber.tag("FolderSync").i("File content changed for ${item.displayName}; refreshing extracted metadata.")
|
||||
recentFilesRepository.clearLocalCachesForBook(item.bookId)
|
||||
}
|
||||
|
||||
if (changedItems.isNotEmpty()) {
|
||||
recentFilesRepository.addRecentFiles(changedItems)
|
||||
dbFlushes++
|
||||
ReaderPerfLog.d(
|
||||
"FolderSync applied remote metadata updates count=${remoteMetadataUpdates.size} folder=$folderUriString"
|
||||
)
|
||||
}
|
||||
|
||||
if (metadataOnly) {
|
||||
sidecarsImported += importAnnotationSidecarsForBooks(
|
||||
folderUri = folderUri,
|
||||
folderUriString = folderUriString,
|
||||
books = existingFolderBooks,
|
||||
phase = "metadata-only"
|
||||
)
|
||||
if (!metadataOnly && syncResult.removedBookIds.isNotEmpty()) {
|
||||
Timber.tag("FolderSync").i("Cleaning up ${syncResult.removedBookIds.size} missing folder books.")
|
||||
recentFilesRepository.deleteFilePermanently(syncResult.removedBookIds.toList())
|
||||
}
|
||||
|
||||
if (!metadataOnly) {
|
||||
Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...")
|
||||
val contentResolver = appContext.contentResolver
|
||||
val foundBookIds = mutableSetOf<String>()
|
||||
val newOrUpdatedItems = mutableListOf<RecentFileItem>()
|
||||
val existingItemsMap = existingFolderBooksById.toMutableMap()
|
||||
val existingItemsByUri = existingFolderBooks
|
||||
.mapNotNull { item -> item.uriString?.let { uri -> uri to item } }
|
||||
.toMap()
|
||||
val legacyItemsByName = existingFolderBooks
|
||||
.asSequence()
|
||||
.filter { it.bookId.startsWith("local_${it.displayName}_") }
|
||||
.groupBy { it.displayName }
|
||||
.mapValues { entry ->
|
||||
ArrayDeque<RecentFileItem>().apply { addAll(entry.value) }
|
||||
}
|
||||
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
|
||||
val dirQueue = ArrayDeque<String>()
|
||||
dirQueue.add(rootDocId)
|
||||
|
||||
val projection = arrayOf(
|
||||
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
|
||||
if (!isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString")
|
||||
stoppedForUnlinkedFolder = true
|
||||
break
|
||||
}
|
||||
val currentDocId = dirQueue.removeFirst()
|
||||
dirsScanned++
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
|
||||
|
||||
try {
|
||||
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
|
||||
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 && !stoppedForUnlinkedFolder) {
|
||||
val docId = cursor.getString(idCol)
|
||||
val name = cursor.getString(nameCol) ?: ""
|
||||
val mimeType = cursor.getString(mimeCol)
|
||||
filesSeen++
|
||||
|
||||
if (filesSeen % 100 == 0 && !isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync folder abort: folder unlinked after entries=$filesSeen folder=$folderUriString")
|
||||
stoppedForUnlinkedFolder = true
|
||||
break
|
||||
}
|
||||
|
||||
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
if (!name.startsWith(".") && name != "EpistemeSyncData") {
|
||||
dirQueue.add(docId)
|
||||
}
|
||||
} else {
|
||||
val size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L
|
||||
val lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
|
||||
|
||||
val type = getFileType(name, mimeType)
|
||||
if (
|
||||
type != null &&
|
||||
type in allowedFileTypes &&
|
||||
isLocalFolderSyncEligibleFile(name, mimeType) &&
|
||||
!name.endsWith(".json") &&
|
||||
!name.startsWith(".")
|
||||
) {
|
||||
supportedBooksSeen++
|
||||
val stableId = buildStableBookId(name, rootDocId, docId)
|
||||
foundBookIds.add(stableId)
|
||||
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
|
||||
val docUriString = docUri.toString()
|
||||
var existingItem = existingItemsMap[stableId]
|
||||
|
||||
if (existingItem != null && existingItem.uriString != docUriString) {
|
||||
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 = existingItemsByUri[docUriString]?.takeIf { it.bookId != stableId }
|
||||
?: legacyItemsByName[name]?.firstOrNull {
|
||||
it.bookId != stableId
|
||||
}
|
||||
if (oldItem != null) {
|
||||
val oldId = oldItem.bookId
|
||||
Timber.tag("FolderSync").i("Migrating book ID for $name from $oldId to $stableId")
|
||||
|
||||
migrateFolderBookId(
|
||||
folderUriString = folderUriString,
|
||||
oldId = oldId,
|
||||
newId = stableId,
|
||||
folderMetadataMap = folderMetadataMap,
|
||||
preloadedSidecars = preloadedSidecars,
|
||||
existingItemsMap = existingItemsMap
|
||||
)
|
||||
legacyItemsByName[name]?.remove(oldItem)
|
||||
existingItem = existingItemsMap[stableId]
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem == null) {
|
||||
val remoteMeta = folderMetadataMap[stableId]
|
||||
|
||||
val newItem = RecentFileItem(
|
||||
bookId = stableId,
|
||||
uriString = docUri.toString(),
|
||||
type = type,
|
||||
displayName = name,
|
||||
timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
||||
lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
||||
coverImagePath = null,
|
||||
title = name.substringBeforeLast('.', name),
|
||||
author = remoteMeta?.author,
|
||||
isAvailable = true,
|
||||
isDeleted = false,
|
||||
isRecent = remoteMeta?.isRecent ?: false,
|
||||
sourceFolderUri = folderUriString,
|
||||
lastChapterIndex = remoteMeta?.lastChapterIndex,
|
||||
lastPage = remoteMeta?.lastPage,
|
||||
lastPositionCfi = remoteMeta?.lastPositionCfi,
|
||||
progressPercentage = remoteMeta?.progressPercentage,
|
||||
bookmarksJson = remoteMeta?.bookmarksJson,
|
||||
highlightsJson = remoteMeta?.highlightsJson,
|
||||
customName = remoteMeta?.customName,
|
||||
locatorBlockIndex = remoteMeta?.locatorBlockIndex,
|
||||
locatorCharOffset = remoteMeta?.locatorCharOffset,
|
||||
fileSize = size,
|
||||
fileContentModifiedTimestamp = lastModified
|
||||
)
|
||||
newOrUpdatedItems.add(newItem)
|
||||
newBooks++
|
||||
} else {
|
||||
var needsUpdate = false
|
||||
var updatedItem = existingItem
|
||||
|
||||
val modifiedChanged = lastModified > 0L &&
|
||||
existingItem.fileContentModifiedTimestamp != lastModified
|
||||
if ((size > 0L && existingItem.fileSize != size) || modifiedChanged) {
|
||||
Timber.tag("FolderSync").i("File content changed for $name; refreshing extracted metadata.")
|
||||
recentFilesRepository.clearLocalCachesForBook(stableId)
|
||||
updatedItem = updatedItem.copy(
|
||||
fileSize = size,
|
||||
fileContentModifiedTimestamp = lastModified,
|
||||
lastModifiedTimestamp = lastModified,
|
||||
coverImagePath = null,
|
||||
title = name.substringBeforeLast('.', name),
|
||||
author = null,
|
||||
seriesName = null,
|
||||
seriesIndex = null,
|
||||
description = null,
|
||||
originalTitle = null,
|
||||
originalAuthor = null,
|
||||
originalSeriesName = null,
|
||||
originalSeriesIndex = null,
|
||||
originalDescription = null,
|
||||
folderTextMetadataParsed = false,
|
||||
folderCoverMetadataParsed = false
|
||||
)
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if (updatedItem.isDeleted || !updatedItem.isAvailable) {
|
||||
updatedItem = updatedItem.copy(isDeleted = false, isAvailable = true)
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if (updatedItem.uriString != docUri.toString()) {
|
||||
updatedItem = updatedItem.copy(uriString = docUri.toString())
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
newOrUpdatedItems.add(updatedItem)
|
||||
updatedBooks++
|
||||
} else {
|
||||
unchangedBooks++
|
||||
}
|
||||
}
|
||||
|
||||
val batchLimit = if (scanDbFlushes == 0) 40 else SCAN_DB_BATCH_SIZE
|
||||
if (newOrUpdatedItems.size >= batchLimit) {
|
||||
if (!isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync batch dropped: folder unlinked pending=${newOrUpdatedItems.size} folder=$folderUriString")
|
||||
newOrUpdatedItems.clear()
|
||||
stoppedForUnlinkedFolder = true
|
||||
break
|
||||
}
|
||||
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
|
||||
dbFlushes++
|
||||
scanDbFlushes++
|
||||
newOrUpdatedItems.clear()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
|
||||
}
|
||||
|
||||
if (stoppedForUnlinkedFolder) break
|
||||
}
|
||||
|
||||
if (!stoppedForUnlinkedFolder && newOrUpdatedItems.isNotEmpty()) {
|
||||
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
|
||||
dbFlushes++
|
||||
scanDbFlushes++
|
||||
newOrUpdatedItems.clear()
|
||||
}
|
||||
|
||||
if (!isStopped && !stoppedForUnlinkedFolder) {
|
||||
val idsToRemove = existingItemsMap.keys.filter { it !in foundBookIds }
|
||||
|
||||
if (idsToRemove.isNotEmpty()) {
|
||||
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
|
||||
recentFilesRepository.deleteFilePermanently(idsToRemove)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadataOnly && !isStopped && !stoppedForUnlinkedFolder) {
|
||||
val booksForAnnotationSync = ReaderPerfLog.measureSuspend(
|
||||
val booksForAnnotationSync = if (metadataOnly) {
|
||||
syncedItems
|
||||
} else {
|
||||
ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase load-post-scan-db",
|
||||
minLogMs = 25L
|
||||
) {
|
||||
recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||
}
|
||||
sidecarsImported += importAnnotationSidecarsForBooks(
|
||||
folderUri = folderUri,
|
||||
folderUriString = folderUriString,
|
||||
books = booksForAnnotationSync,
|
||||
phase = "post-scan"
|
||||
)
|
||||
}
|
||||
sidecarsImported += importAnnotationSidecarsForBooks(
|
||||
folderUri = folderUri,
|
||||
folderUriString = folderUriString,
|
||||
books = booksForAnnotationSync,
|
||||
phase = if (metadataOnly) "metadata-only" else "post-scan"
|
||||
)
|
||||
|
||||
val elapsed = ReaderPerfLog.elapsedMs(folderStart)
|
||||
ReaderPerfLog.i(
|
||||
"FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " +
|
||||
"dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " +
|
||||
"new=$newBooks updated=$updatedBooks unchanged=$unchangedBooks " +
|
||||
"new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " +
|
||||
"remoteUpdates=${syncResult.stats.remoteMetadataUpdates} unchanged=${syncResult.stats.unchangedBooks} " +
|
||||
"removed=${syncResult.stats.removedBooks} migrated=${syncResult.stats.migratedBooks} " +
|
||||
"dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " +
|
||||
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
|
||||
)
|
||||
|
|
@ -601,6 +430,294 @@ class FolderSyncWorker(
|
|||
return imported
|
||||
}
|
||||
|
||||
private data class AndroidFolderScanResult(
|
||||
val files: List<SharedFolderScannedFile> = emptyList(),
|
||||
val dirsScanned: Int = 0,
|
||||
val filesSeen: Int = 0,
|
||||
val stoppedForUnlinkedFolder: Boolean = false
|
||||
)
|
||||
|
||||
private fun scanFolderFiles(
|
||||
folderUri: android.net.Uri,
|
||||
folderUriString: String,
|
||||
allowedFileTypes: Set<FileType>
|
||||
): AndroidFolderScanResult {
|
||||
Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...")
|
||||
val contentResolver = appContext.contentResolver
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
|
||||
val dirQueue = ArrayDeque<String>()
|
||||
val scannedFiles = mutableListOf<SharedFolderScannedFile>()
|
||||
var dirsScanned = 0
|
||||
var filesSeen = 0
|
||||
var stoppedForUnlinkedFolder = false
|
||||
dirQueue.add(rootDocId)
|
||||
|
||||
val projection = arrayOf(
|
||||
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
|
||||
if (!isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString")
|
||||
stoppedForUnlinkedFolder = true
|
||||
break
|
||||
}
|
||||
val currentDocId = dirQueue.removeFirst()
|
||||
dirsScanned++
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
|
||||
|
||||
try {
|
||||
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
|
||||
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 && !stoppedForUnlinkedFolder) {
|
||||
val docId = cursor.getString(idCol)
|
||||
val name = cursor.getString(nameCol) ?: ""
|
||||
val mimeType = cursor.getString(mimeCol)
|
||||
filesSeen++
|
||||
|
||||
if (filesSeen % 100 == 0 && !isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync folder abort: folder unlinked after entries=$filesSeen folder=$folderUriString")
|
||||
stoppedForUnlinkedFolder = true
|
||||
break
|
||||
}
|
||||
|
||||
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
if (!name.startsWith(".") && name != LOCAL_FOLDER_SYNC_DATA_DIR) {
|
||||
dirQueue.add(docId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
val type = getFileType(name, mimeType)
|
||||
if (
|
||||
type == null ||
|
||||
type !in allowedFileTypes ||
|
||||
!isLocalFolderSyncEligibleFile(name, mimeType) ||
|
||||
name.endsWith(".json") ||
|
||||
name.startsWith(".")
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
|
||||
val relativePath = buildRelativePath(rootDocId, docId, name)
|
||||
scannedFiles += SharedFolderScannedFile(
|
||||
name = name,
|
||||
path = docUri.toString(),
|
||||
sourceFolder = folderUriString,
|
||||
relativePath = relativePath,
|
||||
type = type,
|
||||
size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L,
|
||||
lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
|
||||
}
|
||||
|
||||
if (stoppedForUnlinkedFolder) break
|
||||
}
|
||||
|
||||
return AndroidFolderScanResult(
|
||||
files = scannedFiles,
|
||||
dirsScanned = dirsScanned,
|
||||
filesSeen = filesSeen,
|
||||
stoppedForUnlinkedFolder = stoppedForUnlinkedFolder
|
||||
)
|
||||
}
|
||||
|
||||
private fun RecentFileItem.toFolderSyncSharedBookItem(): SharedBookItem {
|
||||
return SharedBookItem(
|
||||
id = bookId,
|
||||
path = uriString,
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = lastModifiedTimestamp,
|
||||
coverImagePath = coverImagePath,
|
||||
title = title,
|
||||
author = author,
|
||||
description = description,
|
||||
originalTitle = originalTitle,
|
||||
originalAuthor = originalAuthor,
|
||||
originalSeriesName = originalSeriesName,
|
||||
originalSeriesIndex = originalSeriesIndex,
|
||||
originalDescription = originalDescription,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent,
|
||||
fileSize = fileSize,
|
||||
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||
sourceFolder = sourceFolderUri,
|
||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex,
|
||||
lastPageIndex = lastPage,
|
||||
readerPosition = readerPositionOrNull(),
|
||||
readerBookmarks = parseReaderBookmarks(),
|
||||
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedBookItem.toFolderSyncRecentFileItem(
|
||||
existing: RecentFileItem?,
|
||||
appliedMetadata: FolderBookMetadata?,
|
||||
scannedFile: SharedFolderScannedFile?,
|
||||
nowMillis: Long
|
||||
): RecentFileItem {
|
||||
val contentChanged = existing != null && folderFileContentChanged(existing, this)
|
||||
val localModifiedTimestamp = when {
|
||||
appliedMetadata != null -> appliedMetadata.lastModifiedTimestamp
|
||||
contentChanged && fileContentModifiedTimestamp > 0L -> fileContentModifiedTimestamp
|
||||
timestamp > 0L -> timestamp
|
||||
else -> nowMillis
|
||||
}
|
||||
val legacyPosition = readerPosition
|
||||
val mappedBookmarksJson = readerBookmarks.toAndroidBookmarksJson(id)
|
||||
val mappedHighlightsJson = readerHighlights
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(EpubAnnotationSerializer::highlightsToJson)
|
||||
val bookmarksJson = if (appliedMetadata != null || existing == null) {
|
||||
mappedBookmarksJson ?: appliedMetadata?.bookmarksJson ?: existing?.bookmarksJson
|
||||
} else {
|
||||
existing.bookmarksJson
|
||||
}
|
||||
val highlightsJson = if (appliedMetadata != null || existing == null) {
|
||||
mappedHighlightsJson ?: appliedMetadata?.highlightsJson ?: existing?.highlightsJson
|
||||
} else {
|
||||
existing.highlightsJson
|
||||
}
|
||||
|
||||
return RecentFileItem(
|
||||
bookId = id,
|
||||
uriString = path,
|
||||
type = type,
|
||||
displayName = scannedFile?.name ?: existing?.displayName ?: displayName,
|
||||
timestamp = when {
|
||||
existing == null -> timestamp.takeIf { it > 0L } ?: localModifiedTimestamp
|
||||
appliedMetadata?.isRecent == true -> appliedMetadata.lastModifiedTimestamp
|
||||
else -> existing.timestamp
|
||||
},
|
||||
coverImagePath = coverImagePath,
|
||||
title = title,
|
||||
author = author,
|
||||
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
|
||||
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
|
||||
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
|
||||
locatorBlockIndex = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
|
||||
locatorCharOffset = appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent,
|
||||
isAvailable = true,
|
||||
lastModifiedTimestamp = localModifiedTimestamp,
|
||||
isDeleted = false,
|
||||
bookmarksJson = bookmarksJson,
|
||||
sourceFolderUri = sourceFolder,
|
||||
isReflowPreferred = existing?.isReflowPreferred ?: false,
|
||||
customName = appliedMetadata?.customName ?: existing?.customName,
|
||||
highlightsJson = highlightsJson,
|
||||
fileSize = fileSize,
|
||||
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex,
|
||||
description = description,
|
||||
originalTitle = originalTitle,
|
||||
originalAuthor = originalAuthor,
|
||||
originalSeriesName = originalSeriesName,
|
||||
originalSeriesIndex = originalSeriesIndex,
|
||||
originalDescription = originalDescription,
|
||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
|
||||
tags = existing?.tags.orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private fun appliedMetadataFor(
|
||||
book: SharedBookItem,
|
||||
existing: RecentFileItem?,
|
||||
metadata: FolderBookMetadata?
|
||||
): FolderBookMetadata? {
|
||||
if (metadata == null) return null
|
||||
val existingModified = existing?.lastModifiedTimestamp ?: Long.MIN_VALUE
|
||||
return metadata.takeIf { existing == null || it.lastModifiedTimestamp > existingModified }
|
||||
}
|
||||
|
||||
private fun RecentFileItem.readerPositionOrNull(): ReaderLocator? {
|
||||
if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null
|
||||
return ReaderLocator.fromLegacy(
|
||||
chapterIndex = lastChapterIndex,
|
||||
cfi = lastPositionCfi,
|
||||
pageIndex = lastPage
|
||||
)
|
||||
}
|
||||
|
||||
private fun RecentFileItem.parseReaderBookmarks(): List<ReaderBookmark> {
|
||||
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
|
||||
.mapIndexed { index, bookmark ->
|
||||
val locator = bookmark.locator.withFallbacks(
|
||||
chapterIndex = bookmark.chapterIndex,
|
||||
cfi = bookmark.cfi,
|
||||
pageIndex = bookmark.pageInChapter?.minus(1),
|
||||
textQuote = bookmark.snippet
|
||||
)
|
||||
val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0
|
||||
ReaderBookmark(
|
||||
id = "bookmark_${bookId}_$index",
|
||||
pageIndex = pageIndex.coerceAtLeast(0),
|
||||
chapterTitle = bookmark.chapterTitle,
|
||||
preview = bookmark.snippet,
|
||||
locator = locator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ReaderBookmark>.toAndroidBookmarksJson(bookId: String): String? {
|
||||
val bookmarks = mapIndexed { index, bookmark ->
|
||||
val locator = bookmark.locator
|
||||
val chapterIndex = locator.chapterIndex ?: 0
|
||||
val cfi = locator.cfi ?: "android:$bookId:$index:${bookmark.pageIndex}"
|
||||
EpubBookmark(
|
||||
cfi = cfi,
|
||||
chapterTitle = bookmark.chapterTitle,
|
||||
label = null,
|
||||
snippet = bookmark.preview,
|
||||
pageInChapter = bookmark.pageIndex + 1,
|
||||
totalPagesInChapter = null,
|
||||
chapterIndex = chapterIndex,
|
||||
locator = locator.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = bookmark.pageIndex,
|
||||
textQuote = bookmark.preview
|
||||
)
|
||||
)
|
||||
}
|
||||
return bookmarks.takeIf { it.isNotEmpty() }?.let(EpubAnnotationSerializer::bookmarksToJson)
|
||||
}
|
||||
|
||||
private fun folderFileContentChanged(previous: RecentFileItem, next: RecentFileItem): Boolean {
|
||||
val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
|
||||
val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
|
||||
previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
|
||||
return sizeChanged || modifiedChanged
|
||||
}
|
||||
|
||||
private fun folderFileContentChanged(previous: RecentFileItem, next: SharedBookItem): Boolean {
|
||||
val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
|
||||
val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
|
||||
previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
|
||||
return sizeChanged || modifiedChanged
|
||||
}
|
||||
|
||||
private fun isFolderStillLinked(folderUriString: String): Boolean {
|
||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||
val jsonString = prefs.getString("synced_folders_list_json", null)
|
||||
|
|
@ -621,11 +738,6 @@ class FolderSyncWorker(
|
|||
return resolveFileTypeFromMetadata(name, mimeType)
|
||||
}
|
||||
|
||||
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
|
||||
val relativePath = buildRelativePath(rootDocId, docId, name)
|
||||
return com.aryan.reader.shared.LocalFolderSyncEngine.buildStableBookId(name, relativePath)
|
||||
}
|
||||
|
||||
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
|
||||
val rootPath = rootDocId.substringAfter(':', "")
|
||||
val docPath = docId.substringAfter(':', "")
|
||||
|
|
@ -638,16 +750,6 @@ class FolderSyncWorker(
|
|||
return relative.ifBlank { fallbackName }
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.ui.SharedAppFontSelector
|
||||
import com.aryan.reader.shared.ui.SharedFontSettingsSection
|
||||
import com.aryan.reader.shared.ui.SharedFontSettingsTabs
|
||||
import com.aryan.reader.data.CustomFontEntity
|
||||
import java.io.File
|
||||
|
||||
|
|
@ -80,6 +84,7 @@ fun FontsScreen(
|
|||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
|
||||
var showGoogleFontsSheet by remember { mutableStateOf(false) }
|
||||
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
|
||||
|
||||
val pickFontLauncher = rememberFilePickerLauncher { uris ->
|
||||
uris.firstOrNull()?.let { viewModel.importFont(it) }
|
||||
|
|
@ -105,7 +110,7 @@ fun FontsScreen(
|
|||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (fonts.isNotEmpty()) {
|
||||
if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
|
|
@ -130,31 +135,61 @@ fun FontsScreen(
|
|||
}
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
if (fonts.isEmpty()) {
|
||||
val secondaryText = if (showGoogleFontsOption) stringResource(R.string.action_browse_google_fonts) else null
|
||||
val secondaryClick: (() -> Unit)? = if (showGoogleFontsOption) { { showGoogleFontsSheet = true } } else null
|
||||
|
||||
EmptyState(
|
||||
title = stringResource(R.string.no_custom_fonts),
|
||||
message = stringResource(R.string.import_fonts_desc),
|
||||
onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
secondaryButtonText = secondaryText,
|
||||
onSecondaryClick = secondaryClick
|
||||
val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
SharedFontSettingsTabs(
|
||||
selectedSection = selectedSection,
|
||||
onSectionChange = { selectedSection = it },
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(fonts, key = { it.id }) { font ->
|
||||
FontListItem(
|
||||
font = font,
|
||||
onDelete = {
|
||||
fontToDelete = font
|
||||
showDeleteDialog = true
|
||||
|
||||
when (selectedSection) {
|
||||
SharedFontSettingsSection.READER_FONTS -> {
|
||||
if (fonts.isEmpty()) {
|
||||
val secondaryText = if (showGoogleFontsOption) stringResource(R.string.action_browse_google_fonts) else null
|
||||
val secondaryClick: (() -> Unit)? = if (showGoogleFontsOption) { { showGoogleFontsSheet = true } } else null
|
||||
|
||||
EmptyState(
|
||||
title = stringResource(R.string.no_custom_fonts),
|
||||
message = stringResource(R.string.import_fonts_desc),
|
||||
onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) },
|
||||
modifier = Modifier.weight(1f),
|
||||
secondaryButtonText = secondaryText,
|
||||
onSecondaryClick = secondaryClick
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(fonts, key = { it.id }) { font ->
|
||||
FontListItem(
|
||||
font = font,
|
||||
onDelete = {
|
||||
fontToDelete = font
|
||||
showDeleteDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SharedFontSettingsSection.APP_TEXT -> {
|
||||
SharedAppFontSelector(
|
||||
preference = uiState.appFontPreference,
|
||||
customFonts = sharedFonts,
|
||||
onPreferenceChange = viewModel::setAppFontPreference,
|
||||
fontFamilyForPreview = { font ->
|
||||
val file = File(font.path)
|
||||
if (file.isFile) {
|
||||
runCatching { FontFamily(Font(file)) }.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -424,6 +459,22 @@ fun FontListItem(
|
|||
}
|
||||
}
|
||||
|
||||
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
|
||||
return filterNot { it.isDeleted }
|
||||
.sortedBy { it.displayName.lowercase() }
|
||||
.map { font ->
|
||||
CustomFontItem(
|
||||
id = font.id,
|
||||
displayName = font.displayName,
|
||||
fileName = font.fileName,
|
||||
fileExtension = font.fileExtension,
|
||||
path = font.path,
|
||||
timestamp = font.timestamp,
|
||||
isDeleted = font.isDeleted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteFontConfirmationDialog(
|
||||
fontName: String,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import androidx.compose.foundation.clickable
|
|||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -351,6 +352,9 @@ fun HomeScreen(
|
|||
showStrictFilterDialog = true
|
||||
}
|
||||
},
|
||||
onUsePdfFileNameAsDisplayNameToggle = {
|
||||
viewModel.setUsePdfFileNameAsDisplayName(!uiState.usePdfFileNameAsDisplayName)
|
||||
},
|
||||
onAppThemeClick = { showAppThemePanel = true },
|
||||
onSettingsClick = {
|
||||
navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE)
|
||||
|
|
@ -432,7 +436,8 @@ fun HomeScreen(
|
|||
onRefresh = { viewModel.refreshLibrary() },
|
||||
isRefreshing = uiState.isRefreshing,
|
||||
isSyncEnabled = uiState.isSyncEnabled,
|
||||
hasSyncedFolder = uiState.syncedFolders.isNotEmpty()
|
||||
hasSyncedFolder = uiState.syncedFolders.isNotEmpty(),
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -500,6 +505,7 @@ fun HomeScreen(
|
|||
if (showInfoDialog) {
|
||||
FileInfoDialog(
|
||||
item = item,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = {
|
||||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
|
|
@ -629,7 +635,8 @@ private fun RecentFilesContent(
|
|||
onRefresh: () -> Unit,
|
||||
isRefreshing: Boolean,
|
||||
isSyncEnabled: Boolean,
|
||||
hasSyncedFolder: Boolean
|
||||
hasSyncedFolder: Boolean,
|
||||
usePdfFileNameAsDisplayName: Boolean
|
||||
) {
|
||||
val canRefresh = isSyncEnabled || hasSyncedFolder
|
||||
val selectedItemUris = remember(selectedContextItems) {
|
||||
|
|
@ -653,7 +660,8 @@ private fun RecentFilesContent(
|
|||
onItemLongClick = onItemLongClick,
|
||||
windowSizeClass = windowSizeClass,
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp),
|
||||
downloadingBookIds = downloadingBookIds
|
||||
downloadingBookIds = downloadingBookIds,
|
||||
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
|
||||
)
|
||||
|
||||
Row(
|
||||
|
|
@ -702,6 +710,7 @@ private fun RecentFilesGrid(
|
|||
windowSizeClass: WindowSizeClass,
|
||||
contentPadding: PaddingValues = PaddingValues(vertical = 8.dp),
|
||||
downloadingBookIds: Set<String>,
|
||||
usePdfFileNameAsDisplayName: Boolean,
|
||||
) {
|
||||
val gridCells = when (windowSizeClass.widthSizeClass) {
|
||||
WindowWidthSizeClass.Compact -> GridCells.Fixed(3)
|
||||
|
|
@ -741,7 +750,7 @@ private fun RecentFilesGrid(
|
|||
onClick = { onItemClick(tab) },
|
||||
label = {
|
||||
Text(
|
||||
text = tab.customName ?: tab.title ?: tab.displayName,
|
||||
text = tab.cardTitle(usePdfFileNameAsDisplayName),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 150.dp)
|
||||
|
|
@ -783,7 +792,8 @@ private fun RecentFilesGrid(
|
|||
isPinned = item.bookId in pinnedHomeBookIds,
|
||||
onClick = { onItemClick(item) },
|
||||
onLongClick = { onItemLongClick(item) },
|
||||
isDownloading = item.bookId in downloadingBookIds
|
||||
isDownloading = item.bookId in downloadingBookIds,
|
||||
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -800,6 +810,7 @@ fun RecentFileCard(
|
|||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
isDownloading: Boolean,
|
||||
usePdfFileNameAsDisplayName: Boolean = false,
|
||||
) {
|
||||
val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt()
|
||||
val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " "
|
||||
|
|
@ -822,11 +833,14 @@ fun RecentFileCard(
|
|||
)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(0.74f)
|
||||
) {
|
||||
val useCompactCoverBadges = maxWidth < 128.dp
|
||||
val coverBadgePadding = if (useCompactCoverBadges) 5.dp else 8.dp
|
||||
|
||||
ThemedBookCover(
|
||||
item = item,
|
||||
contentDescription = item.displayName,
|
||||
|
|
@ -894,30 +908,25 @@ fun RecentFileCard(
|
|||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)) {
|
||||
FileTypeBadge(type = item.type, overlay = true)
|
||||
}
|
||||
|
||||
progressPercent?.let { percent ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(8.dp),
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f),
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.14f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "$percent%",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(coverBadgePadding),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
progressPercent?.let { percent ->
|
||||
CoverProgressBadge(
|
||||
percent = percent,
|
||||
compact = useCompactCoverBadges
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
FileTypeBadge(
|
||||
type = item.type,
|
||||
overlay = true,
|
||||
compact = useCompactCoverBadges
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -929,7 +938,7 @@ fun RecentFileCard(
|
|||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = item.cardTitle(),
|
||||
text = item.cardTitle(usePdfFileNameAsDisplayName),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
|
|
@ -1008,6 +1017,39 @@ fun RecentFileCard(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CoverProgressBadge(
|
||||
percent: Int,
|
||||
compact: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f),
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.14f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "$percent%",
|
||||
style = if (compact) {
|
||||
MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp)
|
||||
} else {
|
||||
MaterialTheme.typography.labelSmall
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = if (compact) 6.dp else 10.dp,
|
||||
vertical = if (compact) 3.dp else 4.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused", "KotlinConstantConditions")
|
||||
@Composable
|
||||
fun DefaultTopAppBar(
|
||||
|
|
@ -1024,6 +1066,7 @@ fun DefaultTopAppBar(
|
|||
onTabsToggle: (Boolean) -> Unit,
|
||||
onExternalFileBehaviorClick: () -> Unit,
|
||||
onStrictFilterToggleClick: () -> Unit,
|
||||
onUsePdfFileNameAsDisplayNameToggle: () -> Unit,
|
||||
onAppThemeClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onTestPanelDetectionClick: () -> Unit,
|
||||
|
|
@ -1132,6 +1175,15 @@ fun DefaultTopAppBar(
|
|||
}
|
||||
})
|
||||
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.options_use_pdf_filename_display_name)) }, onClick = {
|
||||
onUsePdfFileNameAsDisplayNameToggle()
|
||||
showOptionsMenu = false
|
||||
}, trailingIcon = {
|
||||
if (uiState.usePdfFileNameAsDisplayName) {
|
||||
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
|
||||
}
|
||||
})
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.options_language)) }, onClick = {
|
||||
|
|
@ -1262,7 +1314,7 @@ private fun AppDrawerContent(
|
|||
Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.FormatListNumbered, contentDescription = stringResource(R.string.credits_tab), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.credits_count, uiState.credits), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer)
|
||||
Text(safeStringResource(R.string.credits_count, uiState.credits), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
|
||||
|
|
@ -11,13 +12,16 @@ typealias SortOrder = com.aryan.reader.shared.SortOrder
|
|||
typealias ReadStatusFilter = com.aryan.reader.shared.ReadStatusFilter
|
||||
typealias LibraryFilters = com.aryan.reader.shared.LibraryFilters
|
||||
typealias SyncedFolder = com.aryan.reader.shared.SyncedFolder
|
||||
typealias ShelfType = com.aryan.reader.shared.ShelfType
|
||||
|
||||
internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
|
||||
internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
|
||||
internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES
|
||||
internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES
|
||||
|
||||
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
|
||||
internal fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
|
||||
return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
|
||||
}
|
||||
|
||||
data class Shelf(
|
||||
val id: String,
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ 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.OpdsDownloadState
|
||||
import com.aryan.reader.opds.OpdsEntry
|
||||
import com.aryan.reader.opds.OpdsViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -343,7 +344,8 @@ fun LibraryScreen(
|
|||
)
|
||||
},
|
||||
onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog,
|
||||
onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) }
|
||||
onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) },
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -392,6 +394,7 @@ fun LibraryScreen(
|
|||
if (showInfoDialog) {
|
||||
FileInfoDialog(
|
||||
item = item,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = {
|
||||
showInfoDialog = false
|
||||
itemForInfoDialog = null
|
||||
|
|
@ -458,7 +461,8 @@ fun ShelfScreen(
|
|||
onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) },
|
||||
onBack = viewModel::dismissAddBooksToShelf,
|
||||
onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfId) },
|
||||
downloadingBookIds = uiState.downloadingBookIds
|
||||
downloadingBookIds = uiState.downloadingBookIds,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
|
||||
)
|
||||
} else {
|
||||
ShelfDetailScreen(
|
||||
|
|
@ -483,7 +487,8 @@ fun ShelfScreen(
|
|||
onDeleteClick = { showRemoveFromShelfDialog = true },
|
||||
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
|
||||
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
|
||||
downloadingBookIds = uiState.downloadingBookIds
|
||||
downloadingBookIds = uiState.downloadingBookIds,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -523,6 +528,7 @@ fun ShelfScreen(
|
|||
if (showInfoDialog) {
|
||||
FileInfoDialog(
|
||||
item = item,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
|
||||
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
|
||||
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
|
||||
|
|
@ -588,6 +594,7 @@ fun LibraryScreenContent(
|
|||
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
||||
onDeleteCatalogStreams: (String) -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
usePdfFileNameAsDisplayName: Boolean,
|
||||
) {
|
||||
val isBookContextualModeActive = selectedItems.isNotEmpty()
|
||||
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
||||
|
|
@ -860,7 +867,8 @@ fun LibraryScreenContent(
|
|||
isPinned = item.bookId in pinnedLibraryBookIds,
|
||||
onItemClick = { onItemClick(item) },
|
||||
onItemLongClick = { onItemLongClick(item) },
|
||||
isDownloading = item.bookId in downloadingBookIds
|
||||
isDownloading = item.bookId in downloadingBookIds,
|
||||
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1027,6 +1035,7 @@ private fun ShelfDetailScreen(
|
|||
onRenameShelf: () -> Unit,
|
||||
onDeleteShelf: () -> Unit,
|
||||
downloadingBookIds: Set<String>,
|
||||
usePdfFileNameAsDisplayName: Boolean,
|
||||
) {
|
||||
val isContextualModeActive = selectedItems.isNotEmpty()
|
||||
val isFolderShelf = shelf.type == ShelfType.FOLDER
|
||||
|
|
@ -1162,7 +1171,11 @@ private fun ShelfDetailScreen(
|
|||
Text(
|
||||
text = when {
|
||||
isFolderShelf && shelf.childShelfCount > 0 && shelf.directBookCount > 0 ->
|
||||
"${pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount)} • ${getBookCountString(shelf.directBookCount)}"
|
||||
stringResource(
|
||||
R.string.folder_subtitle_folder_book_counts,
|
||||
pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount),
|
||||
getBookCountString(shelf.directBookCount)
|
||||
)
|
||||
isFolderShelf && shelf.childShelfCount > 0 ->
|
||||
pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount)
|
||||
isFolderShelf -> getBookCountString(shelf.directBookCount)
|
||||
|
|
@ -1322,7 +1335,8 @@ private fun ShelfDetailScreen(
|
|||
isSelected = selectedItems.any { it.bookId == item.bookId },
|
||||
onItemClick = { onBookClick(item) },
|
||||
onItemLongClick = { onBookLongClick(item) },
|
||||
isDownloading = item.bookId in downloadingBookIds
|
||||
isDownloading = item.bookId in downloadingBookIds,
|
||||
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1344,6 +1358,7 @@ private fun AddBooksModeScreen(
|
|||
onBack: () -> Unit,
|
||||
onAddSelectedBooks: () -> Unit,
|
||||
downloadingBookIds: Set<String>,
|
||||
usePdfFileNameAsDisplayName: Boolean,
|
||||
) {
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
|
||||
|
|
@ -1445,7 +1460,8 @@ private fun AddBooksModeScreen(
|
|||
isSelected = isSelected,
|
||||
onItemClick = { onBookClick(item) },
|
||||
onItemLongClick = { onBookClick(item) },
|
||||
isDownloading = item.bookId in downloadingBookIds
|
||||
isDownloading = item.bookId in downloadingBookIds,
|
||||
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1631,6 +1647,7 @@ private fun LibraryListItem(
|
|||
onItemClick: () -> Unit,
|
||||
onItemLongClick: () -> Unit,
|
||||
isDownloading: Boolean,
|
||||
usePdfFileNameAsDisplayName: Boolean = false,
|
||||
) {
|
||||
androidx.compose.material3.ElevatedCard(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
|
|
@ -1695,7 +1712,7 @@ private fun LibraryListItem(
|
|||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.cardTitle(),
|
||||
text = item.cardTitle(usePdfFileNameAsDisplayName),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
|
|
@ -2358,8 +2375,7 @@ fun OpdsTab(
|
|||
opdsViewModel: OpdsViewModel = viewModel()
|
||||
) {
|
||||
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val downloadingState by opdsViewModel.downloadingState.collectAsStateWithLifecycle()
|
||||
val downloadingEntries by opdsViewModel.downloadingEntries.collectAsStateWithLifecycle()
|
||||
val downloadingState = uiState.downloadingState
|
||||
val context = LocalContext.current
|
||||
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
|
||||
var showCatalogDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -2836,7 +2852,7 @@ fun OpdsNavigationCard(entry: OpdsEntry, onClick: (String) -> Unit) {
|
|||
fun OpdsBookCard(
|
||||
entry: OpdsEntry,
|
||||
localLibraryFiles: List<RecentFileItem>,
|
||||
downloadState: OpdsViewModel.DownloadState?,
|
||||
downloadState: OpdsDownloadState?,
|
||||
onDownloadClick: (OpdsAcquisition) -> Unit,
|
||||
onReadClick: (RecentFileItem) -> Unit,
|
||||
onStreamClick: () -> Unit,
|
||||
|
|
@ -2967,7 +2983,7 @@ fun OpdsBookCard(
|
|||
fun OpdsBookDetailsSheet(
|
||||
entry: OpdsEntry,
|
||||
localLibraryFiles: List<RecentFileItem>,
|
||||
downloadState: OpdsViewModel.DownloadState?,
|
||||
downloadState: OpdsDownloadState?,
|
||||
onDownloadFormat: (OpdsAcquisition) -> Unit,
|
||||
onReadClick: (RecentFileItem) -> Unit,
|
||||
onStreamClick: () -> Unit,
|
||||
|
|
|
|||
|
|
@ -142,13 +142,13 @@ fun applyLibraryFilters(files: List<RecentFileItem>, filters: LibraryFilters): L
|
|||
return files.mapSharedResults(
|
||||
sharedApplyLibraryFilters(
|
||||
books = files.map { it.toSharedProjectionBookItem() },
|
||||
filters = filters.toSharedLibraryFilters()
|
||||
filters = filters
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFileItem> {
|
||||
return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder.toSharedSortOrder()))
|
||||
return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder))
|
||||
}
|
||||
|
||||
private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<com.aryan.reader.shared.BookItem>): List<RecentFileItem> {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
|
|||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
|
|
@ -49,6 +50,12 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.aryan.reader.tts.ACTION_OPEN_TTS_SESSION
|
||||
import com.aryan.reader.tts.EXTRA_TTS_BOOK_ID
|
||||
import com.aryan.reader.tts.EXTRA_TTS_CHAPTER_INDEX
|
||||
import com.aryan.reader.tts.EXTRA_TTS_PAGE_INDEX
|
||||
import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
|
||||
import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
|
||||
|
||||
@UnstableApi
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
|
@ -89,6 +96,7 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
setContent {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
|
||||
|
||||
ScreenCaptureProtectionEffect(enabled = uiState.isScreenCaptureProtectionEnabled)
|
||||
|
||||
|
|
@ -99,13 +107,17 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
val textDimFactor = if (darkTheme) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight
|
||||
val appFontFamily = remember(uiState.appFontPreference, customFonts) {
|
||||
uiState.appFontPreference.toAndroidAppFontFamily(customFonts)
|
||||
}
|
||||
|
||||
AppTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = uiState.appSeedColor == null,
|
||||
seedColor = uiState.appSeedColor,
|
||||
contrastLevel = uiState.appContrastOption.value,
|
||||
textDimFactor = textDimFactor
|
||||
textDimFactor = textDimFactor,
|
||||
appFontFamily = appFontFamily
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -125,10 +137,26 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
if (intent?.action == ACTION_OPEN_TTS_SESSION) {
|
||||
val bookId = intent.getStringExtra(EXTRA_TTS_BOOK_ID)
|
||||
if (!bookId.isNullOrBlank()) {
|
||||
Timber.d("Received TTS notification intent for bookId=$bookId")
|
||||
viewModel.openTtsNotificationTarget(
|
||||
bookId = bookId,
|
||||
sourceCfi = intent.getStringExtra(EXTRA_TTS_SOURCE_CFI),
|
||||
startOffset = intent.getIntExtra(EXTRA_TTS_START_OFFSET, -1).takeIf { it >= 0 },
|
||||
chapterIndex = intent.getIntExtra(EXTRA_TTS_CHAPTER_INDEX, -1).takeIf { it >= 0 },
|
||||
pageIndex = intent.getIntExtra(EXTRA_TTS_PAGE_INDEX, -1).takeIf { it >= 0 }
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
|
||||
Timber.d("Received VIEW intent with URI: ${intent.data}")
|
||||
val uri = intent.data!!
|
||||
|
|
|
|||
|
|
@ -86,9 +86,15 @@ import com.aryan.reader.paginatedreader.Locator
|
|||
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
||||
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
||||
import com.aryan.reader.pdf.PdfCoverGenerator
|
||||
import com.aryan.reader.pdf.PDF_BLANK_PAGE_PERSISTENCE_TAG
|
||||
import com.aryan.reader.pdf.PdfUserHighlight
|
||||
import com.aryan.reader.pdf.PdfiumCoreProvider
|
||||
import com.aryan.reader.pdf.PdfiumEngineProvider
|
||||
import com.aryan.reader.pdf.PdfiumAnnotationExporter
|
||||
import com.aryan.reader.pdf.ReflowWorker
|
||||
import com.aryan.reader.pdf.pdfLayoutDebugSummary
|
||||
import com.aryan.reader.pdf.remapPdfAnnotationsForLayoutChange
|
||||
import com.aryan.reader.pdf.remapPdfBookmarksJsonForLayoutChange
|
||||
import com.aryan.reader.pdf.data.PageLayoutRepository
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.pdf.data.PdfAnnotationRepository
|
||||
|
|
@ -98,17 +104,18 @@ import com.aryan.reader.pdf.data.PdfTextBoxRepository
|
|||
import com.aryan.reader.pdf.data.PdfTextRepository
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
import com.aryan.reader.pptx.PptxCoverGenerator
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.SharedLibraryEditor
|
||||
import com.aryan.reader.shared.SharedImportOutcomeCounts
|
||||
import com.aryan.reader.shared.SharedImportPlanner
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
||||
import com.aryan.reader.shared.AppAction as SharedAppAction
|
||||
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
|
||||
import io.legere.pdfiumandroid.PdfiumCore
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
|
@ -558,6 +565,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null),
|
||||
externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK",
|
||||
useStrictFileFilter = prefs.getBoolean(KEY_USE_STRICT_FILE_FILTER, false),
|
||||
usePdfFileNameAsDisplayName = prefs.getBoolean(KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME, false),
|
||||
isScreenCaptureProtectionEnabled = prefs.getBoolean(KEY_SCREEN_CAPTURE_PROTECTION, false),
|
||||
appThemeMode = try {
|
||||
AppThemeMode.valueOf(prefs.getString(KEY_APP_THEME_MODE, AppThemeMode.SYSTEM.name) ?: AppThemeMode.SYSTEM.name)
|
||||
|
|
@ -568,6 +576,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
appTextDimFactorLight = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f)),
|
||||
appTextDimFactorDark = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f)),
|
||||
appSeedColor = if (prefs.contains(KEY_APP_SEED_COLOR)) androidx.compose.ui.graphics.Color(prefs.getInt(KEY_APP_SEED_COLOR, 0)) else null,
|
||||
appFontPreference = loadAppFontPreference(prefs),
|
||||
customAppThemes = loadCustomAppThemes(prefs)
|
||||
)
|
||||
)
|
||||
|
|
@ -718,6 +727,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
selectedBookId = bookId,
|
||||
selectedFileType = item.type,
|
||||
initialPageInBook = item.lastPage,
|
||||
initialPageInBookIsExplicit = false,
|
||||
isOpeningFromTtsNotification = false,
|
||||
initialBookmarksJson = item.bookmarksJson,
|
||||
isLoading = false,
|
||||
errorMessage = null
|
||||
|
|
@ -1015,57 +1026,50 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
currentBookmarksJson: String,
|
||||
referenceWidth: Int,
|
||||
referenceHeight: Int,
|
||||
blankPageId: String? = null,
|
||||
wasManuallyAdded: Boolean = false
|
||||
): PageModificationResult = withContext(Dispatchers.Default) {
|
||||
): PageModificationResult = withContext(Dispatchers.Default + NonCancellable) {
|
||||
Timber.d("Adding page at index $insertIndex for book $bookId (manual=$wasManuallyAdded)")
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"vm.addPage.start bookId=$bookId insertIndex=$insertIndex blankPageId=$blankPageId " +
|
||||
"manual=$wasManuallyAdded ref=${referenceWidth}x$referenceHeight " +
|
||||
"current=${currentLayout.pdfLayoutDebugSummary()} annotationPages=${currentAnnotations.keys.sorted()} " +
|
||||
"bookmarksBytes=${currentBookmarksJson.length}"
|
||||
)
|
||||
|
||||
val newLayout = currentLayout.toMutableList()
|
||||
val safeIndex = insertIndex.coerceIn(0, newLayout.size)
|
||||
|
||||
val newPage = VirtualPage.BlankPage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
id = blankPageId ?: UUID.randomUUID().toString(),
|
||||
width = referenceWidth,
|
||||
height = referenceHeight,
|
||||
wasManuallyAdded = wasManuallyAdded
|
||||
)
|
||||
newLayout.add(safeIndex, newPage)
|
||||
|
||||
val newAnnotations = mutableMapOf<Int, List<PdfAnnotation>>()
|
||||
currentAnnotations.forEach { (pageIdx, annots) ->
|
||||
val newIdx = if (pageIdx >= safeIndex) pageIdx + 1 else pageIdx
|
||||
val shiftedAnnots = annots.map { it.copy(pageIndex = newIdx) }
|
||||
newAnnotations[newIdx] = shiftedAnnots
|
||||
}
|
||||
val newAnnotations = remapPdfAnnotationsForLayoutChange(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = newLayout,
|
||||
annotations = currentAnnotations
|
||||
)
|
||||
|
||||
val newTotalPages = newLayout.size
|
||||
val newBookmarksJson = try {
|
||||
if (currentBookmarksJson.isNotBlank()) {
|
||||
val jsonArray = JSONArray(currentBookmarksJson)
|
||||
val newArray = JSONArray()
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val obj = jsonArray.getJSONObject(i)
|
||||
val bmPageIndex = obj.getInt("pageIndex")
|
||||
val title = obj.getString("title")
|
||||
|
||||
val newBmPageIndex = if (bmPageIndex >= safeIndex) bmPageIndex + 1
|
||||
else bmPageIndex
|
||||
|
||||
val newObj = JSONObject()
|
||||
newObj.put("pageIndex", newBmPageIndex)
|
||||
newObj.put("title", title)
|
||||
newObj.put("totalPages", newTotalPages)
|
||||
newArray.put(newObj)
|
||||
}
|
||||
newArray.toString()
|
||||
} else {
|
||||
"[]"
|
||||
}
|
||||
remapPdfBookmarksJsonForLayoutChange(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = newLayout,
|
||||
currentBookmarksJson = currentBookmarksJson
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error shifting bookmarks")
|
||||
currentBookmarksJson
|
||||
}
|
||||
|
||||
pageLayoutRepository.saveLayout(bookId, newLayout)
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"vm.addPage.done bookId=$bookId safeIndex=$safeIndex new=${newLayout.pdfLayoutDebugSummary()} " +
|
||||
"newAnnotationPages=${newAnnotations.keys.sorted()} bookmarksBytes=${newBookmarksJson.length}"
|
||||
)
|
||||
|
||||
PageModificationResult(newLayout, newAnnotations, newBookmarksJson)
|
||||
}
|
||||
|
|
@ -1076,58 +1080,48 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
removeIndex: Int,
|
||||
currentAnnotations: Map<Int, List<PdfAnnotation>>,
|
||||
currentBookmarksJson: String
|
||||
): PageModificationResult = withContext(Dispatchers.Default) {
|
||||
): PageModificationResult = withContext(Dispatchers.Default + NonCancellable) {
|
||||
Timber.d("Removing page at index $removeIndex for book $bookId")
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"vm.removePage.start bookId=$bookId removeIndex=$removeIndex " +
|
||||
"current=${currentLayout.pdfLayoutDebugSummary()} annotationPages=${currentAnnotations.keys.sorted()} " +
|
||||
"bookmarksBytes=${currentBookmarksJson.length}"
|
||||
)
|
||||
|
||||
val newLayout = currentLayout.toMutableList()
|
||||
if (removeIndex in newLayout.indices) {
|
||||
newLayout.removeAt(removeIndex)
|
||||
} else {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
|
||||
"vm.removePage.ignored bookId=$bookId removeIndex=$removeIndex current=${currentLayout.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
return@withContext PageModificationResult(
|
||||
currentLayout, currentAnnotations, currentBookmarksJson
|
||||
)
|
||||
}
|
||||
|
||||
val newAnnotations = mutableMapOf<Int, List<PdfAnnotation>>()
|
||||
currentAnnotations.forEach { (pageIdx, annots) ->
|
||||
if (pageIdx != removeIndex) {
|
||||
val newIdx = if (pageIdx > removeIndex) pageIdx - 1 else pageIdx
|
||||
val shiftedAnnots = annots.map { it.copy(pageIndex = newIdx) }
|
||||
newAnnotations[newIdx] = shiftedAnnots
|
||||
}
|
||||
}
|
||||
val newAnnotations = remapPdfAnnotationsForLayoutChange(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = newLayout,
|
||||
annotations = currentAnnotations
|
||||
)
|
||||
|
||||
val newTotalPages = newLayout.size
|
||||
val newBookmarksJson = try {
|
||||
if (currentBookmarksJson.isNotBlank()) {
|
||||
val jsonArray = JSONArray(currentBookmarksJson)
|
||||
val newArray = JSONArray()
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val obj = jsonArray.getJSONObject(i)
|
||||
val bmPageIndex = obj.getInt("pageIndex")
|
||||
|
||||
if (bmPageIndex == removeIndex) continue
|
||||
|
||||
val title = obj.getString("title")
|
||||
val newBmPageIndex = if (bmPageIndex > removeIndex) bmPageIndex - 1
|
||||
else bmPageIndex
|
||||
|
||||
val newObj = JSONObject()
|
||||
newObj.put("pageIndex", newBmPageIndex)
|
||||
newObj.put("title", title)
|
||||
newObj.put("totalPages", newTotalPages)
|
||||
newArray.put(newObj)
|
||||
}
|
||||
newArray.toString()
|
||||
} else {
|
||||
"[]"
|
||||
}
|
||||
remapPdfBookmarksJsonForLayoutChange(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = newLayout,
|
||||
currentBookmarksJson = currentBookmarksJson
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error shifting bookmarks")
|
||||
currentBookmarksJson
|
||||
}
|
||||
|
||||
pageLayoutRepository.saveLayout(bookId, newLayout)
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"vm.removePage.done bookId=$bookId removeIndex=$removeIndex new=${newLayout.pdfLayoutDebugSummary()} " +
|
||||
"newAnnotationPages=${newAnnotations.keys.sorted()} bookmarksBytes=${newBookmarksJson.length}"
|
||||
)
|
||||
|
||||
PageModificationResult(newLayout, newAnnotations, newBookmarksJson)
|
||||
}
|
||||
|
|
@ -1325,7 +1319,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
initialCfi = null,
|
||||
initialBookmarksJson = item.bookmarksJson,
|
||||
initialHighlightsJson = null,
|
||||
initialPageInBook = item.lastPage
|
||||
initialPageInBook = item.lastPage,
|
||||
initialPageInBookIsExplicit = false,
|
||||
isOpeningFromTtsNotification = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1360,7 +1356,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
initialCfi = item.lastPositionCfi,
|
||||
initialBookmarksJson = item.bookmarksJson,
|
||||
initialHighlightsJson = item.highlightsJson,
|
||||
initialPageInBook = null
|
||||
initialPageInBook = null,
|
||||
initialPageInBookIsExplicit = false,
|
||||
isOpeningFromTtsNotification = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1652,7 +1650,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
|
||||
fun deleteFont(fontId: String) {
|
||||
viewModelScope.launch { fontsRepository.deleteFont(fontId) }
|
||||
viewModelScope.launch {
|
||||
fontsRepository.deleteFont(fontId)
|
||||
if (_internalState.value.appFontPreference.referencesCustomFont(fontId)) {
|
||||
setAppFontPreference(AppFontPreference.System)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) {
|
||||
|
|
@ -1943,7 +1946,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
val sanitizedFilters = filters.copy(
|
||||
fileTypes = filters.fileTypes.filterTo(mutableSetOf()) { it in ANDROID_READABLE_FILE_TYPES }
|
||||
)
|
||||
_internalState.update { it.withSharedLibraryAction(SharedLibraryAction.FiltersChanged(sanitizedFilters.toSharedLibraryFilters())) }
|
||||
_internalState.update { it.withSharedLibraryAction(SharedLibraryAction.FiltersChanged(sanitizedFilters)) }
|
||||
|
||||
prefs.edit {
|
||||
putStringSet(KEY_FILTER_FILE_TYPES, sanitizedFilters.fileTypes.map { it.name }.toSet())
|
||||
|
|
@ -2264,7 +2267,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
isLoading = false,
|
||||
errorMessage = null,
|
||||
initialLocator = null,
|
||||
initialPageInBook = null
|
||||
initialPageInBook = null,
|
||||
initialPageInBookIsExplicit = false,
|
||||
isOpeningFromTtsNotification = false
|
||||
)
|
||||
}
|
||||
clearPersistedReaderSession()
|
||||
|
|
@ -2351,7 +2356,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
bookTitle = book.title,
|
||||
chapterTitle = book.chapters.getOrNull(nextIdx)?.title,
|
||||
coverImageUri = backgroundTtsCoverPath?.let { Uri.fromFile(File(it)).toString() },
|
||||
bookId = bookId,
|
||||
chapterIndex = nextIdx,
|
||||
totalChapters = totalChapters,
|
||||
ttsMode = mode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -3476,7 +3483,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
var description: String? = bundleResult?.description
|
||||
var bookForMetadata = epubBook
|
||||
|
||||
if (bookForMetadata == null && bundleResult == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT)) {
|
||||
if (bookForMetadata == null && bundleResult == null && type in EPUB_READER_FILE_TYPES) {
|
||||
Timber.d("Parsing downloaded book for cover/metadata: $displayName")
|
||||
Timber.tag("FileOpenPerf")
|
||||
.d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)")
|
||||
|
|
@ -3547,7 +3554,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
|
||||
val finalBookMetadata = bookForMetadata
|
||||
|
||||
if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) && finalBookMetadata != null) {
|
||||
if (type in EPUB_READER_FILE_TYPES && finalBookMetadata != null) {
|
||||
title = title ?: finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName
|
||||
|
||||
author = author ?: finalBookMetadata.author.takeIf {
|
||||
|
|
@ -3568,22 +3575,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
|
||||
if (type == FileType.PDF) {
|
||||
try {
|
||||
val pdfiumCore = PdfiumCore(appContext)
|
||||
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
val pdfDocument = pdfiumCore.newDocument(pfd)
|
||||
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
PdfiumCoreProvider.core.newDocument(pfd).use { pdfDocument ->
|
||||
val meta = pdfDocument.getDocumentMeta()
|
||||
|
||||
val extractedTitle = meta.title
|
||||
if (!extractedTitle.isNullOrBlank() && title == displayName) {
|
||||
title = extractedTitle
|
||||
val extractedTitle = meta.title
|
||||
if (!extractedTitle.isNullOrBlank() && title == displayName) {
|
||||
title = extractedTitle
|
||||
}
|
||||
|
||||
val extractedAuthor = meta.author
|
||||
if (!extractedAuthor.isNullOrBlank() && author == null) {
|
||||
author = extractedAuthor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val extractedAuthor = meta.author
|
||||
if (!extractedAuthor.isNullOrBlank() && author == null) {
|
||||
author = extractedAuthor
|
||||
}
|
||||
|
||||
pdfiumCore.closeDocument(pdfDocument)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to extract PDF title using PdfiumCore")
|
||||
|
|
@ -3686,7 +3693,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
|
||||
fun setSortOrder(sortOrder: SortOrder) {
|
||||
_internalState.update { it.withSharedLibraryAction(SharedLibraryAction.SortChanged(sortOrder.toSharedSortOrder())) }
|
||||
_internalState.update { it.withSharedLibraryAction(SharedLibraryAction.SortChanged(sortOrder)) }
|
||||
prefs.edit { putString(KEY_SORT_ORDER, sortOrder.name) }
|
||||
}
|
||||
|
||||
|
|
@ -3814,7 +3821,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
_internalState.update {
|
||||
it.copy(
|
||||
bannerMessage = BannerMessage(
|
||||
message = appContext.getString(R.string.banner_importing_multiple, uris.size),
|
||||
message = appContext.resources.getQuantityString(
|
||||
R.plurals.banner_importing_books_count,
|
||||
uris.size,
|
||||
uris.size
|
||||
),
|
||||
isPersistent = true
|
||||
),
|
||||
contextualActionItems = emptySet()
|
||||
|
|
@ -3866,8 +3877,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
unsupportedCount = unsupportedCount,
|
||||
failedCount = failedCount
|
||||
),
|
||||
importedMessage = "Imported $importedCount books. You can find them in the Library tab.",
|
||||
duplicateMessage = "Those files are already in the library.",
|
||||
importedMessage = appContext.resources.getQuantityString(
|
||||
R.plurals.banner_books_imported_library_tab,
|
||||
importedCount,
|
||||
importedCount
|
||||
),
|
||||
duplicateMessage = appContext.getString(R.string.banner_duplicate_files_already_in_library),
|
||||
unsupportedMessage = appContext.getString(R.string.error_unsupported_file_type),
|
||||
failedMessage = appContext.getString(R.string.error_import_file_failed)
|
||||
)
|
||||
|
|
@ -3901,6 +3916,52 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
fun openTtsNotificationTarget(
|
||||
bookId: String,
|
||||
sourceCfi: String?,
|
||||
startOffset: Int?,
|
||||
chapterIndex: Int?,
|
||||
pageIndex: Int?
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val item = recentFilesRepository.getFileByBookId(bookId)
|
||||
if (item == null) {
|
||||
_internalState.update {
|
||||
it.copy(errorMessage = appContext.getString(R.string.error_recent_item_not_found))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val uri = item.getUri()
|
||||
if (uri == null) {
|
||||
_internalState.update {
|
||||
it.copy(errorMessage = appContext.getString(R.string.error_file_location_not_found))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val initialLocator = chapterIndex?.let {
|
||||
Locator(
|
||||
chapterIndex = it,
|
||||
blockIndex = 0,
|
||||
charOffset = startOffset ?: 0
|
||||
)
|
||||
}
|
||||
|
||||
openBook(
|
||||
uri = uri,
|
||||
bookId = item.bookId,
|
||||
type = item.type,
|
||||
originalDisplayName = item.displayName,
|
||||
initialPageOverride = pageIndex,
|
||||
isInitialPageExplicit = pageIndex != null,
|
||||
initialLocatorOverride = initialLocator,
|
||||
initialCfiOverride = sourceCfi?.takeIf { it.isNotBlank() },
|
||||
preserveTtsOnOpen = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) {
|
||||
_internalState.update {
|
||||
it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet())
|
||||
|
|
@ -4011,6 +4072,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
selectedBookId = bookId,
|
||||
selectedPdfUri = uri,
|
||||
initialPageInBook = syncPosition,
|
||||
initialPageInBookIsExplicit = true,
|
||||
isOpeningFromTtsNotification = false,
|
||||
initialBookmarksJson = item.bookmarksJson,
|
||||
isLoading = false
|
||||
)
|
||||
|
|
@ -4201,7 +4264,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
|
||||
private fun openBook(
|
||||
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null
|
||||
uri: Uri,
|
||||
bookId: String,
|
||||
type: FileType,
|
||||
originalDisplayName: String? = null,
|
||||
suppressNavigation: Boolean = false,
|
||||
bundleResult: CalibreBundleResult? = null,
|
||||
initialPageOverride: Int? = null,
|
||||
isInitialPageExplicit: Boolean = false,
|
||||
initialLocatorOverride: Locator? = null,
|
||||
initialCfiOverride: String? = null,
|
||||
preserveTtsOnOpen: Boolean = false
|
||||
) {
|
||||
val openBookStartTime = System.currentTimeMillis()
|
||||
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
|
||||
|
|
@ -4270,8 +4343,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
selectedFileType = type,
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
initialLocator = null,
|
||||
initialPageInBook = null
|
||||
initialLocator = initialLocatorOverride,
|
||||
initialCfi = initialCfiOverride,
|
||||
initialPageInBook = initialPageOverride,
|
||||
initialPageInBookIsExplicit = isInitialPageExplicit,
|
||||
isOpeningFromTtsNotification = preserveTtsOnOpen
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -4284,7 +4360,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
_internalState.update {
|
||||
it.copy(
|
||||
selectedPdfUri = uri,
|
||||
initialPageInBook = recentItem?.lastPage,
|
||||
initialPageInBook = initialPageOverride ?: recentItem?.lastPage,
|
||||
initialPageInBookIsExplicit = isInitialPageExplicit,
|
||||
isOpeningFromTtsNotification = preserveTtsOnOpen,
|
||||
initialBookmarksJson = recentItem?.bookmarksJson,
|
||||
isLoading = false
|
||||
)
|
||||
|
|
@ -4308,13 +4386,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.tag("FileSwitch").d("PDF state updated, suppressing navigation event for smooth transition")
|
||||
}
|
||||
}
|
||||
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) {
|
||||
} else if (type in EPUB_READER_FILE_TYPES) {
|
||||
viewModelScope.launch {
|
||||
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
||||
Timber.tag("FileOpenPerf")
|
||||
.d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||
val locator =
|
||||
if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) {
|
||||
val locator = initialLocatorOverride
|
||||
?: if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) {
|
||||
Locator(
|
||||
chapterIndex = recentItem.lastChapterIndex,
|
||||
blockIndex = recentItem.locatorBlockIndex,
|
||||
|
|
@ -4328,7 +4406,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
it.copy(
|
||||
selectedEpubUri = uri,
|
||||
initialLocator = locator,
|
||||
initialCfi = recentItem?.lastPositionCfi,
|
||||
initialCfi = initialCfiOverride ?: recentItem?.lastPositionCfi,
|
||||
initialBookmarksJson = recentItem?.bookmarksJson,
|
||||
initialHighlightsJson = recentItem?.highlightsJson,
|
||||
)
|
||||
|
|
@ -4372,7 +4450,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
selectedFileType = null,
|
||||
selectedBookId = null,
|
||||
isLoading = false,
|
||||
errorMessage = appContext.getString(R.string.error_unsupported_file_type)
|
||||
errorMessage = appContext.getString(R.string.error_unsupported_file_type),
|
||||
initialPageInBookIsExplicit = false,
|
||||
isOpeningFromTtsNotification = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -5399,6 +5479,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
if (legacyId == newId) return@withContext
|
||||
Timber.tag("FolderAnnotationSync")
|
||||
.d("Checking migration from legacyId=$legacyId to newId=$newId")
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"vm.migrate.check legacyId=$legacyId newId=$newId"
|
||||
)
|
||||
|
||||
try {
|
||||
fun safeMigrate(legacyFile: File?, newFile: File?, tag: String) {
|
||||
|
|
@ -5430,6 +5513,95 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
fun layoutBlankScore(file: File?): Pair<Int, Int> {
|
||||
if (file == null || !file.exists()) return 0 to 0
|
||||
return try {
|
||||
val array = JSONArray(file.readText())
|
||||
var blankCount = 0
|
||||
var manualBlankCount = 0
|
||||
for (i in 0 until array.length()) {
|
||||
val page = array.optJSONObject(i) ?: continue
|
||||
if (page.optString("type") == "blank") {
|
||||
blankCount++
|
||||
if (page.optBoolean("manual", false)) manualBlankCount++
|
||||
}
|
||||
}
|
||||
manualBlankCount to blankCount
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("FolderAnnotationSync").w(e, "Unable to score layout for migration: ${file.name}")
|
||||
0 to 0
|
||||
}
|
||||
}
|
||||
|
||||
fun safeMigrateLayout(legacyFile: File?, newFile: File?) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"vm.migrate.layout.start legacyId=$legacyId newId=$newId " +
|
||||
"legacyPath=${legacyFile?.absolutePath} legacyExists=${legacyFile?.exists()} " +
|
||||
"legacyBytes=${legacyFile?.takeIf { it.exists() }?.length() ?: 0L} " +
|
||||
"legacyMtime=${legacyFile?.takeIf { it.exists() }?.lastModified() ?: 0L} " +
|
||||
"newPath=${newFile?.absolutePath} newExists=${newFile?.exists()} " +
|
||||
"newBytes=${newFile?.takeIf { it.exists() }?.length() ?: 0L} " +
|
||||
"newMtime=${newFile?.takeIf { it.exists() }?.lastModified() ?: 0L}"
|
||||
)
|
||||
if (legacyFile == null || !legacyFile.exists()) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"vm.migrate.layout.noLegacy legacyId=$legacyId newId=$newId"
|
||||
)
|
||||
return
|
||||
}
|
||||
if (newFile == null) {
|
||||
Timber.tag("FolderAnnotationSync")
|
||||
.w("Destination file for layout is null. Skipping.")
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
|
||||
"vm.migrate.layout.noDestination legacyId=$legacyId newId=$newId"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (newFile.exists()) {
|
||||
val legacyTs = legacyFile.lastModified()
|
||||
val newTs = newFile.lastModified()
|
||||
val legacyScore = layoutBlankScore(legacyFile)
|
||||
val newScore = layoutBlankScore(newFile)
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"vm.migrate.layout.compare legacyId=$legacyId newId=$newId " +
|
||||
"legacyScore=$legacyScore legacyTs=$legacyTs newScore=$newScore newTs=$newTs"
|
||||
)
|
||||
val shouldKeepExisting =
|
||||
newScore.first > legacyScore.first ||
|
||||
(newScore.first == legacyScore.first && newScore.second > legacyScore.second) ||
|
||||
(newScore == legacyScore && newTs >= legacyTs)
|
||||
|
||||
if (shouldKeepExisting) {
|
||||
Timber.tag("FolderAnnotationSync")
|
||||
.i("Skipping layout migration: destination preserves newer or richer blank-page layout.")
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"vm.migrate.layout.keepExisting legacyId=$legacyId newId=$newId"
|
||||
)
|
||||
legacyFile.delete()
|
||||
return
|
||||
}
|
||||
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
|
||||
"vm.migrate.layout.replaceExisting legacyId=$legacyId newId=$newId"
|
||||
)
|
||||
newFile.delete()
|
||||
}
|
||||
|
||||
if (legacyFile.renameTo(newFile)) {
|
||||
Timber.tag("FolderAnnotationSync").i("Migrated layout successfully.")
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"vm.migrate.layout.done legacyId=$legacyId newId=$newId " +
|
||||
"newExists=${newFile.exists()} newBytes=${newFile.length()} newMtime=${newFile.lastModified()}"
|
||||
)
|
||||
} else {
|
||||
Timber.tag("FolderAnnotationSync").w("Failed to rename layout file.")
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
|
||||
"vm.migrate.layout.renameFailed legacyId=$legacyId newId=$newId"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Annotations
|
||||
safeMigrate(
|
||||
pdfAnnotationRepository.getAnnotationFileForSync(legacyId),
|
||||
|
|
@ -5445,10 +5617,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
)
|
||||
|
||||
// 3. Layout
|
||||
safeMigrate(
|
||||
safeMigrateLayout(
|
||||
pageLayoutRepository.getLayoutFile(legacyId),
|
||||
pageLayoutRepository.getLayoutFile(newId),
|
||||
"layout"
|
||||
pageLayoutRepository.getLayoutFile(newId)
|
||||
)
|
||||
|
||||
// 4. Text Boxes
|
||||
|
|
@ -5616,6 +5787,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
_internalState.update { it.copy(useStrictFileFilter = enabled) }
|
||||
}
|
||||
|
||||
fun setUsePdfFileNameAsDisplayName(enabled: Boolean) {
|
||||
prefs.edit { putBoolean(KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME, enabled) }
|
||||
_internalState.update { it.copy(usePdfFileNameAsDisplayName = enabled) }
|
||||
}
|
||||
|
||||
fun setScreenCaptureProtectionEnabled(enabled: Boolean) {
|
||||
prefs.edit { putBoolean(KEY_SCREEN_CAPTURE_PROTECTION, enabled) }
|
||||
_internalState.update { it.copy(isScreenCaptureProtectionEnabled = enabled) }
|
||||
|
|
@ -5642,13 +5818,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
return themes
|
||||
}
|
||||
|
||||
private fun loadAppFontPreference(prefs: SharedPreferences): AppFontPreference {
|
||||
val kind = try {
|
||||
AppFontPreferenceKind.valueOf(
|
||||
prefs.getString(KEY_APP_FONT_KIND, AppFontPreferenceKind.SYSTEM.name)
|
||||
?: AppFontPreferenceKind.SYSTEM.name
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
AppFontPreferenceKind.SYSTEM
|
||||
}
|
||||
return AppFontPreference(
|
||||
kind = kind,
|
||||
customFontId = prefs.getString(KEY_APP_FONT_CUSTOM_ID, null)
|
||||
).sanitized()
|
||||
}
|
||||
|
||||
fun setAppFontPreference(preference: AppFontPreference) {
|
||||
val sanitized = preference.sanitized()
|
||||
_internalState.update { it.withSharedAppAction(SharedAppAction.AppFontPreferenceChanged(sanitized)) }
|
||||
prefs.edit {
|
||||
putString(KEY_APP_FONT_KIND, sanitized.kind.name)
|
||||
if (sanitized.customFontId == null) {
|
||||
remove(KEY_APP_FONT_CUSTOM_ID)
|
||||
} else {
|
||||
putString(KEY_APP_FONT_CUSTOM_ID, sanitized.customFontId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAppThemeMode(mode: AppThemeMode) {
|
||||
_internalState.update { it.withSharedAppAction(SharedAppAction.AppThemeChanged(mode.toSharedAppThemeMode())) }
|
||||
_internalState.update { it.withSharedAppAction(SharedAppAction.AppThemeChanged(mode)) }
|
||||
prefs.edit { putString(KEY_APP_THEME_MODE, mode.name) }
|
||||
}
|
||||
|
||||
fun setAppContrastOption(option: AppContrastOption) {
|
||||
_internalState.update { it.withSharedAppAction(SharedAppAction.AppContrastChanged(option.toSharedAppContrastOption())) }
|
||||
_internalState.update { it.withSharedAppAction(SharedAppAction.AppContrastChanged(option)) }
|
||||
prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) }
|
||||
}
|
||||
|
||||
|
|
@ -5674,7 +5878,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
|
||||
fun addCustomAppTheme(theme: CustomAppTheme) {
|
||||
_internalState.update { it.withSharedAppAction(SharedAppAction.CustomAppThemeAdded(theme.toSharedCustomAppTheme())) }
|
||||
_internalState.update { it.withSharedAppAction(SharedAppAction.CustomAppThemeAdded(theme)) }
|
||||
val current = _internalState.value.customAppThemes
|
||||
saveCustomAppThemes(current)
|
||||
prefs.edit { putInt(KEY_APP_SEED_COLOR, theme.seedColor.toArgb()) }
|
||||
|
|
@ -5905,6 +6109,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type"
|
||||
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
|
||||
private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter"
|
||||
private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name"
|
||||
private const val KEY_SCREEN_CAPTURE_PROTECTION = "screen_capture_protection_enabled"
|
||||
private const val KEY_APP_THEME_MODE = "app_theme_mode"
|
||||
private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option"
|
||||
|
|
@ -5912,23 +6117,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
private const val KEY_APP_TEXT_DIM_FACTOR = "app_text_dim_factor"
|
||||
private const val KEY_APP_TEXT_DIM_FACTOR_LIGHT = "app_text_dim_factor_light"
|
||||
private const val KEY_APP_TEXT_DIM_FACTOR_DARK = "app_text_dim_factor_dark"
|
||||
private const val KEY_APP_FONT_KIND = "app_font_kind"
|
||||
private const val KEY_APP_FONT_CUSTOM_ID = "app_font_custom_id"
|
||||
private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes"
|
||||
|
||||
val SUPPORTED_MIME_TYPES = arrayOf(
|
||||
"application/pdf", "application/epub+zip", "application/x-mobipocket-ebook",
|
||||
"application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook", "text/markdown",
|
||||
"text/x-markdown", "text/plain", "text/html", "application/xhtml+xml",
|
||||
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2", "application/zip",
|
||||
"application/vnd.comicbook+zip", "application/x-cbz", "application/vnd.comicbook-rar",
|
||||
"application/x-cbr", "application/x-rar-compressed", "application/x-cb7",
|
||||
"application/x-7z-compressed", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.oasis.opendocument.text", "application/x-vnd.oasis.opendocument.text-flat-xml",
|
||||
"text/csv", "text/comma-separated-values", "text/tab-separated-values", "application/json",
|
||||
"application/xml", "text/xml", "text/x-java-source", "text/x-python", "text/x-kotlin",
|
||||
"text/javascript", "application/javascript", "text/x-c", "text/x-c++",
|
||||
"text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log"
|
||||
)
|
||||
val SUPPORTED_MIME_TYPES = SharedFileCapabilities.androidFilePickerMimeTypes.toTypedArray()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import androidx.work.WorkerParameters
|
|||
import androidx.work.WorkManager
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.RecentFilesRepository
|
||||
import io.legere.pdfiumandroid.PdfiumCore
|
||||
import com.aryan.reader.pdf.PdfiumCoreProvider
|
||||
import com.aryan.reader.pdf.PdfiumEngineProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
|
@ -277,16 +278,14 @@ class MetadataExtractionWorker(
|
|||
return parseXmlTextMetadata(xml)
|
||||
}
|
||||
|
||||
private fun parsePdfTextMetadata(uri: android.net.Uri): TextMetadata {
|
||||
private suspend fun parsePdfTextMetadata(uri: android.net.Uri): TextMetadata {
|
||||
return try {
|
||||
val pdfiumCore = PdfiumCore(appContext)
|
||||
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
val pdfDocument = pdfiumCore.newDocument(pfd)
|
||||
try {
|
||||
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
|
||||
TextMetadata(title = meta.title, author = meta.author)
|
||||
} finally {
|
||||
pdfiumCore.closeDocument(pdfDocument)
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
PdfiumCoreProvider.core.newDocument(pfd).use { pdfDocument ->
|
||||
val meta = pdfDocument.getDocumentMeta()
|
||||
TextMetadata(title = meta.title, author = meta.author)
|
||||
}
|
||||
}
|
||||
} ?: TextMetadata()
|
||||
} catch (e: Exception) {
|
||||
|
|
|
|||
193
app/src/main/java/com/aryan/reader/ReaderBrightness.kt
Normal file
193
app/src/main/java/com/aryan/reader/ReaderBrightness.kt
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.edit
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val READER_PREFS_NAME = "reader_prefs"
|
||||
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
|
||||
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
|
||||
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
|
||||
private const val MIN_CUSTOM_BRIGHTNESS = 0.05f
|
||||
|
||||
data class ReaderBrightnessSettings(
|
||||
val useSystemBrightness: Boolean = true,
|
||||
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
|
||||
) {
|
||||
val safeCustomBrightness: Float
|
||||
get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
||||
}
|
||||
|
||||
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
|
||||
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return ReaderBrightnessSettings(
|
||||
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
|
||||
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
|
||||
.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
fun saveReaderBrightnessSettings(context: Context, settings: ReaderBrightnessSettings) {
|
||||
context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE).edit {
|
||||
putBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, settings.useSystemBrightness)
|
||||
putFloat(PREF_READER_BRIGHTNESS_VALUE, settings.safeCustomBrightness)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReaderBrightnessEffect(
|
||||
window: Window?,
|
||||
settings: ReaderBrightnessSettings
|
||||
) {
|
||||
DisposableEffect(window) {
|
||||
val originalBrightness = window?.attributes?.screenBrightness
|
||||
?: WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
onDispose {
|
||||
window?.setReaderBrightness(originalBrightness)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(window, settings) {
|
||||
val brightness = if (settings.useSystemBrightness) {
|
||||
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
} else {
|
||||
settings.safeCustomBrightness
|
||||
}
|
||||
window?.setReaderBrightness(brightness)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReaderBrightnessSheet(
|
||||
settings: ReaderBrightnessSettings,
|
||||
onSettingsChange: (ReaderBrightnessSettings) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.reader_brightness_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_done))
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.reader_brightness_system),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.reader_brightness_system_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = settings.useSystemBrightness,
|
||||
onCheckedChange = { useSystem ->
|
||||
onSettingsChange(settings.copy(useSystemBrightness = useSystem))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.reader_brightness_custom),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.reader_brightness_percent,
|
||||
(settings.safeCustomBrightness * 100f).roundToInt()
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = settings.safeCustomBrightness,
|
||||
onValueChange = { brightness ->
|
||||
onSettingsChange(
|
||||
settings.copy(
|
||||
useSystemBrightness = false,
|
||||
customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
|
||||
)
|
||||
)
|
||||
},
|
||||
valueRange = MIN_CUSTOM_BRIGHTNESS..1f
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.reader_brightness_custom_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Window.setReaderBrightness(brightness: Float) {
|
||||
attributes = attributes.apply {
|
||||
screenBrightness = brightness
|
||||
}
|
||||
}
|
||||
104
app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt
Normal file
104
app/src/main/java/com/aryan/reader/ReaderFileInfoDialogs.kt
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
|
||||
@Composable
|
||||
private fun rememberReaderFileInfoItem(
|
||||
uiState: ReaderScreenState,
|
||||
primaryBookId: String?,
|
||||
secondaryBookId: String? = null,
|
||||
uriString: String? = null
|
||||
): RecentFileItem? {
|
||||
return remember(
|
||||
uiState.allRecentFiles,
|
||||
uiState.recentFiles,
|
||||
primaryBookId,
|
||||
secondaryBookId,
|
||||
uriString
|
||||
) {
|
||||
findReaderFileInfoItem(
|
||||
uiState = uiState,
|
||||
primaryBookId = primaryBookId,
|
||||
secondaryBookId = secondaryBookId,
|
||||
uriString = uriString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findReaderFileInfoItem(
|
||||
uiState: ReaderScreenState,
|
||||
primaryBookId: String?,
|
||||
secondaryBookId: String?,
|
||||
uriString: String?
|
||||
): RecentFileItem? {
|
||||
val bookId = primaryBookId ?: secondaryBookId
|
||||
return uiState.allRecentFiles.firstOrNull { it.bookId == bookId }
|
||||
?: uiState.recentFiles.firstOrNull { it.bookId == bookId }
|
||||
?: uiState.allRecentFiles.firstOrNull { it.uriString == uriString }
|
||||
?: uiState.recentFiles.firstOrNull { it.uriString == uriString }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ReaderFileInfoDialogs(
|
||||
isFileInfoVisible: Boolean,
|
||||
onFileInfoVisibleChange: (Boolean) -> Unit,
|
||||
uiState: ReaderScreenState,
|
||||
primaryBookId: String?,
|
||||
secondaryBookId: String? = null,
|
||||
uriString: String? = null,
|
||||
viewModel: MainViewModel
|
||||
) {
|
||||
val item = rememberReaderFileInfoItem(
|
||||
uiState = uiState,
|
||||
primaryBookId = primaryBookId,
|
||||
secondaryBookId = secondaryBookId,
|
||||
uriString = uriString
|
||||
)
|
||||
|
||||
LaunchedEffect(item?.bookId) {
|
||||
if (item == null) {
|
||||
onFileInfoVisibleChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
item?.let { fileInfoItem ->
|
||||
if (isFileInfoVisible) {
|
||||
FileInfoDialog(
|
||||
item = fileInfoItem,
|
||||
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
|
||||
onDismiss = { onFileInfoVisibleChange(false) },
|
||||
onSaveMetadata = { metadata ->
|
||||
viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
|
||||
},
|
||||
onSaveDisplayName = { name ->
|
||||
viewModel.updateCustomName(fileInfoItem.bookId, name)
|
||||
},
|
||||
onRestoreMetadata = {
|
||||
viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
|
||||
},
|
||||
onOpenTags = {
|
||||
onFileInfoVisibleChange(false)
|
||||
viewModel.openTagSelection(setOf(fileInfoItem.bookId))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
137
app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt
Normal file
137
app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.core.content.edit
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
private const val READER_SLIDER_CHROME_PREFS = "reader_slider_chrome_prefs"
|
||||
private const val READER_SLIDER_TOGGLE_PREFIX = "reader_slider_toggle_"
|
||||
private const val MIN_SLIDER_ACCENT_CONTRAST = 3f
|
||||
private const val MIN_SLIDER_CONTENT_CONTRAST = 4.5f
|
||||
|
||||
internal data class ReaderSliderBookmarkPosition(
|
||||
val startPage: Int,
|
||||
val currentPage: Float
|
||||
)
|
||||
|
||||
internal data class ReaderSliderToggleState(
|
||||
val isToggledOn: Boolean,
|
||||
val bookmarkPosition: ReaderSliderBookmarkPosition
|
||||
)
|
||||
|
||||
internal data class ReaderSliderChromeColors(
|
||||
val activeTrackColor: Color,
|
||||
val inactiveTrackColor: Color,
|
||||
val thumbColor: Color,
|
||||
val bookmarkColor: Color,
|
||||
val contentColor: Color,
|
||||
val thumbnailSurfaceColor: Color,
|
||||
val thumbnailContentColor: Color
|
||||
)
|
||||
|
||||
internal fun readerSliderBookmarkPosition(currentPage: Int): ReaderSliderBookmarkPosition {
|
||||
val sanitizedPage = currentPage.coerceAtLeast(0)
|
||||
return ReaderSliderBookmarkPosition(
|
||||
startPage = sanitizedPage,
|
||||
currentPage = sanitizedPage.toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
internal fun readerSliderToggleState(
|
||||
isCurrentlyToggledOn: Boolean,
|
||||
currentPage: Int
|
||||
): ReaderSliderToggleState {
|
||||
return ReaderSliderToggleState(
|
||||
isToggledOn = !isCurrentlyToggledOn,
|
||||
bookmarkPosition = readerSliderBookmarkPosition(currentPage)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldRenderReaderSlider(
|
||||
isToggledOn: Boolean,
|
||||
isBottomChromeVisible: Boolean,
|
||||
isSearchActive: Boolean
|
||||
): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive
|
||||
|
||||
internal fun readerSliderTogglePreferenceKey(bookId: String): String =
|
||||
READER_SLIDER_TOGGLE_PREFIX + bookId
|
||||
|
||||
internal fun loadReaderSliderToggled(context: Context, bookId: String): Boolean {
|
||||
return context
|
||||
.getSharedPreferences(READER_SLIDER_CHROME_PREFS, Context.MODE_PRIVATE)
|
||||
.getBoolean(readerSliderTogglePreferenceKey(bookId), false)
|
||||
}
|
||||
|
||||
internal fun saveReaderSliderToggled(
|
||||
context: Context,
|
||||
bookId: String,
|
||||
isToggledOn: Boolean
|
||||
) {
|
||||
context
|
||||
.getSharedPreferences(READER_SLIDER_CHROME_PREFS, Context.MODE_PRIVATE)
|
||||
.edit { putBoolean(readerSliderTogglePreferenceKey(bookId), isToggledOn) }
|
||||
}
|
||||
|
||||
internal fun readerSliderChromeColors(
|
||||
pageBackground: Color,
|
||||
pageText: Color,
|
||||
themePrimary: Color
|
||||
): ReaderSliderChromeColors {
|
||||
val background = specifiedColorOr(pageBackground, Color.White)
|
||||
val fallbackContent = highContrastColorFor(background)
|
||||
val content = specifiedColorOr(pageText, fallbackContent)
|
||||
.takeIf { contrastRatio(it, background) >= MIN_SLIDER_CONTENT_CONTRAST }
|
||||
?: fallbackContent
|
||||
val active = specifiedColorOr(themePrimary, content)
|
||||
.takeIf { contrastRatio(it, background) >= MIN_SLIDER_ACCENT_CONTRAST }
|
||||
?: content
|
||||
val inactiveAlpha = if (background.luminance() > 0.5f) 0.44f else 0.52f
|
||||
val thumbnailSurface = blendColors(
|
||||
foreground = content,
|
||||
background = background,
|
||||
alpha = if (background.luminance() > 0.5f) 0.08f else 0.12f
|
||||
).copy(alpha = 0.96f)
|
||||
|
||||
return ReaderSliderChromeColors(
|
||||
activeTrackColor = active,
|
||||
inactiveTrackColor = content.copy(alpha = inactiveAlpha),
|
||||
thumbColor = active,
|
||||
bookmarkColor = active,
|
||||
contentColor = content,
|
||||
thumbnailSurfaceColor = thumbnailSurface,
|
||||
thumbnailContentColor = content
|
||||
)
|
||||
}
|
||||
|
||||
private fun specifiedColorOr(color: Color, fallback: Color): Color {
|
||||
return if (color == Color.Unspecified) fallback else color
|
||||
}
|
||||
|
||||
private fun highContrastColorFor(background: Color): Color {
|
||||
return if (contrastRatio(Color.Black, background) >= contrastRatio(Color.White, background)) {
|
||||
Color.Black
|
||||
} else {
|
||||
Color.White
|
||||
}
|
||||
}
|
||||
|
||||
private fun contrastRatio(first: Color, second: Color): Float {
|
||||
val firstLuminance = first.luminance()
|
||||
val secondLuminance = second.luminance()
|
||||
val lighter = max(firstLuminance, secondLuminance)
|
||||
val darker = min(firstLuminance, secondLuminance)
|
||||
return (lighter + 0.05f) / (darker + 0.05f)
|
||||
}
|
||||
|
||||
private fun blendColors(foreground: Color, background: Color, alpha: Float): Color {
|
||||
val clampedAlpha = alpha.coerceIn(0f, 1f)
|
||||
return Color(
|
||||
red = foreground.red * clampedAlpha + background.red * (1f - clampedAlpha),
|
||||
green = foreground.green * clampedAlpha + background.green * (1f - clampedAlpha),
|
||||
blue = foreground.blue * clampedAlpha + background.blue * (1f - clampedAlpha),
|
||||
alpha = 1f
|
||||
)
|
||||
}
|
||||
35
app/src/main/java/com/aryan/reader/SafeStringResources.kt
Normal file
35
app/src/main/java/com/aryan/reader/SafeStringResources.kt
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import java.util.IllegalFormatException
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun safeStringResource(@StringRes id: Int, vararg formatArgs: Any?): String {
|
||||
LocalConfiguration.current
|
||||
return LocalContext.current.safeGetString(id, *formatArgs)
|
||||
}
|
||||
|
||||
fun Context.safeGetString(@StringRes id: Int, vararg formatArgs: Any?): String {
|
||||
return try {
|
||||
resources.getString(id, *formatArgs)
|
||||
} catch (_: IllegalFormatException) {
|
||||
try {
|
||||
englishResources().getString(id, *formatArgs)
|
||||
} catch (_: IllegalFormatException) {
|
||||
resources.getString(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.englishResources() =
|
||||
createConfigurationContext(
|
||||
Configuration(resources.configuration).apply {
|
||||
setLocale(Locale.ENGLISH)
|
||||
}
|
||||
).resources
|
||||
|
|
@ -29,11 +29,8 @@ import androidx.media3.common.util.UnstableApi
|
|||
import androidx.navigation.NavHostController
|
||||
import com.aryan.reader.data.CustomFontEntity
|
||||
import com.aryan.reader.epubreader.FormatSettings as AndroidFormatSettings
|
||||
import com.aryan.reader.epubreader.PageInfoMode as AndroidPageInfoMode
|
||||
import com.aryan.reader.epubreader.PageInfoPosition as AndroidPageInfoPosition
|
||||
import com.aryan.reader.epubreader.ReaderFont as AndroidReaderFont
|
||||
import com.aryan.reader.epubreader.ReaderTextAlign as AndroidReaderTextAlign
|
||||
import com.aryan.reader.epubreader.SystemUiMode as AndroidSystemUiMode
|
||||
import com.aryan.reader.epubreader.loadFormatSettings
|
||||
import com.aryan.reader.epubreader.loadPageInfoMode
|
||||
import com.aryan.reader.epubreader.loadPageInfoPosition
|
||||
|
|
@ -56,12 +53,11 @@ import com.aryan.reader.pdf.loadPdfVerticalPageGapVisible
|
|||
import com.aryan.reader.pdf.loadPdfPageNumberOverlayVisible
|
||||
import com.aryan.reader.shared.BuiltInPdfReaderThemes
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.PageInfoMode as SharedPageInfoMode
|
||||
import com.aryan.reader.shared.PageInfoPosition as SharedPageInfoPosition
|
||||
import com.aryan.reader.shared.SharedSettingsAction
|
||||
import com.aryan.reader.shared.SharedSettingsDestination
|
||||
import com.aryan.reader.shared.SystemUiMode as SharedSystemUiMode
|
||||
import com.aryan.reader.shared.parentDestination
|
||||
import com.aryan.reader.shared.toReaderSettingsFontFamily
|
||||
import com.aryan.reader.shared.toSharedReaderTextAlign
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
|
|
@ -204,6 +200,9 @@ fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR -> showBehaviorDialog = true
|
||||
SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME -> {
|
||||
viewModel.setUsePdfFileNameAsDisplayName(!uiState.usePdfFileNameAsDisplayName)
|
||||
}
|
||||
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> {
|
||||
val next = !uiState.isScreenCaptureProtectionEnabled
|
||||
viewModel.setScreenCaptureProtectionEnabled(next)
|
||||
|
|
@ -438,9 +437,9 @@ private fun loadAndroidEpubReaderDefaultSettings(
|
|||
themeId = loadReaderThemeId(context),
|
||||
textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f),
|
||||
customFontPath = format.customPath?.takeIf { it.isNotBlank() },
|
||||
systemUiMode = loadSystemUiMode(context).toSharedSystemUiMode(),
|
||||
pageInfoMode = loadPageInfoMode(context).toSharedPageInfoMode(),
|
||||
pageInfoPosition = loadPageInfoPosition(context).toSharedPageInfoPosition(),
|
||||
systemUiMode = loadSystemUiMode(context),
|
||||
pageInfoMode = loadPageInfoMode(context),
|
||||
pageInfoPosition = loadPageInfoPosition(context),
|
||||
seamlessChapterNavigation = loadPullToTurn(context),
|
||||
chapterTurnDragMultiplier = loadPullToTurnMultiplier(context)
|
||||
)
|
||||
|
|
@ -453,7 +452,7 @@ private fun loadAndroidPdfReaderDefaultSettings(
|
|||
val base = ReaderSettings(
|
||||
themeId = loadPdfThemeId(context),
|
||||
textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f),
|
||||
systemUiMode = loadPdfSystemUiMode(context).toSharedSystemUiMode(),
|
||||
systemUiMode = loadPdfSystemUiMode(context),
|
||||
pdfVerticalPageGapVisible = loadPdfVerticalPageGapVisible(context),
|
||||
pdfPageNumberOverlayVisible = loadPdfPageNumberOverlayVisible(context)
|
||||
)
|
||||
|
|
@ -476,9 +475,9 @@ private fun saveAndroidEpubReaderDefaultSettings(
|
|||
customFontPath = settings.customFontPath,
|
||||
textAlign = settings.textAlign.toAndroidTextAlign()
|
||||
)
|
||||
saveSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode())
|
||||
savePageInfoMode(context, settings.pageInfoMode.toAndroidPageInfoMode())
|
||||
savePageInfoPosition(context, settings.pageInfoPosition.toAndroidPageInfoPosition())
|
||||
saveSystemUiMode(context, settings.systemUiMode)
|
||||
savePageInfoMode(context, settings.pageInfoMode)
|
||||
savePageInfoPosition(context, settings.pageInfoPosition)
|
||||
savePullToTurn(context, settings.seamlessChapterNavigation)
|
||||
savePullToTurnMultiplier(context, settings.chapterTurnDragMultiplier)
|
||||
saveReaderThemeId(context, settings.themeId ?: "system")
|
||||
|
|
@ -489,7 +488,7 @@ private fun saveAndroidPdfReaderDefaultSettings(
|
|||
context: Context,
|
||||
settings: ReaderSettings
|
||||
) {
|
||||
savePdfSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode())
|
||||
savePdfSystemUiMode(context, settings.systemUiMode)
|
||||
savePdfThemeId(context, settings.themeId ?: "no_theme")
|
||||
savePdfVerticalPageGapVisible(context, settings.pdfVerticalPageGapVisible)
|
||||
savePdfPageNumberOverlayVisible(context, settings.pdfPageNumberOverlayVisible)
|
||||
|
|
@ -514,23 +513,7 @@ private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontIte
|
|||
|
||||
private fun AndroidFormatSettings.toSharedFontFamilyName(): String {
|
||||
return customPath?.substringAfterLast('/')?.substringAfterLast('\\')?.takeIf { it.isNotBlank() }
|
||||
?: when (font) {
|
||||
AndroidReaderFont.ORIGINAL -> "Default"
|
||||
AndroidReaderFont.MERRIWEATHER,
|
||||
AndroidReaderFont.LORA -> "Serif"
|
||||
AndroidReaderFont.LATO,
|
||||
AndroidReaderFont.LEXEND -> "Sans"
|
||||
AndroidReaderFont.ROBOTO_MONO -> "Mono"
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
|
||||
return when (this) {
|
||||
AndroidReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
|
||||
AndroidReaderTextAlign.RIGHT -> SharedReaderTextAlign.RIGHT
|
||||
AndroidReaderTextAlign.DEFAULT,
|
||||
AndroidReaderTextAlign.LEFT -> SharedReaderTextAlign.START
|
||||
}
|
||||
?: font.toReaderSettingsFontFamily()
|
||||
}
|
||||
|
||||
private fun ReaderSettings.toAndroidReaderFont(): AndroidReaderFont {
|
||||
|
|
@ -564,27 +547,3 @@ private fun ReaderSettings.toAndroidRenderMode(): RenderMode {
|
|||
ReaderReadingMode.VERTICAL -> RenderMode.VERTICAL_SCROLL
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidSystemUiMode.toSharedSystemUiMode(): SharedSystemUiMode {
|
||||
return SharedSystemUiMode.valueOf(name)
|
||||
}
|
||||
|
||||
private fun SharedSystemUiMode.toAndroidSystemUiMode(): AndroidSystemUiMode {
|
||||
return AndroidSystemUiMode.valueOf(name)
|
||||
}
|
||||
|
||||
private fun AndroidPageInfoMode.toSharedPageInfoMode(): SharedPageInfoMode {
|
||||
return SharedPageInfoMode.valueOf(name)
|
||||
}
|
||||
|
||||
private fun SharedPageInfoMode.toAndroidPageInfoMode(): AndroidPageInfoMode {
|
||||
return AndroidPageInfoMode.valueOf(name)
|
||||
}
|
||||
|
||||
private fun AndroidPageInfoPosition.toSharedPageInfoPosition(): SharedPageInfoPosition {
|
||||
return SharedPageInfoPosition.valueOf(name)
|
||||
}
|
||||
|
||||
private fun SharedPageInfoPosition.toAndroidPageInfoPosition(): AndroidPageInfoPosition {
|
||||
return AndroidPageInfoPosition.valueOf(name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ import androidx.core.net.toUri
|
|||
import androidx.core.text.HtmlCompat
|
||||
import com.aryan.reader.data.BookMetadataEdit
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.shared.SharedText
|
||||
import com.aryan.reader.shared.ui.SharedMarkdownText
|
||||
import timber.log.Timber
|
||||
import java.text.SimpleDateFormat
|
||||
|
|
@ -239,7 +240,8 @@ fun rememberFilePickerLauncher(
|
|||
contract = ActivityResultContracts.OpenMultipleDocuments(),
|
||||
onResult = { uris: List<Uri> ->
|
||||
if (uris.isNotEmpty()) {
|
||||
Timber.d("${uris.size} file(s) selected.")
|
||||
val fileLabel = if (uris.size == 1) "file" else "files"
|
||||
Timber.d("${uris.size} $fileLabel selected.")
|
||||
onFilesSelected(uris)
|
||||
} else {
|
||||
Timber.d("File selection cancelled.")
|
||||
|
|
@ -383,6 +385,7 @@ fun DeleteConfirmationDialog(
|
|||
@Composable
|
||||
fun FileInfoDialog(
|
||||
item: RecentFileItem,
|
||||
usePdfFileNameAsDisplayName: Boolean = false,
|
||||
onDismiss: () -> Unit,
|
||||
onSaveMetadata: (BookMetadataEdit) -> Unit,
|
||||
onSaveDisplayName: (String?) -> Unit,
|
||||
|
|
@ -399,8 +402,8 @@ fun FileInfoDialog(
|
|||
mutableStateOf(item.seriesIndex?.formatMetadataNumber().orEmpty())
|
||||
}
|
||||
var descriptionInput by remember(item.bookId, item.description) { mutableStateOf(item.description.orEmpty()) }
|
||||
var displayNameInput by remember(item.bookId, item.customName, item.title, item.displayName) {
|
||||
mutableStateOf(item.customName ?: item.cardTitle())
|
||||
var displayNameInput by remember(item.bookId, item.customName, item.title, item.displayName, usePdfFileNameAsDisplayName) {
|
||||
mutableStateOf(item.customName ?: item.cardTitle(usePdfFileNameAsDisplayName))
|
||||
}
|
||||
var showRestoreConfirmation by remember(item.bookId) { mutableStateOf(false) }
|
||||
|
||||
|
|
@ -455,7 +458,7 @@ fun FileInfoDialog(
|
|||
} else {
|
||||
stringResource(R.string.file_information)
|
||||
},
|
||||
subtitle = item.cardTitle(),
|
||||
subtitle = item.cardTitle(usePdfFileNameAsDisplayName),
|
||||
onClose = {
|
||||
if (isEditing) {
|
||||
isEditing = false
|
||||
|
|
@ -498,6 +501,7 @@ fun FileInfoDialog(
|
|||
} else {
|
||||
BookMetadataInfoContent(
|
||||
item = item,
|
||||
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName,
|
||||
formattedDate = formattedDate,
|
||||
lastModifiedDate = lastModifiedDate,
|
||||
pathText = pathTextFinal,
|
||||
|
|
@ -611,6 +615,7 @@ private fun FileInfoTopBar(
|
|||
@Composable
|
||||
private fun BookMetadataInfoContent(
|
||||
item: RecentFileItem,
|
||||
usePdfFileNameAsDisplayName: Boolean,
|
||||
formattedDate: String,
|
||||
lastModifiedDate: String?,
|
||||
pathText: String,
|
||||
|
|
@ -624,7 +629,7 @@ private fun BookMetadataInfoContent(
|
|||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
item.cardTitle(),
|
||||
item.cardTitle(usePdfFileNameAsDisplayName),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 3,
|
||||
|
|
@ -1120,6 +1125,8 @@ private fun Double.formatMetadataNumber(): String {
|
|||
|
||||
@Composable
|
||||
fun CustomTopBanner(bannerMessage: BannerMessage?) {
|
||||
val context = LocalContext.current
|
||||
val bannerText = bannerMessage?.localizedMessage(context).orEmpty()
|
||||
AnimatedVisibility(
|
||||
visible = bannerMessage != null,
|
||||
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
|
||||
|
|
@ -1138,7 +1145,7 @@ fun CustomTopBanner(bannerMessage: BannerMessage?) {
|
|||
shadowElevation = 8.dp
|
||||
) {
|
||||
Text(
|
||||
text = bannerMessage?.message ?: "",
|
||||
text = bannerText,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
color = if (bannerMessage?.isError == true) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
|
@ -1149,6 +1156,25 @@ fun CustomTopBanner(bannerMessage: BannerMessage?) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun BannerMessage.localizedMessage(context: Context): String {
|
||||
return text?.resolveAndroidText(context) ?: message
|
||||
}
|
||||
|
||||
private fun SharedText.resolveAndroidText(context: Context): String {
|
||||
val resources = context.resources
|
||||
val packageName = context.packageName
|
||||
val formatArgs = args.toTypedArray()
|
||||
val quantityValue = quantity
|
||||
val resolved = if (quantityValue == null) {
|
||||
val id = resources.getIdentifier(name, "string", packageName)
|
||||
if (id == 0) null else runCatching { resources.getString(id, *formatArgs) }.getOrNull()
|
||||
} else {
|
||||
val id = resources.getIdentifier(name, "plurals", packageName)
|
||||
if (id == 0) null else runCatching { resources.getQuantityString(id, quantityValue, *formatArgs) }.getOrNull()
|
||||
}
|
||||
return resolved ?: fallbackMessage()
|
||||
}
|
||||
|
||||
@Suppress("KotlinConstantConditions")
|
||||
@Composable
|
||||
fun AboutDialog(onDismiss: () -> Unit) {
|
||||
|
|
@ -1429,7 +1455,12 @@ fun AutoSizeText(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolean = false) {
|
||||
fun FileTypeBadge(
|
||||
type: FileType,
|
||||
modifier: Modifier = Modifier,
|
||||
overlay: Boolean = false,
|
||||
compact: Boolean = false
|
||||
) {
|
||||
val containerColor = if (overlay) Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer
|
||||
val contentColor = if (overlay) Color.White else MaterialTheme.colorScheme.onSecondaryContainer
|
||||
|
||||
|
|
@ -1442,9 +1473,17 @@ fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolea
|
|||
) {
|
||||
Text(
|
||||
text = if (type == FileType.UNKNOWN) "FILE" else type.name.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp),
|
||||
style = if (compact) {
|
||||
MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp, letterSpacing = 0.sp)
|
||||
} else {
|
||||
MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp)
|
||||
},
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = if (compact) 6.dp else 10.dp,
|
||||
vertical = if (compact) 3.dp else 4.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1498,8 +1537,12 @@ fun BookTagChipsRow(
|
|||
|
||||
private const val UNKNOWN_AUTHOR_LABEL = "No author listed"
|
||||
|
||||
fun RecentFileItem.cardTitle(): String {
|
||||
return customName ?: title?.takeIf { it.isNotBlank() } ?: displayName
|
||||
fun RecentFileItem.cardTitle(usePdfFileNameAsDisplayName: Boolean = false): String {
|
||||
customName?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
if (usePdfFileNameAsDisplayName && type == FileType.PDF) {
|
||||
return displayName
|
||||
}
|
||||
return title?.takeIf { it.isNotBlank() } ?: displayName
|
||||
}
|
||||
|
||||
fun RecentFileItem.cardAuthor(): String {
|
||||
|
|
|
|||
|
|
@ -5,31 +5,34 @@ import com.aryan.reader.data.BookTagCrossRef
|
|||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.ShelfEntity
|
||||
import com.aryan.reader.data.TagEntity
|
||||
import com.aryan.reader.shared.AddBooksSource as SharedAddBooksSource
|
||||
import com.aryan.reader.shared.AppContrastOption as SharedAppContrastOption
|
||||
import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode
|
||||
import com.aryan.reader.shared.BannerMessage as SharedBannerMessage
|
||||
import com.aryan.reader.shared.BookItem as SharedBookItem
|
||||
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
|
||||
import com.aryan.reader.shared.CustomAppTheme as SharedCustomAppTheme
|
||||
import com.aryan.reader.shared.EpubAnnotationSerializer
|
||||
import com.aryan.reader.shared.FileType as SharedFileType
|
||||
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
|
||||
import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter
|
||||
import com.aryan.reader.shared.RenderMode as SharedRenderMode
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.Shelf as SharedShelf
|
||||
import com.aryan.reader.shared.ShelfRecord
|
||||
import com.aryan.reader.shared.ShelfType as SharedShelfType
|
||||
import com.aryan.reader.shared.SortOrder as SharedSortOrder
|
||||
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
|
||||
import com.aryan.reader.shared.Tag as SharedTag
|
||||
|
||||
fun FileType.toSharedFileType(): SharedFileType = this
|
||||
|
||||
fun SharedFileType.toAndroidFileType(): FileType = this
|
||||
|
||||
fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters = this
|
||||
|
||||
fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters = this
|
||||
|
||||
fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this
|
||||
|
||||
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
|
||||
|
||||
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
|
||||
return SharedBookItem(
|
||||
id = bookId,
|
||||
path = uriString,
|
||||
type = type.toSharedFileType(),
|
||||
type = type,
|
||||
displayName = customName ?: displayName,
|
||||
timestamp = timestamp,
|
||||
coverImagePath = coverImagePath,
|
||||
|
|
@ -67,7 +70,7 @@ fun SharedBookItem.toRecentFileItem(
|
|||
return androidBooksById[id]?.copy(tags = resolvedTags)
|
||||
?.copy(
|
||||
uriString = path,
|
||||
type = type.toAndroidFileType(),
|
||||
type = type,
|
||||
displayName = androidBooksById[id]?.displayName ?: displayName,
|
||||
timestamp = timestamp,
|
||||
coverImagePath = coverImagePath,
|
||||
|
|
@ -92,7 +95,7 @@ fun SharedBookItem.toRecentFileItem(
|
|||
?: RecentFileItem(
|
||||
bookId = id,
|
||||
uriString = path,
|
||||
type = type.toAndroidFileType(),
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = timestamp,
|
||||
coverImagePath = coverImagePath,
|
||||
|
|
@ -169,11 +172,11 @@ fun ReaderScreenState.toSharedReaderScreenState(
|
|||
return SharedReaderScreenState(
|
||||
selectedBookId = selectedBookId,
|
||||
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
|
||||
selectedFileType = selectedFileType?.toSharedFileType(),
|
||||
selectedFileType = selectedFileType,
|
||||
isLoading = isLoading,
|
||||
errorMessage = errorMessage,
|
||||
renderMode = renderMode.toSharedRenderMode(),
|
||||
sortOrder = sortOrder.toSharedSortOrder(),
|
||||
renderMode = renderMode,
|
||||
sortOrder = sortOrder,
|
||||
viewingShelfId = viewingShelfId,
|
||||
isAddingBooksToShelf = isAddingBooksToShelf,
|
||||
showCreateShelfDialog = showCreateShelfDialog,
|
||||
|
|
@ -181,7 +184,7 @@ fun ReaderScreenState.toSharedReaderScreenState(
|
|||
libraryScreenStartPage = libraryScreenStartPage,
|
||||
showRenameShelfDialogFor = showRenameShelfDialogFor,
|
||||
showDeleteShelfDialogFor = showDeleteShelfDialogFor,
|
||||
addBooksSource = addBooksSource.toSharedAddBooksSource(),
|
||||
addBooksSource = addBooksSource,
|
||||
booksSelectedForAdding = booksSelectedForAdding,
|
||||
selectedBookIds = contextualActionItems.mapTo(mutableSetOf()) { it.bookId },
|
||||
selectedShelfIds = contextualActionShelfIds,
|
||||
|
|
@ -189,10 +192,10 @@ fun ReaderScreenState.toSharedReaderScreenState(
|
|||
credits = credits,
|
||||
isSyncEnabled = isSyncEnabled,
|
||||
isFolderSyncEnabled = isFolderSyncEnabled,
|
||||
bannerMessage = bannerMessage?.toSharedBannerMessage(),
|
||||
bannerMessage = bannerMessage,
|
||||
downloadingBookIds = downloadingBookIds,
|
||||
uploadingBookIds = uploadingBookIds,
|
||||
syncedFolders = syncedFolders.map { it.toSharedSyncedFolder() },
|
||||
syncedFolders = syncedFolders,
|
||||
lastFolderScanTime = lastFolderScanTime,
|
||||
hasUnreadFeedback = hasUnreadFeedback,
|
||||
searchQuery = searchQuery,
|
||||
|
|
@ -204,7 +207,7 @@ fun ReaderScreenState.toSharedReaderScreenState(
|
|||
rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
|
||||
pinnedHomeBookIds = pinnedHomeBookIds,
|
||||
pinnedLibraryBookIds = pinnedLibraryBookIds,
|
||||
libraryFilters = libraryFilters.toSharedLibraryFilters(),
|
||||
libraryFilters = libraryFilters,
|
||||
recentFilesLimit = recentFilesLimit,
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
openTabIds = openTabIds,
|
||||
|
|
@ -213,12 +216,14 @@ fun ReaderScreenState.toSharedReaderScreenState(
|
|||
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
|
||||
externalFileBehavior = externalFileBehavior,
|
||||
useStrictFileFilter = useStrictFileFilter,
|
||||
appThemeMode = appThemeMode.toSharedAppThemeMode(),
|
||||
appContrastOption = appContrastOption.toSharedAppContrastOption(),
|
||||
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName,
|
||||
appThemeMode = appThemeMode,
|
||||
appContrastOption = appContrastOption,
|
||||
appTextDimFactorLight = appTextDimFactorLight,
|
||||
appTextDimFactorDark = appTextDimFactorDark,
|
||||
appSeedColor = appSeedColor,
|
||||
customAppThemes = customAppThemes.map { it.toSharedCustomAppTheme() },
|
||||
appFontPreference = appFontPreference,
|
||||
customAppThemes = customAppThemes,
|
||||
allTags = dbTags.map { it.toSharedTag() },
|
||||
showTagSelectionDialogFor = showTagSelectionDialogFor
|
||||
)
|
||||
|
|
@ -271,7 +276,7 @@ fun SharedShelf.toAndroidShelf(
|
|||
return Shelf(
|
||||
id = id,
|
||||
name = name,
|
||||
type = type.toAndroidShelfType(),
|
||||
type = type,
|
||||
books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
|
||||
directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
|
||||
parentShelfId = parentShelfId,
|
||||
|
|
@ -280,91 +285,3 @@ fun SharedShelf.toAndroidShelf(
|
|||
sortKey = sortKey
|
||||
)
|
||||
}
|
||||
|
||||
fun FileType.toSharedFileType(): SharedFileType {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedFileType.toAndroidFileType(): FileType {
|
||||
return this
|
||||
}
|
||||
|
||||
fun RenderMode.toSharedRenderMode(): SharedRenderMode {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedRenderMode.toAndroidRenderMode(): RenderMode {
|
||||
return this
|
||||
}
|
||||
|
||||
fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedAddBooksSource.toAndroidAddBooksSource(): AddBooksSource {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SortOrder.toSharedSortOrder(): SharedSortOrder {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedSortOrder.toAndroidSortOrder(): SortOrder {
|
||||
return this
|
||||
}
|
||||
|
||||
fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedReadStatusFilter.toAndroidReadStatusFilter(): ReadStatusFilter {
|
||||
return this
|
||||
}
|
||||
|
||||
fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder {
|
||||
return this
|
||||
}
|
||||
|
||||
private fun SharedShelfType.toAndroidShelfType(): ShelfType {
|
||||
return ShelfType.valueOf(name)
|
||||
}
|
||||
|
||||
fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
|
||||
return this
|
||||
}
|
||||
|
||||
fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedAppThemeMode.toAndroidAppThemeMode(): AppThemeMode {
|
||||
return this
|
||||
}
|
||||
|
||||
fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedAppContrastOption.toAndroidAppContrastOption(): AppContrastOption {
|
||||
return this
|
||||
}
|
||||
|
||||
fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
|
||||
return this
|
||||
}
|
||||
|
||||
fun SharedCustomAppTheme.toAndroidCustomAppTheme(): CustomAppTheme {
|
||||
return this
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
package com.aryan.reader.data
|
||||
|
||||
import com.aryan.reader.FileType
|
||||
import org.json.JSONObject
|
||||
import com.aryan.reader.shared.SharedFolderBookMetadata
|
||||
|
||||
data class FolderBookMetadata(
|
||||
val bookId: String,
|
||||
|
|
@ -31,67 +31,76 @@ data class FolderBookMetadata(
|
|||
val originalDescription: String? = null
|
||||
) {
|
||||
fun toJsonString(): String {
|
||||
val json = JSONObject()
|
||||
json.put("bookId", bookId)
|
||||
json.put("displayName", displayName)
|
||||
json.put("type", type)
|
||||
json.put("lastChapterIndex", lastChapterIndex ?: -1)
|
||||
json.put("lastPage", lastPage ?: -1)
|
||||
json.put("lastPositionCfi", lastPositionCfi)
|
||||
json.put("progressPercentage", progressPercentage.toDouble())
|
||||
json.put("isRecent", isRecent)
|
||||
json.put("lastModifiedTimestamp", lastModifiedTimestamp)
|
||||
json.put("bookmarksJson", bookmarksJson)
|
||||
json.put("locatorBlockIndex", locatorBlockIndex ?: -1)
|
||||
json.put("locatorCharOffset", locatorCharOffset ?: -1)
|
||||
json.put("customName", customName)
|
||||
json.put("highlightsJson", highlightsJson)
|
||||
return json.toString()
|
||||
return toSharedFolderBookMetadata().toJsonString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJsonString(jsonString: String): FolderBookMetadata {
|
||||
val json = JSONObject(jsonString)
|
||||
|
||||
fun JSONObject.optStringNull(key: String): String? {
|
||||
return if (has(key) && !isNull(key)) getString(key) else null
|
||||
}
|
||||
|
||||
fun JSONObject.optIntNull(key: String): Int? {
|
||||
val value = optInt(key, -1)
|
||||
return if (value == -1) null else value
|
||||
}
|
||||
|
||||
return FolderBookMetadata(
|
||||
bookId = json.getString("bookId"),
|
||||
title = null,
|
||||
author = null,
|
||||
displayName = json.optString("displayName", "Unknown"),
|
||||
type = json.optString("type", "PDF"),
|
||||
lastChapterIndex = json.optIntNull("lastChapterIndex"),
|
||||
lastPage = json.optIntNull("lastPage"),
|
||||
lastPositionCfi = json.optStringNull("lastPositionCfi"),
|
||||
progressPercentage = json.optDouble("progressPercentage", 0.0).toFloat(),
|
||||
isRecent = json.optBoolean("isRecent", true),
|
||||
lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L),
|
||||
bookmarksJson = json.optStringNull("bookmarksJson"),
|
||||
locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
|
||||
locatorCharOffset = json.optIntNull("locatorCharOffset"),
|
||||
customName = json.optStringNull("customName"),
|
||||
highlightsJson = json.optStringNull("highlightsJson"),
|
||||
seriesName = null,
|
||||
seriesIndex = null,
|
||||
description = null,
|
||||
originalTitle = null,
|
||||
originalAuthor = null,
|
||||
originalSeriesName = null,
|
||||
originalSeriesIndex = null,
|
||||
originalDescription = null
|
||||
)
|
||||
return SharedFolderBookMetadata.fromJsonString(jsonString)
|
||||
?.toFolderBookMetadata()
|
||||
?: error("Invalid folder metadata JSON")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun FolderBookMetadata.toSharedFolderBookMetadata(): SharedFolderBookMetadata {
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = bookId,
|
||||
title = title,
|
||||
author = author,
|
||||
displayName = displayName,
|
||||
type = type,
|
||||
lastChapterIndex = lastChapterIndex,
|
||||
lastPage = lastPage,
|
||||
lastPositionCfi = lastPositionCfi,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent,
|
||||
lastModifiedTimestamp = lastModifiedTimestamp,
|
||||
bookmarksJson = bookmarksJson,
|
||||
locatorBlockIndex = locatorBlockIndex,
|
||||
locatorCharOffset = locatorCharOffset,
|
||||
customName = customName,
|
||||
highlightsJson = highlightsJson,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex,
|
||||
description = description,
|
||||
originalTitle = originalTitle,
|
||||
originalAuthor = originalAuthor,
|
||||
originalSeriesName = originalSeriesName,
|
||||
originalSeriesIndex = originalSeriesIndex,
|
||||
originalDescription = originalDescription
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedFolderBookMetadata.toFolderBookMetadata(): FolderBookMetadata {
|
||||
return FolderBookMetadata(
|
||||
bookId = bookId,
|
||||
title = title,
|
||||
author = author,
|
||||
displayName = displayName,
|
||||
type = type,
|
||||
lastChapterIndex = lastChapterIndex,
|
||||
lastPage = lastPage,
|
||||
lastPositionCfi = lastPositionCfi,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent,
|
||||
lastModifiedTimestamp = lastModifiedTimestamp,
|
||||
bookmarksJson = bookmarksJson,
|
||||
locatorBlockIndex = locatorBlockIndex,
|
||||
locatorCharOffset = locatorCharOffset,
|
||||
customName = customName,
|
||||
highlightsJson = highlightsJson,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex,
|
||||
description = description,
|
||||
originalTitle = originalTitle,
|
||||
originalAuthor = originalAuthor,
|
||||
originalSeriesName = originalSeriesName,
|
||||
originalSeriesIndex = originalSeriesIndex,
|
||||
originalDescription = originalDescription
|
||||
)
|
||||
}
|
||||
|
||||
fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, sourceFolderUri: String?): RecentFileItem {
|
||||
return RecentFileItem(
|
||||
bookId = this.bookId,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ data class EpubBook(
|
|||
val description: String? = null,
|
||||
)
|
||||
|
||||
fun epubContentFilePath(path: String): String = path.substringBefore('#').substringBefore('?')
|
||||
|
||||
fun EpubChapter.contentFilePath(): String = epubContentFilePath(htmlFilePath)
|
||||
|
||||
fun EpubBook.hasReadableExtractedContent(): Boolean {
|
||||
if (extractionBasePath.isBlank()) return false
|
||||
val extractionDir = File(extractionBasePath)
|
||||
|
|
@ -60,6 +64,6 @@ fun EpubBook.hasReadableExtractedContent(): Boolean {
|
|||
if (chapters.isEmpty()) return extractionDir.list()?.isNotEmpty() == true
|
||||
|
||||
return chapters.all { chapter ->
|
||||
File(extractionDir, chapter.htmlFilePath).isFile
|
||||
File(extractionDir, chapter.contentFilePath()).isFile
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ class SingleFileImporter(private val context: Context) {
|
|||
companion object {
|
||||
private const val MAX_DOCX_ARCHIVE_BYTES = 64L * 1024L * 1024L
|
||||
private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L
|
||||
private const val MAX_HTML_CHAPTER_CHARS = 1_000_000
|
||||
private const val MAX_HTML_BUFFERED_LINE_CHARS = 128_000
|
||||
private const val MAX_HTML_HEAD_SCAN_CHARS = 256_000
|
||||
private const val MAX_HTML_INLINE_CSS_CHARS = 256_000
|
||||
private const val PAGE_BREAK_MARKER = "<page-break></page-break>"
|
||||
}
|
||||
|
||||
private val htmlSafelist = Safelist.relaxed()
|
||||
|
|
@ -519,57 +524,140 @@ class SingleFileImporter(private val context: Context) {
|
|||
var pageNum = 1
|
||||
val headBuilder = java.lang.StringBuilder()
|
||||
val currentChapterBuilder = java.lang.StringBuilder()
|
||||
var titleFound = false
|
||||
var authorFound = false
|
||||
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
val trimmed = line!!.trim()
|
||||
fun appendCss(style: String) {
|
||||
if (style.isBlank() || cssBuilder.length >= MAX_HTML_INLINE_CSS_CHARS) return
|
||||
val remaining = MAX_HTML_INLINE_CSS_CHARS - cssBuilder.length
|
||||
cssBuilder.append(style, 0, minOf(style.length, remaining)).append('\n')
|
||||
}
|
||||
|
||||
fun appendHeadSample(sample: String) {
|
||||
if (headBuilder.length >= MAX_HTML_HEAD_SCAN_CHARS) return
|
||||
val remaining = MAX_HTML_HEAD_SCAN_CHARS - headBuilder.length
|
||||
headBuilder.append(sample, 0, minOf(sample.length, remaining)).append('\n')
|
||||
}
|
||||
|
||||
fun flushChapter() {
|
||||
if (currentChapterBuilder.isBlank()) {
|
||||
currentChapterBuilder.clear()
|
||||
return
|
||||
}
|
||||
chapters.add(
|
||||
writeHtmlChapter(
|
||||
extractionDir,
|
||||
bookId,
|
||||
pageNum++,
|
||||
title,
|
||||
cssBuilder.toString(),
|
||||
currentChapterBuilder.toString()
|
||||
)
|
||||
)
|
||||
currentChapterBuilder.clear()
|
||||
}
|
||||
|
||||
fun appendBodySegment(segment: String, startIndex: Int = 0, endIndex: Int = segment.length, addNewline: Boolean = true) {
|
||||
var start = startIndex
|
||||
while (start < endIndex) {
|
||||
val remainingCapacity = (MAX_HTML_CHAPTER_CHARS - currentChapterBuilder.length).coerceAtLeast(1)
|
||||
val requestedEnd = minOf(endIndex, start + remainingCapacity)
|
||||
val chunkEnd = findHtmlChunkEnd(segment, start, requestedEnd, endIndex)
|
||||
currentChapterBuilder.append(segment, start, chunkEnd)
|
||||
start = chunkEnd
|
||||
if (currentChapterBuilder.length >= MAX_HTML_CHAPTER_CHARS) {
|
||||
flushChapter()
|
||||
}
|
||||
}
|
||||
if (addNewline) {
|
||||
currentChapterBuilder.append('\n')
|
||||
if (currentChapterBuilder.length >= MAX_HTML_CHAPTER_CHARS) {
|
||||
flushChapter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun appendBodyLine(line: String) {
|
||||
var start = 0
|
||||
var markerIndex = line.indexOf(PAGE_BREAK_MARKER, start, ignoreCase = true)
|
||||
while (markerIndex >= 0) {
|
||||
appendBodySegment(line, start, markerIndex)
|
||||
flushChapter()
|
||||
start = markerIndex + PAGE_BREAK_MARKER.length
|
||||
markerIndex = line.indexOf(PAGE_BREAK_MARKER, start, ignoreCase = true)
|
||||
}
|
||||
appendBodySegment(line, start, line.length)
|
||||
}
|
||||
|
||||
reader.forEachBoundedLine(MAX_HTML_BUFFERED_LINE_CHARS) { line ->
|
||||
val trimmed = line.trim()
|
||||
|
||||
if (inScript) {
|
||||
if (trimmed.contains("</script", ignoreCase = true)) {
|
||||
inScript = false
|
||||
}
|
||||
continue
|
||||
return@forEachBoundedLine
|
||||
}
|
||||
if (trimmed.startsWith("<script", ignoreCase = true)) {
|
||||
if (!trimmed.contains("</script", ignoreCase = true)) {
|
||||
inScript = true
|
||||
}
|
||||
continue
|
||||
return@forEachBoundedLine
|
||||
}
|
||||
|
||||
if (!inBody) {
|
||||
headBuilder.append(line).append('\n')
|
||||
extractHtmlTitle(headBuilder.toString())?.let { title = it }
|
||||
extractHtmlAuthor(headBuilder.toString())?.let { author = it }
|
||||
appendHeadSample(line)
|
||||
if (!titleFound) {
|
||||
(extractHtmlTitle(line) ?: extractHtmlTitle(headBuilder.toString()))?.let {
|
||||
title = it
|
||||
titleFound = true
|
||||
}
|
||||
}
|
||||
if (!authorFound) {
|
||||
(extractHtmlAuthor(line) ?: extractHtmlAuthor(headBuilder.toString()))?.let {
|
||||
author = it
|
||||
authorFound = true
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("<style", ignoreCase = true)) {
|
||||
inStyle = true
|
||||
val styleContent = line.substringAfter(">").substringBefore("</style>")
|
||||
if (styleContent.isNotBlank()) cssBuilder.append(styleContent).append("\n")
|
||||
appendCss(styleContent)
|
||||
if (trimmed.contains("</style>")) {
|
||||
inStyle = false
|
||||
}
|
||||
continue
|
||||
return@forEachBoundedLine
|
||||
}
|
||||
if (inStyle) {
|
||||
if (trimmed.contains("</style>")) {
|
||||
cssBuilder.append(line.substringBefore("</style>")).append("\n")
|
||||
appendCss(line.substringBefore("</style>"))
|
||||
inStyle = false
|
||||
} else {
|
||||
cssBuilder.append(line).append("\n")
|
||||
appendCss(line)
|
||||
}
|
||||
continue
|
||||
return@forEachBoundedLine
|
||||
}
|
||||
|
||||
if (trimmed.equals("<body>", ignoreCase = true)) {
|
||||
inBody = true
|
||||
continue
|
||||
return@forEachBoundedLine
|
||||
}
|
||||
if (trimmed.startsWith("<body ", ignoreCase = true)) {
|
||||
inBody = true
|
||||
val afterBody = line.substringAfter(">", "")
|
||||
if (afterBody.isNotBlank()) currentChapterBuilder.append(afterBody).append("\n")
|
||||
continue
|
||||
if (afterBody.isNotBlank()) appendBodyLine(afterBody)
|
||||
return@forEachBoundedLine
|
||||
}
|
||||
val embeddedBodyIndex = line.indexOf("<body", ignoreCase = true)
|
||||
if (embeddedBodyIndex >= 0) {
|
||||
val bodyContentIndex = line.indexOf('>', startIndex = embeddedBodyIndex)
|
||||
if (bodyContentIndex >= 0) {
|
||||
inBody = true
|
||||
val afterBody = line.substring(bodyContentIndex + 1)
|
||||
if (afterBody.isNotBlank()) appendBodyLine(afterBody)
|
||||
return@forEachBoundedLine
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("<p") || trimmed.startsWith("<div") ||
|
||||
|
|
@ -577,41 +665,18 @@ class SingleFileImporter(private val context: Context) {
|
|||
trimmed.contains("<page-break>") ||
|
||||
(trimmed.isNotBlank() && !trimmed.startsWith("<") && !trimmed.startsWith("<!"))) {
|
||||
inBody = true
|
||||
currentChapterBuilder.append(line).append("\n")
|
||||
appendBodyLine(line)
|
||||
}
|
||||
} else {
|
||||
if (trimmed.equals("</body>", ignoreCase = true) || trimmed.equals("</html>", ignoreCase = true)) {
|
||||
continue
|
||||
return@forEachBoundedLine
|
||||
}
|
||||
|
||||
if (line.contains("<page-break></page-break>")) {
|
||||
val parts = line.split("<page-break></page-break>")
|
||||
for (i in parts.indices) {
|
||||
currentChapterBuilder.append(parts[i]).append("\n")
|
||||
if (i < parts.size - 1) {
|
||||
val chapterHtml = currentChapterBuilder.toString()
|
||||
if (chapterHtml.isNotBlank()) {
|
||||
chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), chapterHtml))
|
||||
}
|
||||
currentChapterBuilder.clear() // Clean memory allocation
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
currentChapterBuilder.append(line).append("\n")
|
||||
|
||||
if (currentChapterBuilder.length > 2_000_000) {
|
||||
chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), currentChapterBuilder.toString()))
|
||||
currentChapterBuilder.clear()
|
||||
}
|
||||
appendBodyLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
val finalChapterHtml = currentChapterBuilder.toString()
|
||||
if (finalChapterHtml.isNotBlank()) {
|
||||
chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), finalChapterHtml))
|
||||
}
|
||||
flushChapter()
|
||||
}
|
||||
|
||||
if (chapters.isEmpty()) {
|
||||
|
|
@ -761,7 +826,7 @@ class SingleFileImporter(private val context: Context) {
|
|||
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")
|
||||
writer.write(htmlContent)
|
||||
writeHtmlBodyContentChunked(writer, htmlContent)
|
||||
writer.write("\n</body>\n</html>")
|
||||
}
|
||||
|
||||
|
|
@ -816,12 +881,19 @@ class SingleFileImporter(private val context: Context) {
|
|||
val fileName = "page_$pageNum.html"
|
||||
val file = File(extractionDir, fileName)
|
||||
val sanitizedBodyContent = sanitizeHtmlFragment(bodyContent)
|
||||
val escapedTitle = title.replace("\"", """)
|
||||
|
||||
val fullHtml = "<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<title>${title.replace("\"", """)}</title>\n<style>${cssStyle}</style>\n</head>\n<body>\n${sanitizedBodyContent.trim()}\n</body>\n</html>"
|
||||
file.bufferedWriter().use { writer ->
|
||||
writer.write("<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<title>")
|
||||
writer.write(escapedTitle)
|
||||
writer.write("</title>\n<style>")
|
||||
writer.write(cssStyle)
|
||||
writer.write("</style>\n</head>\n<body>\n")
|
||||
writer.write(sanitizedBodyContent.trim())
|
||||
writer.write("\n</body>\n</html>")
|
||||
}
|
||||
|
||||
file.writeText(fullHtml)
|
||||
|
||||
val plainText = Jsoup.parse(fullHtml).text()
|
||||
val plainText = Jsoup.parse(sanitizedBodyContent).text()
|
||||
|
||||
return EpubChapter(
|
||||
chapterId = "${bookId}_$pageNum",
|
||||
|
|
@ -834,4 +906,70 @@ class SingleFileImporter(private val context: Context) {
|
|||
isInToc = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun java.io.BufferedReader.forEachBoundedLine(
|
||||
maxLineChars: Int,
|
||||
onLine: (String) -> Unit
|
||||
) {
|
||||
val buffer = CharArray(16_384)
|
||||
val currentLine = StringBuilder()
|
||||
while (true) {
|
||||
val read = read(buffer)
|
||||
if (read == -1) break
|
||||
|
||||
var start = 0
|
||||
var index = 0
|
||||
while (index < read) {
|
||||
val char = buffer[index]
|
||||
val reachesLimit = currentLine.length + (index - start + 1) >= maxLineChars
|
||||
if (char == '\n' || reachesLimit) {
|
||||
val count = index - start + if (char == '\n') 0 else 1
|
||||
if (count > 0) {
|
||||
currentLine.append(buffer, start, count)
|
||||
}
|
||||
onLine(currentLine.toString().trimEnd('\r'))
|
||||
currentLine.clear()
|
||||
start = index + 1
|
||||
}
|
||||
index++
|
||||
}
|
||||
|
||||
if (start < read) {
|
||||
currentLine.append(buffer, start, read - start)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine.isNotEmpty()) {
|
||||
onLine(currentLine.toString().trimEnd('\r'))
|
||||
}
|
||||
}
|
||||
|
||||
private fun findHtmlChunkEnd(
|
||||
source: String,
|
||||
start: Int,
|
||||
requestedEnd: Int,
|
||||
absoluteEnd: Int
|
||||
): Int {
|
||||
if (requestedEnd >= absoluteEnd) return absoluteEnd
|
||||
|
||||
val tagStart = source.lastIndexOf('<', requestedEnd - 1)
|
||||
val tagEnd = source.lastIndexOf('>', requestedEnd - 1)
|
||||
if (tagStart > start && tagStart > tagEnd && requestedEnd - tagStart <= 4096) {
|
||||
return tagStart
|
||||
}
|
||||
|
||||
return requestedEnd
|
||||
}
|
||||
|
||||
private fun writeHtmlBodyContentChunked(writer: java.io.Writer, htmlContent: String) {
|
||||
var charsSinceBreak = 0
|
||||
for (char in htmlContent) {
|
||||
writer.write(char.code)
|
||||
charsSinceBreak++
|
||||
if (char == '>' || charsSinceBreak >= MAX_HTML_BUFFERED_LINE_CHARS) {
|
||||
writer.write('\n'.code)
|
||||
charsSinceBreak = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -468,6 +468,9 @@ fun ChapterWebView(
|
|||
ttsScope: CoroutineScope,
|
||||
tocFragments: List<String>,
|
||||
initialFragmentId: String? = null,
|
||||
initialImageSource: String? = null,
|
||||
initialImageOriginalSource: String? = null,
|
||||
initialImageOrdinal: Int = 0,
|
||||
onTtsTextReady: suspend (String) -> Unit,
|
||||
isProUser: Boolean,
|
||||
isOss: Boolean = false,
|
||||
|
|
@ -1011,6 +1014,14 @@ fun ChapterWebView(
|
|||
}
|
||||
onChapterInitiallyScrolled()
|
||||
scrollActionTaken = true
|
||||
} else if (!initialImageSource.isNullOrBlank()) {
|
||||
val imageJsCommand =
|
||||
"javascript:window.scrollToReaderImageSource('${escapeJsString(initialImageSource)}', $initialImageOrdinal, '${escapeJsString(initialImageOriginalSource.orEmpty())}');"
|
||||
Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to image source: $initialImageSource")
|
||||
view?.evaluateJavascript(imageJsCommand) {
|
||||
onChapterInitiallyScrolled()
|
||||
scrollActionTaken = true
|
||||
}
|
||||
} else if (initialScrollTarget != null) {
|
||||
val scrollJsCommand = when (initialScrollTarget) {
|
||||
ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();"
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import com.aryan.reader.SummarizationResult
|
|||
import com.aryan.reader.SummaryCacheManager
|
||||
import com.aryan.reader.callByokTextAi
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
import com.aryan.reader.epub.contentFilePath
|
||||
import com.aryan.reader.fetchRecap
|
||||
import com.aryan.reader.paginatedreader.IPaginator
|
||||
import com.aryan.reader.summarizationUrl
|
||||
|
|
@ -209,8 +210,7 @@ suspend fun executeRecapLogic(
|
|||
val textToSummarize = paginator?.getPlainTextForChapter(i) ?: withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val chapter = chapters[i]
|
||||
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
|
||||
val doc = Jsoup.parse(File(fullPath), "UTF-8")
|
||||
val doc = Jsoup.parse(File(epubBook.extractionBasePath, chapter.contentFilePath()), "UTF-8")
|
||||
doc.body().text()
|
||||
} catch (_: Exception) { "" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,13 @@ import android.content.Context
|
|||
import com.aryan.reader.R
|
||||
import timber.log.Timber
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
import com.aryan.reader.epub.contentFilePath
|
||||
import com.aryan.reader.paginatedreader.LocatorConverter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.nodes.Node
|
||||
import java.io.File
|
||||
|
||||
data class ChapterLoadingResult(
|
||||
|
|
@ -34,9 +37,44 @@ data class ChapterLoadingResult(
|
|||
val chunks: List<String>,
|
||||
val startChunkIndex: Int,
|
||||
val isSuccess: Boolean,
|
||||
val errorMessage: String? = null
|
||||
val errorMessage: String? = null,
|
||||
val chunkElementStartIndices: List<Int> = emptyList(),
|
||||
val chunkElementCounts: List<Int> = emptyList()
|
||||
)
|
||||
|
||||
internal data class ReaderHtmlChunk(
|
||||
val html: String,
|
||||
val elementStartIndex: Int,
|
||||
val elementCount: Int
|
||||
)
|
||||
|
||||
internal fun splitBodyNodesIntoReaderChunks(
|
||||
bodyNodes: List<Node>,
|
||||
chunkSize: Int = 20
|
||||
): List<ReaderHtmlChunk> {
|
||||
var elementStartIndex = 0
|
||||
return bodyNodes.chunked(chunkSize).map { nodes ->
|
||||
val elementCount = nodes.count { it is Element }
|
||||
ReaderHtmlChunk(
|
||||
html = nodes.joinToString(separator = "\n") { it.outerHtml() },
|
||||
elementStartIndex = elementStartIndex,
|
||||
elementCount = elementCount
|
||||
).also {
|
||||
elementStartIndex += elementCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun readerChunkContainerAttributes(
|
||||
index: Int,
|
||||
chunkElementStartIndices: List<Int>,
|
||||
chunkElementCounts: List<Int>
|
||||
): String {
|
||||
val startIndex = chunkElementStartIndices.getOrElse(index) { index * 20 }
|
||||
val elementCount = chunkElementCounts.getOrElse(index) { 20 }
|
||||
return "data-chunk-index='$index' data-element-start-index='$startIndex' data-element-count='$elementCount'"
|
||||
}
|
||||
|
||||
/**
|
||||
* loads the chapter HTML, splits it into chunks, and calculates
|
||||
* the initial chunk to display based on navigation state (CFI, overrides, etc.).
|
||||
|
|
@ -56,24 +94,36 @@ suspend fun loadChapterContent(
|
|||
)
|
||||
|
||||
try {
|
||||
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
|
||||
val htmlFile = File(fullPath)
|
||||
val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath())
|
||||
|
||||
val (headContent, chunks) = if (htmlFile.exists()) {
|
||||
val (headContent, chunks, chunkElementStartIndices, chunkElementCounts) = if (htmlFile.exists()) {
|
||||
val doc = Jsoup.parse(htmlFile, "UTF-8")
|
||||
val head = doc.head().html()
|
||||
doc.select("script").remove()
|
||||
val bodyNodes = doc.body().childNodes().toList()
|
||||
val chunkedList = bodyNodes.chunked(20).map { chunkOfNodes ->
|
||||
chunkOfNodes.joinToString(separator = "\n") { it.outerHtml() }
|
||||
}
|
||||
if (chunkedList.isEmpty()) {
|
||||
head to listOf("<body><p>${context.getString(R.string.chapter_empty)}</p></body>")
|
||||
val htmlChunks = splitBodyNodesIntoReaderChunks(bodyNodes)
|
||||
if (htmlChunks.isEmpty()) {
|
||||
ChapterHtmlPayload(
|
||||
head = head,
|
||||
chunks = listOf("<body><p>${context.getString(R.string.chapter_empty)}</p></body>"),
|
||||
chunkElementStartIndices = listOf(0),
|
||||
chunkElementCounts = listOf(1)
|
||||
)
|
||||
} else {
|
||||
head to chunkedList
|
||||
ChapterHtmlPayload(
|
||||
head = head,
|
||||
chunks = htmlChunks.map { it.html },
|
||||
chunkElementStartIndices = htmlChunks.map { it.elementStartIndex },
|
||||
chunkElementCounts = htmlChunks.map { it.elementCount }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
"" to listOf("<h1>${context.getString(R.string.chapter_not_found)}</h1>")
|
||||
ChapterHtmlPayload(
|
||||
head = "",
|
||||
chunks = listOf("<h1>${context.getString(R.string.chapter_not_found)}</h1>"),
|
||||
chunkElementStartIndices = listOf(0),
|
||||
chunkElementCounts = listOf(1)
|
||||
)
|
||||
}
|
||||
|
||||
var targetChunk = 0
|
||||
|
|
@ -101,7 +151,9 @@ suspend fun loadChapterContent(
|
|||
head = headContent,
|
||||
chunks = chunks,
|
||||
startChunkIndex = targetChunk,
|
||||
isSuccess = true
|
||||
isSuccess = true,
|
||||
chunkElementStartIndices = chunkElementStartIndices,
|
||||
chunkElementCounts = chunkElementCounts
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -115,3 +167,10 @@ suspend fun loadChapterContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ChapterHtmlPayload(
|
||||
val head: String,
|
||||
val chunks: List<String>,
|
||||
val chunkElementStartIndices: List<Int>,
|
||||
val chunkElementCounts: List<Int>
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -19,9 +19,11 @@
|
|||
*/
|
||||
package com.aryan.reader.epubreader
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
|
|
@ -32,6 +34,7 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
|
|
@ -55,6 +58,7 @@ 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.Download
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
|
|
@ -66,12 +70,13 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
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
|
||||
|
|
@ -83,7 +88,9 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
|
@ -94,8 +101,10 @@ import com.aryan.reader.R
|
|||
import com.aryan.reader.RenderMode
|
||||
import com.aryan.reader.epub.EpubChapter
|
||||
import com.aryan.reader.epub.EpubTocEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
|
|
@ -207,6 +216,7 @@ fun EpubReaderDrawerSheet(
|
|||
chapters: List<EpubChapter>,
|
||||
tableOfContents: List<EpubTocEntry>,
|
||||
activeFragmentId: String?,
|
||||
readerImages: List<EpubReaderImageReference>,
|
||||
bookmarks: Set<Bookmark>,
|
||||
userHighlights: List<UserHighlight>,
|
||||
currentChapterIndex: Int,
|
||||
|
|
@ -214,6 +224,8 @@ fun EpubReaderDrawerSheet(
|
|||
renderMode: RenderMode,
|
||||
onNavigateToChapter: (Int) -> Unit,
|
||||
onNavigateToTocEntry: (EpubTocEntry) -> Unit,
|
||||
onNavigateToImage: (EpubReaderImageReference) -> Unit,
|
||||
onDownloadImage: (EpubReaderImageReference) -> Unit,
|
||||
onNavigateToBookmark: (Bookmark) -> Unit,
|
||||
onNavigateToHighlight: (UserHighlight) -> Unit,
|
||||
onDeleteBookmark: (Bookmark) -> Unit,
|
||||
|
|
@ -227,11 +239,15 @@ fun EpubReaderDrawerSheet(
|
|||
ModalDrawerSheet(
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
) {
|
||||
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 = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 0,
|
||||
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(0) } },
|
||||
|
|
@ -247,6 +263,11 @@ fun EpubReaderDrawerSheet(
|
|||
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(2) } },
|
||||
text = { Text(stringResource(R.string.tab_annotations)) }
|
||||
)
|
||||
Tab(
|
||||
selected = drawerPagerState.currentPage == 3,
|
||||
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(3) } },
|
||||
text = { Text(stringResource(R.string.tab_images)) }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
|
|
@ -282,6 +303,11 @@ fun EpubReaderDrawerSheet(
|
|||
onOpenPaletteManager = onOpenPaletteManager,
|
||||
onHighlightColorChange = onHighlightColorChange
|
||||
)
|
||||
3 -> ImagesList(
|
||||
readerImages = readerImages,
|
||||
onNavigateToImage = onNavigateToImage,
|
||||
onDownloadImage = onDownloadImage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -710,6 +736,145 @@ private fun BookmarksList(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImagesList(
|
||||
readerImages: List<EpubReaderImageReference>,
|
||||
onNavigateToImage: (EpubReaderImageReference) -> Unit,
|
||||
onDownloadImage: (EpubReaderImageReference) -> Unit
|
||||
) {
|
||||
if (readerImages.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_images_found),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(end = 4.dp),
|
||||
contentPadding = PaddingValues(vertical = 4.dp)
|
||||
) {
|
||||
items(
|
||||
items = readerImages,
|
||||
key = { it.id }
|
||||
) { image ->
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
EpubReaderImageThumbnail(
|
||||
image = image,
|
||||
modifier = Modifier.size(width = 72.dp, height = 56.dp)
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = image.displayTitle,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(
|
||||
text = image.chapterTitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val metadata = listOfNotNull(image.dimensionLabel, image.sourceName()).joinToString(" - ")
|
||||
if (metadata.isNotBlank()) {
|
||||
Text(
|
||||
text = metadata,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = { onDownloadImage(image) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = stringResource(R.string.content_desc_download_image)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable { onNavigateToImage(image) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
listState = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpubReaderImageThumbnail(
|
||||
image: EpubReaderImageReference,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var bitmap by remember(image.sourcePath) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(image.sourcePath) {
|
||||
bitmap = withContext(Dispatchers.IO) {
|
||||
if (image.sourcePath.startsWith("data:", ignoreCase = true)) {
|
||||
val bytes = image.readDownloadBytes()
|
||||
bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
|
||||
} else {
|
||||
BitmapFactory.decodeFile(image.sourcePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||
) {
|
||||
val currentBitmap = bitmap
|
||||
if (currentBitmap != null) {
|
||||
Image(
|
||||
bitmap = currentBitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = (image.index + 1).toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun HighlightsList(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
package com.aryan.reader.epubreader
|
||||
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
import com.aryan.reader.epub.EpubChapter
|
||||
import com.aryan.reader.paginatedreader.AndroidHtmlResourceResolver
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Base64
|
||||
|
||||
data class EpubReaderImageReference(
|
||||
val id: String,
|
||||
val index: Int,
|
||||
val sourcePath: String,
|
||||
val originalSource: String,
|
||||
val altText: String?,
|
||||
val chapterIndex: Int,
|
||||
val chapterTitle: String,
|
||||
val elementId: String?,
|
||||
val ordinalInChapter: Int,
|
||||
val chunkIndex: Int?,
|
||||
val intrinsicWidth: Int?,
|
||||
val intrinsicHeight: Int?
|
||||
) {
|
||||
val displayTitle: String
|
||||
get() = altText?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
|
||||
?: "Image ${index + 1}"
|
||||
|
||||
val dimensionLabel: String?
|
||||
get() {
|
||||
val width = intrinsicWidth?.takeIf { it > 0 }
|
||||
val height = intrinsicHeight?.takeIf { it > 0 }
|
||||
return if (width != null && height != null) "${width}x$height" else null
|
||||
}
|
||||
|
||||
fun sourceName(): String? {
|
||||
val source = originalSource.takeIf { it.isNotBlank() } ?: sourcePath
|
||||
if (source.startsWith("data:", ignoreCase = true)) return null
|
||||
return source
|
||||
.substringBefore('#')
|
||||
.substringBefore('?')
|
||||
.replace('\\', '/')
|
||||
.substringAfterLast('/')
|
||||
.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun suggestedDownloadFileName(): String {
|
||||
val extension = sourcePath.readerImageExtension()
|
||||
?: originalSource.readerImageExtension()
|
||||
?: "png"
|
||||
val base = altText?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
|
||||
?: "image-${index + 1}"
|
||||
val safeBase = base.sanitizedReaderImageFileBase().ifBlank { "image-${index + 1}" }
|
||||
return "$safeBase.$extension"
|
||||
}
|
||||
|
||||
fun mimeType(): String {
|
||||
val dataMime = readerDataUriMimeType(sourcePath)
|
||||
if (dataMime != null) return dataMime
|
||||
return when (sourcePath.readerImageExtension() ?: originalSource.readerImageExtension()) {
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"png" -> "image/png"
|
||||
"gif" -> "image/gif"
|
||||
"webp" -> "image/webp"
|
||||
"bmp" -> "image/bmp"
|
||||
"svg" -> "image/svg+xml"
|
||||
else -> "image/*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun EpubBook.readerImageReferencesForDrawer(): List<EpubReaderImageReference> {
|
||||
val references = mutableListOf<EpubReaderImageReference>()
|
||||
chapters.forEachIndexed { chapterIndex, chapter ->
|
||||
val html = chapter.readerImageHtml(extractionBasePath).takeIf { it.isNotBlank() }
|
||||
?: return@forEachIndexed
|
||||
val sourceOrdinalByKey = mutableMapOf<String, Int>()
|
||||
val document = Jsoup.parse(html, chapter.absPath)
|
||||
|
||||
document.select("img, image").forEach { element ->
|
||||
val originalSource = element.readerImageSource() ?: return@forEach
|
||||
val sourcePath = resolveReaderImageSource(chapter, extractionBasePath, originalSource)
|
||||
val sourceKey = sourcePath.readerImageLookupKey()
|
||||
val ordinal = sourceOrdinalByKey.getOrDefault(sourceKey, 0)
|
||||
sourceOrdinalByKey[sourceKey] = ordinal + 1
|
||||
val index = references.size
|
||||
|
||||
references += EpubReaderImageReference(
|
||||
id = "android-epub-image:$chapterIndex:$ordinal:${sourcePath.hashCode()}:$index",
|
||||
index = index,
|
||||
sourcePath = sourcePath,
|
||||
originalSource = originalSource,
|
||||
altText = element.attr("alt").ifBlank { element.attr("title") }.ifBlank { null },
|
||||
chapterIndex = chapterIndex,
|
||||
chapterTitle = chapter.title.ifBlank { "Chapter ${chapterIndex + 1}" },
|
||||
elementId = element.id().ifBlank { null },
|
||||
ordinalInChapter = ordinal,
|
||||
chunkIndex = element.readerTopLevelBodyChildIndex()?.let { it / 20 },
|
||||
intrinsicWidth = element.readerImageDimension("width"),
|
||||
intrinsicHeight = element.readerImageDimension("height")
|
||||
)
|
||||
}
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
fun EpubReaderImageReference.readDownloadBytes(): ByteArray? {
|
||||
if (sourcePath.startsWith("data:", ignoreCase = true)) {
|
||||
return sourcePath.readerDataUriBytes()
|
||||
}
|
||||
return runCatching {
|
||||
File(sourcePath).takeIf { it.isFile }?.readBytes()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun EpubChapter.readerImageHtml(extractionBasePath: String): String {
|
||||
if (htmlContent.isNotBlank()) return htmlContent
|
||||
return runCatching {
|
||||
File(extractionBasePath, htmlFilePath).takeIf { it.isFile }?.readText().orEmpty()
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
private fun Element.readerImageSource(): String? {
|
||||
return listOf("src", "href", "xlink:href", "data-src")
|
||||
.firstNotNullOfOrNull { attrName ->
|
||||
attr(attrName).trim().takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Element.readerImageDimension(attribute: String): Int? {
|
||||
val raw = attr(attribute).trim().takeIf { it.isNotBlank() } ?: return null
|
||||
return Regex("""\d+""").find(raw)?.value?.toIntOrNull()?.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
private fun Element.readerTopLevelBodyChildIndex(): Int? {
|
||||
val body = ownerDocument()?.body() ?: return null
|
||||
var topLevel: Element = this
|
||||
while (topLevel.parent() != null && topLevel.parent() != body) {
|
||||
topLevel = topLevel.parent() ?: break
|
||||
}
|
||||
if (topLevel.parent() != body) return null
|
||||
return body.childNodes().indexOf(topLevel).takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
private fun resolveReaderImageSource(
|
||||
chapter: EpubChapter,
|
||||
extractionBasePath: String,
|
||||
source: String
|
||||
): String {
|
||||
val withoutFragment = source.substringBefore('#').substringBefore('?')
|
||||
if (withoutFragment.startsWith("data:", ignoreCase = true)) return source
|
||||
|
||||
val fileUriPath = withoutFragment.readerFileUriPath()
|
||||
fileUriPath?.let { path ->
|
||||
val file = File(path)
|
||||
if (file.isFile) {
|
||||
return runCatching { file.canonicalFile.absolutePath }.getOrDefault(file.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
val sourceForResolve = fileUriPath ?: withoutFragment
|
||||
AndroidHtmlResourceResolver.resolvePath(chapter.absPath, extractionBasePath, sourceForResolve)?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val fallbackCandidates = listOf(
|
||||
File(extractionBasePath, sourceForResolve),
|
||||
File(extractionBasePath, sourceForResolve.trimStart('/', '\\'))
|
||||
)
|
||||
return fallbackCandidates
|
||||
.firstOrNull { it.isFile }
|
||||
?.let { runCatching { it.canonicalFile.absolutePath }.getOrDefault(it.absolutePath) }
|
||||
?: source
|
||||
}
|
||||
|
||||
private fun String.readerFileUriPath(): String? {
|
||||
if (!startsWith("file:", ignoreCase = true)) return null
|
||||
return runCatching {
|
||||
URI(this).path?.let { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun String.readerImageLookupKey(): String {
|
||||
return substringBefore('#')
|
||||
.substringBefore('?')
|
||||
.replace('\\', '/')
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
private fun String.readerImageExtension(): String? {
|
||||
readerDataUriMimeType(this)?.let { mime ->
|
||||
return when (mime.lowercase()) {
|
||||
"image/jpeg" -> "jpg"
|
||||
"image/png" -> "png"
|
||||
"image/gif" -> "gif"
|
||||
"image/webp" -> "webp"
|
||||
"image/bmp" -> "bmp"
|
||||
"image/svg+xml" -> "svg"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
return substringBefore('#')
|
||||
.substringBefore('?')
|
||||
.substringAfterLast('.', "")
|
||||
.lowercase()
|
||||
.takeIf { it in setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg") }
|
||||
}
|
||||
|
||||
private fun readerDataUriMimeType(source: String): String? {
|
||||
if (!source.startsWith("data:", ignoreCase = true)) return null
|
||||
return source
|
||||
.drop(5)
|
||||
.substringBefore(';')
|
||||
.substringBefore(',')
|
||||
.takeIf { it.startsWith("image/", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun String.readerDataUriBytes(): ByteArray? {
|
||||
val commaIndex = indexOf(',')
|
||||
if (!startsWith("data:", ignoreCase = true) || commaIndex == -1) return null
|
||||
val metadata = substring(0, commaIndex)
|
||||
val data = substring(commaIndex + 1)
|
||||
return runCatching {
|
||||
if (metadata.contains(";base64", ignoreCase = true)) {
|
||||
Base64.getDecoder().decode(data)
|
||||
} else {
|
||||
URLDecoder.decode(data, StandardCharsets.UTF_8.name()).toByteArray(StandardCharsets.UTF_8)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun String.sanitizedReaderImageFileBase(): String {
|
||||
return replace(Regex("""[\\/:*?"<>|]+"""), "_")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
.trim('.')
|
||||
.take(80)
|
||||
}
|
||||
|
|
@ -158,6 +158,9 @@ import com.aryan.reader.BuildConfig
|
|||
import com.aryan.reader.BuiltInThemes
|
||||
import com.aryan.reader.MainViewModel
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.ReaderBrightnessEffect
|
||||
import com.aryan.reader.ReaderFileInfoDialogs
|
||||
import com.aryan.reader.ReaderBrightnessSheet
|
||||
import com.aryan.reader.ReaderScreenOrientationEffect
|
||||
import com.aryan.reader.ReaderScreenOrientationSheet
|
||||
import com.aryan.reader.ReaderThemePanel
|
||||
|
|
@ -176,13 +179,17 @@ import com.aryan.reader.epub.hasReadableExtractedContent
|
|||
import com.aryan.reader.fetchAiDefinition
|
||||
import com.aryan.reader.loadCustomThemes
|
||||
import com.aryan.reader.loadGlobalTextureTransparency
|
||||
import com.aryan.reader.loadReaderBrightnessSettings
|
||||
import com.aryan.reader.loadReaderScreenOrientationMode
|
||||
import com.aryan.reader.loadEpubRightToLeftPagination
|
||||
import com.aryan.reader.loadReaderThemeId
|
||||
import com.aryan.reader.loadReaderSliderToggled
|
||||
import com.aryan.reader.loadReaderTextureBitmap
|
||||
import com.aryan.reader.loadTtsReplacementPreferences
|
||||
import com.aryan.reader.readerSliderBookmarkPosition
|
||||
import com.aryan.reader.readerSliderChromeColors
|
||||
import com.aryan.reader.readerSliderToggleState
|
||||
import com.aryan.reader.paginatedreader.BookPaginator
|
||||
import com.aryan.reader.paginatedreader.CfiUtils
|
||||
import com.aryan.reader.paginatedreader.HeaderBlock
|
||||
import com.aryan.reader.paginatedreader.IPaginator
|
||||
import com.aryan.reader.paginatedreader.ListItemBlock
|
||||
|
|
@ -198,10 +205,13 @@ import com.aryan.reader.paginatedreader.semanticBlockModule
|
|||
import com.aryan.reader.rememberSearchState
|
||||
import com.aryan.reader.saveCustomThemes
|
||||
import com.aryan.reader.saveGlobalTextureTransparency
|
||||
import com.aryan.reader.saveReaderBrightnessSettings
|
||||
import com.aryan.reader.saveReaderScreenOrientationMode
|
||||
import com.aryan.reader.saveEpubRightToLeftPagination
|
||||
import com.aryan.reader.saveReaderThemeId
|
||||
import com.aryan.reader.saveReaderSliderToggled
|
||||
import com.aryan.reader.saveTtsReplacementPreferences
|
||||
import com.aryan.reader.shouldRenderReaderSlider
|
||||
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
|
||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||
|
|
@ -210,6 +220,7 @@ import com.aryan.reader.tts.loadTtsMode
|
|||
import com.aryan.reader.tts.splitTextIntoChunks
|
||||
import com.aryan.reader.withTtsReplacements
|
||||
import com.aryan.reader.shared.reader.ReaderJumpHistory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
|
@ -217,6 +228,7 @@ import kotlinx.coroutines.flow.filter
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
|
|
@ -246,7 +258,7 @@ private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
|
|||
private const val TOOL_ORDER_KEY = "reader_tool_order"
|
||||
private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools"
|
||||
private const val HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "reader_hidden_tools_defaults_version"
|
||||
private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 1
|
||||
private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 2
|
||||
private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore"
|
||||
private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume"
|
||||
private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
|
||||
|
|
@ -264,6 +276,25 @@ private fun epubHighlightDiagSnippet(text: String, maxLength: Int = 80): String
|
|||
.take(maxLength)
|
||||
}
|
||||
|
||||
private fun List<TtsChunk>.withInitialChunkOverride(
|
||||
startChunkIndex: Int,
|
||||
initialChunk: TtsChunk?
|
||||
): List<TtsChunk> {
|
||||
if (initialChunk == null || startChunkIndex !in indices) return this
|
||||
val existing = this[startChunkIndex]
|
||||
if (
|
||||
existing.text == initialChunk.text &&
|
||||
existing.sourceCfi == initialChunk.sourceCfi &&
|
||||
existing.startOffsetInSource == initialChunk.startOffsetInSource
|
||||
) {
|
||||
return this
|
||||
}
|
||||
|
||||
return toMutableList().also { chunks ->
|
||||
chunks[startChunkIndex] = initialChunk
|
||||
}
|
||||
}
|
||||
|
||||
private fun View.bottomRoundedCornerRadiusPx(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
|
||||
|
||||
|
|
@ -313,7 +344,7 @@ private fun loadHiddenTools(context: Context): Set<String> {
|
|||
val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
|
||||
val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
||||
if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
||||
val migratedHiddenTools = savedHiddenTools + defaultReaderHiddenTools()
|
||||
val migratedHiddenTools = savedHiddenTools + readerHiddenToolsIntroducedAfter(defaultsVersion)
|
||||
prefs.edit {
|
||||
putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
||||
putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||
|
|
@ -323,6 +354,13 @@ private fun loadHiddenTools(context: Context): Set<String> {
|
|||
return savedHiddenTools
|
||||
}
|
||||
|
||||
private fun readerHiddenToolsIntroducedAfter(defaultsVersion: Int): Set<String> {
|
||||
return buildSet {
|
||||
if (defaultsVersion < 1) add(ReaderTool.SCREEN_ORIENTATION.name)
|
||||
if (defaultsVersion < 2) add(ReaderTool.BRIGHTNESS.name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToolOrder(context: Context, toolOrder: List<ReaderTool>) {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
|
||||
|
|
@ -644,9 +682,19 @@ fun EpubReaderHost(
|
|||
) {
|
||||
val view = LocalView.current
|
||||
val context = LocalContext.current
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val window = (view.context as? Activity)?.window
|
||||
val activity = context as? Activity
|
||||
val scope = rememberCoroutineScope()
|
||||
var readerBrightnessSettings by remember { mutableStateOf(loadReaderBrightnessSettings(context)) }
|
||||
var showBrightnessSheet by remember { mutableStateOf(false) }
|
||||
ReaderBrightnessEffect(window, readerBrightnessSettings)
|
||||
|
||||
val updateReaderBrightness: (com.aryan.reader.ReaderBrightnessSettings) -> Unit = { settings ->
|
||||
readerBrightnessSettings = settings
|
||||
saveReaderBrightnessSettings(context, settings)
|
||||
}
|
||||
|
||||
fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) {
|
||||
viewModel.showBanner(message, isError, isPersistent)
|
||||
}
|
||||
|
|
@ -663,8 +711,8 @@ fun EpubReaderHost(
|
|||
var isNavigatingToPosition by remember { mutableStateOf(false) }
|
||||
var isSeamlessTransitioning by remember { mutableStateOf(false) }
|
||||
var showInsufficientCreditsDialog by remember { mutableStateOf(false) }
|
||||
var showFileInfoDialog by remember { mutableStateOf(false) }
|
||||
|
||||
var isPageSliderVisible by remember { mutableStateOf(false) }
|
||||
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
|
||||
var isFastScrubbing by remember { mutableStateOf(false) }
|
||||
val scrubDebounceJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
|
@ -717,6 +765,11 @@ fun EpubReaderHost(
|
|||
val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) {
|
||||
stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
|
||||
}
|
||||
val bookId = readerCacheBookId
|
||||
|
||||
var isPageSliderVisible by remember(bookId) {
|
||||
mutableStateOf(loadReaderSliderToggled(context, bookId))
|
||||
}
|
||||
|
||||
val locatorConverter = remember(context, readerCacheBookId) {
|
||||
LocatorConverter(
|
||||
|
|
@ -749,7 +802,6 @@ fun EpubReaderHost(
|
|||
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
|
||||
var isTtsCollapsed by remember { mutableStateOf(false) }
|
||||
|
||||
val bookId = readerCacheBookId
|
||||
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
|
||||
|
||||
val initialSettings = remember(isAutoScrollLocal) {
|
||||
|
|
@ -1017,6 +1069,13 @@ fun EpubReaderHost(
|
|||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
var showBars by remember { mutableStateOf(false) }
|
||||
val chapters = remember(epubBook.chapters) { epubBook.chapters }
|
||||
var readerImages by remember(epubBook) { mutableStateOf<List<EpubReaderImageReference>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(epubBook) {
|
||||
readerImages = withContext(Dispatchers.IO) {
|
||||
epubBook.readerImageReferencesForDrawer()
|
||||
}
|
||||
}
|
||||
|
||||
var currentChapterIndex by rememberSaveable(epubBook.title) {
|
||||
mutableIntStateOf(
|
||||
|
|
@ -1048,11 +1107,14 @@ fun EpubReaderHost(
|
|||
var loadUpToChunkIndex by remember(currentChapterIndex) { mutableIntStateOf(0) }
|
||||
|
||||
var chapterChunks by remember(currentChapterIndex) { mutableStateOf<List<String>>(emptyList()) }
|
||||
var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
|
||||
var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
|
||||
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
|
||||
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
|
||||
|
||||
var cfiToLoad by remember { mutableStateOf(initialCfi) }
|
||||
var fragmentToLoad by remember { mutableStateOf<String?>(null) }
|
||||
var imageToLoad by remember { mutableStateOf<EpubReaderImageReference?>(null) }
|
||||
var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) }
|
||||
var bookmarkPageMap by remember { mutableStateOf<Map<String, Int>>(emptyMap()) }
|
||||
|
||||
|
|
@ -1170,13 +1232,6 @@ fun EpubReaderHost(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isPageSliderVisible) {
|
||||
if (!isPageSliderVisible) {
|
||||
startPageThumbnail?.recycle()
|
||||
startPageThumbnail = null
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(ttsState.errorMessage) {
|
||||
ttsState.errorMessage?.let { message ->
|
||||
if (message == "INSUFFICIENT_CREDITS") {
|
||||
|
|
@ -1189,6 +1244,12 @@ fun EpubReaderHost(
|
|||
}
|
||||
|
||||
val searchState = rememberSearchState(scope = scope, searcher = epubSearcher)
|
||||
val isEpubSliderReady = currentRenderMode == RenderMode.VERTICAL_SCROLL || paginatedPagerState.pageCount > 0
|
||||
val epubSliderChromeVisible = shouldRenderReaderSlider(
|
||||
isToggledOn = isPageSliderVisible,
|
||||
isBottomChromeVisible = showBars,
|
||||
isSearchActive = searchState.isSearchActive
|
||||
) && isEpubSliderReady
|
||||
val speakerPlayer = remember(context, scope) {
|
||||
SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() })
|
||||
}
|
||||
|
|
@ -1282,6 +1343,11 @@ fun EpubReaderHost(
|
|||
if (systemIsDark) Color(0xFFE0E0E0) else Color(0xFF000000)
|
||||
} else activeTheme.textColor
|
||||
}
|
||||
val epubReaderSliderColors = readerSliderChromeColors(
|
||||
pageBackground = effectiveBg,
|
||||
pageText = effectiveText,
|
||||
themePrimary = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
val activeTextureId = activeTheme.textureId
|
||||
val activeTextureAlpha = 1f - globalTextureTransparency
|
||||
val activeTextureBitmap = remember(activeTextureId) {
|
||||
|
|
@ -1326,7 +1392,8 @@ fun EpubReaderHost(
|
|||
!ttsState.currentWordSourceCfi.isNullOrBlank() ||
|
||||
!ttsState.sourceCfi.isNullOrBlank() ||
|
||||
!ttsState.currentText.isNullOrBlank()
|
||||
val isSameBook = ttsState.bookTitle == null || ttsState.bookTitle == epubBook.title
|
||||
val isSameBook = ttsState.bookId?.let { it == bookId }
|
||||
?: (ttsState.bookTitle == null || ttsState.bookTitle == epubBook.title)
|
||||
return isReaderSession && hasReaderSessionState && isSameBook
|
||||
}
|
||||
|
||||
|
|
@ -1372,6 +1439,7 @@ fun EpubReaderHost(
|
|||
chunkTargetOverride = null
|
||||
cfiToLoad = null
|
||||
fragmentToLoad = null
|
||||
imageToLoad = null
|
||||
isNavigatingToPosition = false
|
||||
suppressNextVerticalTtsDetach = false
|
||||
}
|
||||
|
|
@ -1570,22 +1638,31 @@ fun EpubReaderHost(
|
|||
val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0
|
||||
val pageInChapter = currentPage - chapterStartPage
|
||||
|
||||
val ttsChunks = bookPaginator.getTtsChunksForChapter(
|
||||
chapterIndex = chapterIndex,
|
||||
startingFromPageInChapter = pageInChapter
|
||||
)
|
||||
val allTtsChunks = bookPaginator.getTtsChunksForChapter(chapterIndex)
|
||||
val firstChunkOnPage = if (pageInChapter > 0) {
|
||||
bookPaginator.getTtsChunksForChapter(
|
||||
chapterIndex = chapterIndex,
|
||||
startingFromPageInChapter = pageInChapter
|
||||
)?.firstOrNull()
|
||||
} else {
|
||||
allTtsChunks?.firstOrNull()
|
||||
}
|
||||
val startChunkIndex = findTtsChunkStartIndex(allTtsChunks.orEmpty(), firstChunkOnPage) ?: 0
|
||||
|
||||
if (!ttsChunks.isNullOrEmpty()) {
|
||||
if (!allTtsChunks.isNullOrEmpty() && firstChunkOnPage != null) {
|
||||
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
|
||||
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
|
||||
ttsChapterIndex = chapterIndex
|
||||
ttsController.start(
|
||||
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
|
||||
chunks = allTtsChunks.withInitialChunkOverride(startChunkIndex, firstChunkOnPage)
|
||||
.withTtsReplacements(ttsReplacementPreferences, bookId),
|
||||
bookTitle = epubBook.title,
|
||||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
bookId = bookId,
|
||||
chapterIndex = chapterIndex,
|
||||
totalChapters = chapters.size,
|
||||
startChunkIndex = startChunkIndex,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -1597,6 +1674,33 @@ fun EpubReaderHost(
|
|||
)
|
||||
}
|
||||
|
||||
var pendingImageDownload by remember { mutableStateOf<EpubReaderImageReference?>(null) }
|
||||
val imageSaveLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("image/*"),
|
||||
onResult = { uri ->
|
||||
val image = pendingImageDownload
|
||||
pendingImageDownload = null
|
||||
if (uri != null && image != null) {
|
||||
scope.launch {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val bytes = image.readDownloadBytes() ?: error("Image bytes are unavailable")
|
||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
output.write(bytes)
|
||||
} ?: error("Could not open image destination")
|
||||
}.isSuccess
|
||||
}
|
||||
val message = if (saved) {
|
||||
context.getString(R.string.saved_image_message, image.suggestedDownloadFileName())
|
||||
} else {
|
||||
context.getString(R.string.error_save_image)
|
||||
}
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
onResult = { _ ->
|
||||
|
|
@ -1616,17 +1720,14 @@ fun EpubReaderHost(
|
|||
val bookPaginator = paginator as? BookPaginator
|
||||
val chapterIndex = currentChapterInPaginatedMode ?: return@launch
|
||||
val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch
|
||||
|
||||
var foundIdx = -1
|
||||
for (i in chunks.indices) {
|
||||
val c = chunks[i]
|
||||
val cPath = CfiUtils.getPath(c.sourceCfi)
|
||||
val bPath = CfiUtils.getPath(baseCfi)
|
||||
if (cPath == bPath && startOffset >= c.startOffsetInSource && startOffset < c.startOffsetInSource + c.text.length) {
|
||||
foundIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
val foundIdx = findTtsChunkStartIndex(
|
||||
chunks = chunks,
|
||||
target = TtsChunk(
|
||||
text = "",
|
||||
sourceCfi = baseCfi,
|
||||
startOffsetInSource = startOffset
|
||||
)
|
||||
) ?: -1
|
||||
|
||||
if (foundIdx != -1) {
|
||||
val target = chunks[foundIdx]
|
||||
|
|
@ -1639,21 +1740,24 @@ fun EpubReaderHost(
|
|||
spokenText = slicedText,
|
||||
)
|
||||
|
||||
val remainingChunks = mutableListOf(newChunk)
|
||||
remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size))
|
||||
val sessionChunks = chunks.toMutableList().also {
|
||||
it[foundIdx] = newChunk
|
||||
}
|
||||
|
||||
if (remainingChunks.isNotEmpty()) {
|
||||
if (sessionChunks.isNotEmpty()) {
|
||||
ttsShouldStartOnChapterLoad = false
|
||||
ttsChapterIndex = chapterIndex
|
||||
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
|
||||
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
|
||||
ttsController.start(
|
||||
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
|
||||
chunks = sessionChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
|
||||
bookTitle = epubBook.title,
|
||||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
bookId = bookId,
|
||||
chapterIndex = chapterIndex,
|
||||
totalChapters = chapters.size,
|
||||
startChunkIndex = foundIdx,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -1770,6 +1874,63 @@ fun EpubReaderHost(
|
|||
}
|
||||
}
|
||||
|
||||
fun currentEpubSliderPage(): Int {
|
||||
return when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> currentPageInChapter
|
||||
RenderMode.PAGINATED -> (paginatedPagerState.currentPage + 1).coerceAtLeast(1)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetEpubSliderBookmark() {
|
||||
val position = readerSliderBookmarkPosition(currentEpubSliderPage())
|
||||
sliderStartPage = position.startPage
|
||||
sliderCurrentPage = position.currentPage
|
||||
}
|
||||
|
||||
LaunchedEffect(bookId, isPageSliderVisible) {
|
||||
saveReaderSliderToggled(context, bookId, isPageSliderVisible)
|
||||
if (isPageSliderVisible) {
|
||||
resetEpubSliderBookmark()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleEpubPageSlider() {
|
||||
if (!isPageSliderVisible && currentRenderMode == RenderMode.PAGINATED && paginatedPagerState.pageCount <= 0) {
|
||||
showBanner("Book is not paginated yet.")
|
||||
return
|
||||
}
|
||||
|
||||
val nextState = readerSliderToggleState(
|
||||
isCurrentlyToggledOn = isPageSliderVisible,
|
||||
currentPage = currentEpubSliderPage()
|
||||
)
|
||||
sliderStartPage = nextState.bookmarkPosition.startPage
|
||||
sliderCurrentPage = nextState.bookmarkPosition.currentPage
|
||||
isPageSliderVisible = nextState.isToggledOn
|
||||
showBars = true
|
||||
if (nextState.isToggledOn) {
|
||||
showFormatAdjustmentBars = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isPageSliderVisible, epubSliderChromeVisible, currentRenderMode, currentPageInChapter, paginatedPagerState.currentPage) {
|
||||
if (isPageSliderVisible && !epubSliderChromeVisible) {
|
||||
resetEpubSliderBookmark()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(epubSliderChromeVisible, currentRenderMode, sliderStartPage, webViewRefForTts) {
|
||||
if (epubSliderChromeVisible && currentRenderMode == RenderMode.VERTICAL_SCROLL) {
|
||||
startPageThumbnail?.recycle()
|
||||
startPageThumbnail = webViewRefForTts?.let { webView ->
|
||||
captureWebViewVisibleArea(webView)
|
||||
}
|
||||
} else if (!epubSliderChromeVisible || currentRenderMode == RenderMode.PAGINATED) {
|
||||
startPageThumbnail?.recycle()
|
||||
startPageThumbnail = null
|
||||
}
|
||||
}
|
||||
|
||||
val latestChapterIndex by rememberUpdatedState(currentChapterIndex)
|
||||
|
||||
LaunchedEffect(ttsState.bookTitle, ttsState.chapterIndex, ttsState.sourceCfi, ttsState.playbackSource) {
|
||||
|
|
@ -1952,6 +2113,8 @@ fun EpubReaderHost(
|
|||
webViewRefForTts = null
|
||||
chapterHead = ""
|
||||
chapterChunks = emptyList()
|
||||
chapterChunkElementStartIndices = emptyList()
|
||||
chapterChunkElementCounts = emptyList()
|
||||
startPageThumbnail?.recycle()
|
||||
startPageThumbnail = null
|
||||
autoScrollResumeJob.value?.cancel()
|
||||
|
|
@ -2029,6 +2192,8 @@ fun EpubReaderHost(
|
|||
|
||||
chapterHead = result.head
|
||||
chapterChunks = result.chunks
|
||||
chapterChunkElementStartIndices = result.chunkElementStartIndices
|
||||
chapterChunkElementCounts = result.chunkElementCounts
|
||||
isChapterParsing = false
|
||||
|
||||
if (initialScrollTargetForChapter == ChapterScrollPosition.END) {
|
||||
|
|
@ -2505,6 +2670,42 @@ fun EpubReaderHost(
|
|||
}
|
||||
}
|
||||
|
||||
fun scrollCurrentVerticalChapterToImage(image: EpubReaderImageReference) {
|
||||
val targetChunk = image.chunkIndex
|
||||
if (targetChunk != null && targetChunk >= 0) {
|
||||
injectVerticalChunksThrough(targetChunk)
|
||||
}
|
||||
val escapedSource = escapeJsString(image.sourcePath)
|
||||
val escapedOriginalSource = escapeJsString(image.originalSource)
|
||||
webViewRefForTts?.evaluateJavascript(
|
||||
"javascript:window.scrollToReaderImageSource('$escapedSource', ${image.ordinalInChapter}, '$escapedOriginalSource');",
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
fun navigateVerticalToImage(image: EpubReaderImageReference) {
|
||||
scope.launch {
|
||||
recordEpubJump(chapterStartJumpLocator(image.chapterIndex))
|
||||
clearPendingTtsRelocationState("sidebar_image_vertical")
|
||||
imageToLoad = image
|
||||
cfiToLoad = null
|
||||
fragmentToLoad = null
|
||||
initialScrollTargetForChapter = null
|
||||
if (image.chapterIndex != currentChapterIndex) {
|
||||
chunkTargetOverride = image.chunkIndex?.coerceAtLeast(0)
|
||||
Timber.tag(TAG_LINK_NAV)
|
||||
.d("[CHAPTER-NAV] source=SIDEBAR_IMAGE, from=$currentChapterIndex, to=${image.chapterIndex}, image='${image.sourceName()}'")
|
||||
currentScrollYPosition = 0
|
||||
currentScrollHeightValue = 0
|
||||
currentChapterIndex = image.chapterIndex
|
||||
} else {
|
||||
chunkTargetOverride = null
|
||||
scrollCurrentVerticalChapterToImage(image)
|
||||
imageToLoad = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateVerticalToCfi(chapterIndex: Int, cfi: String) {
|
||||
scope.launch {
|
||||
val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi)
|
||||
|
|
@ -2766,10 +2967,7 @@ fun EpubReaderHost(
|
|||
}
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
if (isPageSliderVisible) {
|
||||
isPageSliderVisible = false
|
||||
showBars = true
|
||||
} else if (drawerState.isOpen) {
|
||||
if (drawerState.isOpen) {
|
||||
scope.launch {
|
||||
Timber.d("Back pressed: Closing drawer")
|
||||
drawerState.close()
|
||||
|
|
@ -2795,6 +2993,7 @@ fun EpubReaderHost(
|
|||
chapters = chapters,
|
||||
tableOfContents = epubBook.tableOfContents,
|
||||
activeFragmentId = activeFragmentId,
|
||||
readerImages = readerImages,
|
||||
bookmarks = bookmarks,
|
||||
userHighlights = userHighlights,
|
||||
currentChapterIndex = currentChapterIndex,
|
||||
|
|
@ -2803,6 +3002,56 @@ fun EpubReaderHost(
|
|||
activeHighlightPalette = currentHighlightPalette,
|
||||
onOpenPaletteManager = { showPaletteManager = true },
|
||||
onHighlightColorChange = onHighlightColorChange,
|
||||
onNavigateToImage = { image ->
|
||||
scope.launch {
|
||||
drawerState.close()
|
||||
when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> {
|
||||
navigateVerticalToImage(image)
|
||||
}
|
||||
RenderMode.PAGINATED -> {
|
||||
val bookPaginator = paginator as? BookPaginator
|
||||
if (bookPaginator != null) {
|
||||
isNavigatingByToc = true
|
||||
try {
|
||||
val imagePage = bookPaginator.findStablePageForImageSource(
|
||||
chapterIndex = image.chapterIndex,
|
||||
sourcePath = image.sourcePath,
|
||||
elementId = image.elementId,
|
||||
ordinalInChapter = image.ordinalInChapter
|
||||
)
|
||||
if (imagePage != null) {
|
||||
val (pageIndex, locator) = imagePage
|
||||
paginatedJumpLocatorForPage(
|
||||
pageIndex = pageIndex,
|
||||
targetLocator = locator,
|
||||
allowPageFallback = true
|
||||
)?.let { recordEpubJump(it) }
|
||||
scrollPaginatedToJumpPage(pageIndex, locator)
|
||||
} else {
|
||||
val fallbackPage = bookPaginator.findStableChapterStartPage(image.chapterIndex)
|
||||
if (fallbackPage != null) {
|
||||
recordEpubJump(chapterStartJumpLocator(image.chapterIndex).copy(pageIndex = fallbackPage))
|
||||
scrollPaginatedToJumpPage(
|
||||
fallbackPage,
|
||||
Locator(image.chapterIndex, 0, 0),
|
||||
fallbackToChapterStart = true
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isNavigatingByToc = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showBars) showBars = false
|
||||
}
|
||||
},
|
||||
onDownloadImage = { image ->
|
||||
pendingImageDownload = image
|
||||
imageSaveLauncher.launch(image.suggestedDownloadFileName())
|
||||
},
|
||||
onNavigateToTocEntry = { entry ->
|
||||
scope.launch {
|
||||
drawerState.close()
|
||||
|
|
@ -3434,6 +3683,10 @@ fun EpubReaderHost(
|
|||
currentTopPadding
|
||||
}
|
||||
|
||||
val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel()
|
||||
val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel()
|
||||
val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -3545,16 +3798,26 @@ fun EpubReaderHost(
|
|||
} else if (chapterChunks.isNotEmpty()) {
|
||||
var hasRequestedExtractionForThisChapter by remember(targetChapterIndex) { mutableStateOf(false) }
|
||||
|
||||
val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) {
|
||||
val initialContentToLoad = remember(
|
||||
loadUpToChunkIndex,
|
||||
chapterChunks,
|
||||
chapterChunkElementStartIndices,
|
||||
chapterChunkElementCounts
|
||||
) {
|
||||
val targetIdx = loadUpToChunkIndex
|
||||
val startIdx = 0
|
||||
val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1)
|
||||
|
||||
chapterChunks.indices.joinToString(separator = "\n") { index ->
|
||||
val attributes = readerChunkContainerAttributes(
|
||||
index,
|
||||
chapterChunkElementStartIndices,
|
||||
chapterChunkElementCounts
|
||||
)
|
||||
if (index in startIdx..endIdx) {
|
||||
"<div class='chunk-container' data-chunk-index='$index'>${chapterChunks[index]}</div>"
|
||||
"<div class='chunk-container' $attributes>${chapterChunks[index]}</div>"
|
||||
} else {
|
||||
"<div class='chunk-container' data-chunk-index='$index'></div>"
|
||||
"<div class='chunk-container' $attributes></div>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3651,6 +3914,9 @@ fun EpubReaderHost(
|
|||
initialPageScrollY = currentScrollYPosition,
|
||||
initialCfi = cfiToLoad,
|
||||
initialFragmentId = fragmentToLoad.also { },
|
||||
initialImageSource = imageToLoad?.sourcePath,
|
||||
initialImageOriginalSource = imageToLoad?.originalSource,
|
||||
initialImageOrdinal = imageToLoad?.ordinalInChapter ?: 0,
|
||||
userHighlights = userHighlights.filter { it.chapterIndex == targetChapterIndex },
|
||||
activeHighlightPalette = currentHighlightPalette,
|
||||
onUpdatePalette = onUpdateHighlightPalette,
|
||||
|
|
@ -3693,12 +3959,14 @@ fun EpubReaderHost(
|
|||
)
|
||||
} else {
|
||||
val wasCfiScroll = cfiToLoad != null
|
||||
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
|
||||
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll")
|
||||
val wasImageScroll = imageToLoad != null
|
||||
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll, Was image scroll: $wasImageScroll")
|
||||
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll wasImageScroll=$wasImageScroll")
|
||||
initialScrollTargetForChapter = null
|
||||
cfiToLoad = null
|
||||
fragmentToLoad = null
|
||||
Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll")
|
||||
imageToLoad = null
|
||||
Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll, Was image scroll: $wasImageScroll")
|
||||
isWebViewReady = true
|
||||
|
||||
if (wasCfiScroll) {
|
||||
|
|
@ -4154,14 +4422,27 @@ fun EpubReaderHost(
|
|||
Uri.fromFile(File(it)).toString()
|
||||
}
|
||||
ttsChapterIndex = targetChapterIndex
|
||||
val nativeChapterChunks = locatorConverter
|
||||
.getTtsChunksForChapter(epubBook, targetChapterIndex, bookId)
|
||||
.orEmpty()
|
||||
val extractedStartChunk = ttsChunks.firstOrNull()
|
||||
val nativeStartChunkIndex = findTtsChunkStartIndex(nativeChapterChunks, extractedStartChunk)
|
||||
val sessionChunks = if (nativeChapterChunks.isNotEmpty() && nativeStartChunkIndex != null) {
|
||||
nativeChapterChunks.withInitialChunkOverride(nativeStartChunkIndex, extractedStartChunk)
|
||||
} else {
|
||||
ttsChunks
|
||||
}
|
||||
val startChunkIndex = nativeStartChunkIndex ?: 0
|
||||
|
||||
ttsController.start(
|
||||
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
|
||||
chunks = sessionChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
|
||||
bookTitle = epubBook.title,
|
||||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
bookId = bookId,
|
||||
chapterIndex = targetChapterIndex,
|
||||
totalChapters = chapters.size,
|
||||
startChunkIndex = startChunkIndex,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
|
|
@ -5216,6 +5497,7 @@ fun EpubReaderHost(
|
|||
currentRenderMode = currentRenderMode,
|
||||
isBookmarked = isBookmarked,
|
||||
isTtsActive = isTtsSessionActive,
|
||||
isSliderActive = isPageSliderVisible,
|
||||
tapToNavigateEnabled = tapToNavigateEnabled,
|
||||
volumeScrollEnabled = volumeScrollEnabled,
|
||||
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
||||
|
|
@ -5307,35 +5589,11 @@ fun EpubReaderHost(
|
|||
onOpenTtsReplacements = { showTtsReplacementsSheet = true },
|
||||
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||
onOpenThemeSettings = { showThemePanel = true },
|
||||
onOpenBrightness = { showBrightnessSheet = true },
|
||||
onOpenVisualOptions = { showVisualOptionsSheet = true },
|
||||
onOpenScreenOrientation = { showScreenOrientationSheet = true },
|
||||
onOpenAiHub = { showAiHubSheet = true },
|
||||
onOpenSlider = {
|
||||
when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> {
|
||||
sliderStartPage = currentPageInChapter
|
||||
sliderCurrentPage = currentPageInChapter.toFloat()
|
||||
isPageSliderVisible = true
|
||||
showBars = false
|
||||
scope.launch {
|
||||
webViewRefForTts?.let { webView ->
|
||||
startPageThumbnail = captureWebViewVisibleArea(webView)
|
||||
}
|
||||
}
|
||||
}
|
||||
RenderMode.PAGINATED -> {
|
||||
if (paginatedPagerState.pageCount > 0) {
|
||||
sliderStartPage = paginatedPagerState.currentPage + 1
|
||||
sliderCurrentPage = (paginatedPagerState.currentPage + 1).toFloat()
|
||||
isPageSliderVisible = true
|
||||
showBars = false
|
||||
startPageThumbnail = null
|
||||
} else {
|
||||
showBanner("Book is not paginated yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenSlider = ::toggleEpubPageSlider,
|
||||
onOpenDrawer = {
|
||||
scope.launch { drawerState.open() }
|
||||
},
|
||||
|
|
@ -5343,6 +5601,7 @@ fun EpubReaderHost(
|
|||
showFormatAdjustmentBars = !showFormatAdjustmentBars
|
||||
if (showFormatAdjustmentBars) {
|
||||
searchState.showSearchResultsPanel = false
|
||||
resetEpubSliderBookmark()
|
||||
isPageSliderVisible = false
|
||||
}
|
||||
},
|
||||
|
|
@ -5374,6 +5633,7 @@ fun EpubReaderHost(
|
|||
}
|
||||
}
|
||||
},
|
||||
onOpenFileInfo = { showFileInfoDialog = true },
|
||||
onToggleReflow = if (onToggleReflow != null) {
|
||||
{
|
||||
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
|
||||
|
|
@ -5537,8 +5797,8 @@ fun EpubReaderHost(
|
|||
.padding(bottom = bottomPadding + 45.dp),
|
||||
showStandardBars = showBars,
|
||||
searchStateActive = searchState.isSearchActive,
|
||||
backLabel = epubJumpHistory.backLocator?.epubJumpLabel(),
|
||||
forwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel(),
|
||||
backLabel = epubJumpBackLabel,
|
||||
forwardLabel = epubJumpForwardLabel,
|
||||
onBack = ::goBackInEpubJumpHistory,
|
||||
onForward = ::goForwardInEpubJumpHistory,
|
||||
onClear = { epubJumpHistory = epubJumpHistory.clear() }
|
||||
|
|
@ -5555,35 +5815,12 @@ fun EpubReaderHost(
|
|||
toolOrder = toolOrder,
|
||||
bottomTools = bottomTools,
|
||||
currentTtsMode = currentTtsMode,
|
||||
isSliderActive = isPageSliderVisible,
|
||||
onOpenAiHub = { showAiHubSheet = true },
|
||||
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||
onOpenThemeSettings = { showThemePanel = true },
|
||||
onOpenSlider = {
|
||||
when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> {
|
||||
sliderStartPage = currentPageInChapter
|
||||
sliderCurrentPage = currentPageInChapter.toFloat()
|
||||
isPageSliderVisible = true
|
||||
showBars = false
|
||||
scope.launch {
|
||||
webViewRefForTts?.let { webView ->
|
||||
startPageThumbnail = captureWebViewVisibleArea(webView)
|
||||
}
|
||||
}
|
||||
}
|
||||
RenderMode.PAGINATED -> {
|
||||
if (paginatedPagerState.pageCount > 0) {
|
||||
sliderStartPage = paginatedPagerState.currentPage + 1
|
||||
sliderCurrentPage = (paginatedPagerState.currentPage + 1).toFloat()
|
||||
isPageSliderVisible = true
|
||||
showBars = false
|
||||
startPageThumbnail = null
|
||||
} else {
|
||||
showBanner("Book is not paginated yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenBrightness = { showBrightnessSheet = true },
|
||||
onOpenSlider = ::toggleEpubPageSlider,
|
||||
onOpenDrawer = {
|
||||
scope.launch { drawerState.open() }
|
||||
},
|
||||
|
|
@ -5592,6 +5829,7 @@ fun EpubReaderHost(
|
|||
showFormatAdjustmentBars = !showFormatAdjustmentBars
|
||||
if (showFormatAdjustmentBars) {
|
||||
searchState.showSearchResultsPanel = false
|
||||
resetEpubSliderBookmark()
|
||||
isPageSliderVisible = false
|
||||
}
|
||||
},
|
||||
|
|
@ -5880,69 +6118,62 @@ fun EpubReaderHost(
|
|||
onDismiss = { activeFootnoteHtml = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EpubReaderPageSlider(
|
||||
isVisible = isPageSliderVisible,
|
||||
currentRenderMode = currentRenderMode,
|
||||
totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount,
|
||||
sliderCurrentPage = sliderCurrentPage,
|
||||
sliderStartPage = sliderStartPage,
|
||||
startPageThumbnail = startPageThumbnail,
|
||||
paginator = paginator,
|
||||
chapters = chapters,
|
||||
onClose = {
|
||||
isPageSliderVisible = false
|
||||
showBars = true
|
||||
},
|
||||
onScrub = { newValue ->
|
||||
sliderCurrentPage = newValue
|
||||
isFastScrubbing = true
|
||||
scrubDebounceJob.value?.cancel()
|
||||
scrubDebounceJob.value = scope.launch {
|
||||
delay(200)
|
||||
if (isActive) {
|
||||
val targetPage = newValue.roundToInt()
|
||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
|
||||
val scrollY = (targetPage - 1) * currentClientHeightValue
|
||||
webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null)
|
||||
} else {
|
||||
paginatedPagerState.scrollToPage(targetPage - 1)
|
||||
EpubReaderPageSlider(
|
||||
isVisible = epubSliderChromeVisible,
|
||||
currentRenderMode = currentRenderMode,
|
||||
totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount,
|
||||
sliderCurrentPage = sliderCurrentPage,
|
||||
sliderStartPage = sliderStartPage,
|
||||
startPageThumbnail = startPageThumbnail,
|
||||
paginator = paginator,
|
||||
chapters = chapters,
|
||||
onScrub = { newValue ->
|
||||
sliderCurrentPage = newValue
|
||||
isFastScrubbing = true
|
||||
scrubDebounceJob.value?.cancel()
|
||||
scrubDebounceJob.value = scope.launch {
|
||||
delay(200)
|
||||
if (isActive) {
|
||||
val targetPage = newValue.roundToInt()
|
||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
|
||||
val scrollY = (targetPage - 1) * currentClientHeightValue
|
||||
webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null)
|
||||
} else {
|
||||
paginatedPagerState.scrollToPage(targetPage - 1)
|
||||
}
|
||||
isFastScrubbing = false
|
||||
}
|
||||
}
|
||||
isFastScrubbing = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onJumpToPage = { page ->
|
||||
scope.launch {
|
||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
|
||||
sliderCurrentPage = page.toFloat()
|
||||
val scrollY = (page - 1) * currentClientHeightValue
|
||||
recordEpubJump(
|
||||
SharedReaderLocator(
|
||||
chapterIndex = currentChapterIndex,
|
||||
cfi = "android-scroll:$scrollY"
|
||||
)
|
||||
)
|
||||
webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null)
|
||||
} else {
|
||||
sliderCurrentPage = page.toFloat()
|
||||
val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(page - 1)
|
||||
paginatedJumpLocatorForPage(
|
||||
pageIndex = page - 1,
|
||||
targetLocator = targetLocator,
|
||||
allowPageFallback = true
|
||||
)?.let { recordEpubJump(it) }
|
||||
scrollPaginatedToJumpPage(page - 1, targetLocator)
|
||||
}
|
||||
},
|
||||
onJumpToPage = { page ->
|
||||
scope.launch {
|
||||
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
|
||||
sliderCurrentPage = page.toFloat()
|
||||
val scrollY = (page - 1) * currentClientHeightValue
|
||||
webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null)
|
||||
} else {
|
||||
sliderCurrentPage = page.toFloat()
|
||||
val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(page - 1)
|
||||
scrollPaginatedToJumpPage(page - 1, targetLocator)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = bottomPadding + 45.dp + if (isEpubJumpHistoryVisible) 40.dp else 0.dp),
|
||||
activeColor = epubReaderSliderColors.activeTrackColor,
|
||||
inactiveColor = epubReaderSliderColors.inactiveTrackColor,
|
||||
contentColor = epubReaderSliderColors.contentColor,
|
||||
thumbnailSurfaceColor = epubReaderSliderColors.thumbnailSurfaceColor,
|
||||
thumbnailContentColor = epubReaderSliderColors.thumbnailContentColor
|
||||
)
|
||||
|
||||
if (epubSliderChromeVisible && isFastScrubbing) {
|
||||
val total = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount
|
||||
PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (isPageSliderVisible && isFastScrubbing) {
|
||||
val total = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount
|
||||
PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total)
|
||||
}
|
||||
|
||||
if (showTtsSettingsSheet) {
|
||||
|
|
@ -5974,6 +6205,15 @@ fun EpubReaderHost(
|
|||
onDismiss = { showTtsReplacementsSheet = false },
|
||||
)
|
||||
|
||||
ReaderFileInfoDialogs(
|
||||
isFileInfoVisible = showFileInfoDialog,
|
||||
onFileInfoVisibleChange = { showFileInfoDialog = it },
|
||||
uiState = uiState,
|
||||
primaryBookId = uiState.selectedBookId ?: stableBookId,
|
||||
uriString = uiState.selectedEpubUri?.toString(),
|
||||
viewModel = viewModel
|
||||
)
|
||||
|
||||
if (showCustomizeToolsSheet) {
|
||||
CustomizeToolsSheet(
|
||||
hiddenTools = hiddenTools,
|
||||
|
|
@ -5995,6 +6235,14 @@ fun EpubReaderHost(
|
|||
)
|
||||
}
|
||||
|
||||
if (showBrightnessSheet) {
|
||||
ReaderBrightnessSheet(
|
||||
settings = readerBrightnessSettings,
|
||||
onSettingsChange = updateReaderBrightness,
|
||||
onDismiss = { showBrightnessSheet = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showDictionarySettingsSheet) {
|
||||
DictionarySettingsDialog(
|
||||
isVisible = true,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import com.aryan.reader.SearchResult
|
|||
import com.aryan.reader.SearchResultsPanel
|
||||
import com.aryan.reader.SearchState
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
import com.aryan.reader.epub.contentFilePath
|
||||
import com.aryan.reader.paginatedreader.IPaginator
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -64,8 +65,7 @@ fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List<SearchResul
|
|||
val results = mutableListOf<SearchResult>()
|
||||
epubBook.chapters.forEachIndexed { chapterIndex, chapter ->
|
||||
try {
|
||||
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
|
||||
val htmlFile = File(fullPath)
|
||||
val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath())
|
||||
if (!htmlFile.exists()) return@forEachIndexed
|
||||
|
||||
val doc = Jsoup.parse(htmlFile, "UTF-8")
|
||||
|
|
|
|||
|
|
@ -109,6 +109,13 @@ import com.aryan.reader.data.CustomFontEntity
|
|||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
typealias ReaderFont = com.aryan.reader.shared.ReaderFont
|
||||
typealias ReaderTextAlign = com.aryan.reader.shared.ReaderTextAlign
|
||||
typealias SystemUiMode = com.aryan.reader.shared.SystemUiMode
|
||||
typealias PageInfoMode = com.aryan.reader.shared.PageInfoMode
|
||||
typealias PageInfoPosition = com.aryan.reader.shared.PageInfoPosition
|
||||
typealias FormatSettings = com.aryan.reader.shared.FormatSettings
|
||||
|
||||
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"
|
||||
|
|
@ -153,50 +160,45 @@ fun loadTtsPitch(context: Context): Float {
|
|||
return prefs.getFloat(TTS_PITCH_KEY, 1.0f)
|
||||
}
|
||||
|
||||
enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) {
|
||||
ORIGINAL("original", "Original", "Original"),
|
||||
MERRIWEATHER("merriweather", "Merriweather", "Merriweather"),
|
||||
LATO("lato", "Lato", "Lato"),
|
||||
LORA("lora", "Lora", "Lora"),
|
||||
ROBOTO_MONO("roboto_mono", "Roboto Mono", "Roboto Mono"),
|
||||
LEXEND("lexend", "Lexend", "Lexend")
|
||||
}
|
||||
val ReaderTextAlign.iconResId: Int
|
||||
get() = when (this) {
|
||||
ReaderTextAlign.DEFAULT,
|
||||
ReaderTextAlign.LEFT -> R.drawable.format_align_left
|
||||
ReaderTextAlign.RIGHT -> R.drawable.format_align_right
|
||||
ReaderTextAlign.JUSTIFY -> R.drawable.format_align_justify
|
||||
}
|
||||
|
||||
enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, @StringRes val displayNameRes: Int) {
|
||||
DEFAULT("default", "", R.drawable.format_align_left, R.string.label_default),
|
||||
LEFT("left", "left", R.drawable.format_align_left, R.string.label_left),
|
||||
RIGHT("right", "right", R.drawable.format_align_right, R.string.label_right),
|
||||
JUSTIFY("justify", "justify", R.drawable.format_align_justify, R.string.label_justify)
|
||||
}
|
||||
@get:StringRes
|
||||
val ReaderTextAlign.displayNameRes: Int
|
||||
get() = when (this) {
|
||||
ReaderTextAlign.DEFAULT -> R.string.label_default
|
||||
ReaderTextAlign.LEFT -> R.string.label_left
|
||||
ReaderTextAlign.RIGHT -> R.string.label_right
|
||||
ReaderTextAlign.JUSTIFY -> R.string.label_justify
|
||||
}
|
||||
|
||||
enum class SystemUiMode(val id: Int, @StringRes val titleRes: Int) {
|
||||
DEFAULT(0, R.string.label_always_show),
|
||||
SYNC(1, R.string.label_sync_with_menus),
|
||||
HIDDEN(2, R.string.label_always_hide)
|
||||
}
|
||||
@get:StringRes
|
||||
val SystemUiMode.titleRes: Int
|
||||
get() = when (this) {
|
||||
SystemUiMode.DEFAULT -> R.string.label_always_show
|
||||
SystemUiMode.SYNC -> R.string.label_sync_with_menus
|
||||
SystemUiMode.HIDDEN -> R.string.label_always_hide
|
||||
}
|
||||
|
||||
enum class PageInfoMode(val id: Int, @StringRes val titleRes: Int) {
|
||||
DEFAULT(0, R.string.label_always_show),
|
||||
SYNC(1, R.string.label_sync_with_menus),
|
||||
HIDDEN(2, R.string.label_always_hide)
|
||||
}
|
||||
@get:StringRes
|
||||
val PageInfoMode.titleRes: Int
|
||||
get() = when (this) {
|
||||
PageInfoMode.DEFAULT -> R.string.label_always_show
|
||||
PageInfoMode.SYNC -> R.string.label_sync_with_menus
|
||||
PageInfoMode.HIDDEN -> R.string.label_always_hide
|
||||
}
|
||||
|
||||
enum class PageInfoPosition(val id: Int, @StringRes val titleRes: Int) {
|
||||
BOTTOM(0, R.string.label_bottom),
|
||||
TOP(1, R.string.label_top)
|
||||
}
|
||||
|
||||
data class FormatSettings(
|
||||
val fontSize: Float,
|
||||
val lineHeight: Float,
|
||||
val paragraphGap: Float,
|
||||
val imageSize: Float,
|
||||
val horizontalMargin: Float,
|
||||
val verticalMargin: Float,
|
||||
val font: ReaderFont,
|
||||
val customPath: String?,
|
||||
val textAlign: ReaderTextAlign
|
||||
)
|
||||
@get:StringRes
|
||||
val PageInfoPosition.titleRes: Int
|
||||
get() = when (this) {
|
||||
PageInfoPosition.BOTTOM -> R.string.label_bottom
|
||||
PageInfoPosition.TOP -> R.string.label_top
|
||||
}
|
||||
|
||||
private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_"
|
||||
private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_"
|
||||
|
|
|
|||
|
|
@ -327,20 +327,27 @@ private fun handleVerticalAutoAdvance(
|
|||
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Loading remaining text of current chapter natively.")
|
||||
val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex)
|
||||
|
||||
if (!nativeChunks.isNullOrEmpty() && lastReadCfi != null) {
|
||||
val lastCfiPath = lastReadCfi.split(":")[0]
|
||||
val resumeIdx = nativeChunks.indexOfLast { it.sourceCfi.split(":")[0] == lastCfiPath }
|
||||
if (!nativeChunks.isNullOrEmpty()) {
|
||||
val resumeIdx = findTtsChunkResumeIndex(
|
||||
chunks = nativeChunks,
|
||||
sourceCfi = lastReadCfi,
|
||||
startOffsetInSource = currentState.startOffsetInSource,
|
||||
currentText = currentState.currentText,
|
||||
currentChunkIndexFallback = currentState.currentChunkIndex
|
||||
)
|
||||
|
||||
if (resumeIdx != -1 && resumeIdx + 1 < nativeChunks.size) {
|
||||
val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size)
|
||||
if (resumeIdx != null && resumeIdx + 1 < nativeChunks.size) {
|
||||
val startChunkIndex = resumeIdx + 1
|
||||
val token = getAuthToken()
|
||||
ttsController.start(
|
||||
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
|
||||
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
|
||||
bookTitle = epubBookTitle,
|
||||
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
|
||||
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
|
||||
bookId = ttsReplacementBookId,
|
||||
chapterIndex = currentTtsChapterIndex,
|
||||
totalChapters = chapters.size,
|
||||
startChunkIndex = startChunkIndex,
|
||||
continueSession = true,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER",
|
||||
|
|
@ -371,6 +378,7 @@ private fun handleVerticalAutoAdvance(
|
|||
bookTitle = epubBookTitle,
|
||||
chapterTitle = chapters.getOrNull(nextIdx)?.title,
|
||||
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
|
||||
bookId = ttsReplacementBookId,
|
||||
chapterIndex = nextIdx,
|
||||
totalChapters = chapters.size,
|
||||
continueSession = true,
|
||||
|
|
@ -450,6 +458,7 @@ private fun handlePaginatedAutoAdvance(
|
|||
bookTitle = epubBookTitle,
|
||||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
bookId = ttsReplacementBookId,
|
||||
chapterIndex = chapterToTry,
|
||||
totalChapters = chapters.size,
|
||||
continueSession = true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
package com.aryan.reader.epubreader
|
||||
|
||||
import com.aryan.reader.paginatedreader.CfiUtils
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
import kotlin.math.abs
|
||||
|
||||
private val TTS_WHITESPACE = Regex("\\s+")
|
||||
|
||||
internal fun sameTtsChunkSource(first: String, second: String): Boolean {
|
||||
if (first.isBlank() || second.isBlank()) return first == second
|
||||
val firstPath = CfiUtils.getPath(first)
|
||||
val secondPath = CfiUtils.getPath(second)
|
||||
return firstPath == secondPath || cfiPathContains(firstPath, secondPath) || cfiPathContains(secondPath, firstPath)
|
||||
}
|
||||
|
||||
internal fun findTtsChunkStartIndex(
|
||||
chunks: List<TtsChunk>,
|
||||
target: TtsChunk?
|
||||
): Int? {
|
||||
if (target == null) return null
|
||||
|
||||
val exactIndex = chunks.indexOfFirst {
|
||||
sameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
|
||||
it.startOffsetInSource == target.startOffsetInSource &&
|
||||
normalizedTtsText(it.text) == normalizedTtsText(target.text)
|
||||
}
|
||||
if (exactIndex >= 0) return exactIndex
|
||||
|
||||
val sourceAndOffsetIndex = chunks.indexOfFirst {
|
||||
sameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
|
||||
target.startOffsetInSource >= it.startOffsetInSource &&
|
||||
target.startOffsetInSource < it.startOffsetInSource + it.text.length
|
||||
}
|
||||
if (sourceAndOffsetIndex >= 0) return sourceAndOffsetIndex
|
||||
|
||||
val sourceAndTextIndex = chunks.indexOfFirst {
|
||||
sameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
|
||||
ttsTextMatches(it.text, target.text)
|
||||
}
|
||||
if (sourceAndTextIndex >= 0) return sourceAndTextIndex
|
||||
|
||||
val sourceNearestOffsetIndex = chunks
|
||||
.mapIndexedNotNull { index, chunk ->
|
||||
if (sameTtsChunkSource(chunk.sourceCfi, target.sourceCfi)) {
|
||||
index to abs(chunk.startOffsetInSource - target.startOffsetInSource)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.minByOrNull { it.second }
|
||||
?.first
|
||||
if (sourceNearestOffsetIndex != null) return sourceNearestOffsetIndex
|
||||
|
||||
return findUniqueTextMatch(chunks, target.text)
|
||||
}
|
||||
|
||||
internal fun findTtsChunkResumeIndex(
|
||||
chunks: List<TtsChunk>,
|
||||
sourceCfi: String?,
|
||||
startOffsetInSource: Int,
|
||||
currentText: String?,
|
||||
currentChunkIndexFallback: Int
|
||||
): Int? {
|
||||
val target = sourceCfi
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
TtsChunk(
|
||||
text = currentText.orEmpty(),
|
||||
sourceCfi = it,
|
||||
startOffsetInSource = startOffsetInSource.coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
|
||||
val matchedIndex = findTtsChunkStartIndex(chunks, target)
|
||||
?: currentText?.let { findUniqueTextMatch(chunks, it) }
|
||||
if (matchedIndex != null) return matchedIndex
|
||||
|
||||
return currentChunkIndexFallback.takeIf { it in chunks.indices }
|
||||
}
|
||||
|
||||
private fun cfiPathContains(parentPath: String, childPath: String): Boolean {
|
||||
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
|
||||
val parentParts = parentPath.split('/').filter { it.isNotEmpty() }
|
||||
val childParts = childPath.split('/').filter { it.isNotEmpty() }
|
||||
return parentParts.size < childParts.size && childParts.take(parentParts.size) == parentParts
|
||||
}
|
||||
|
||||
private fun normalizedTtsText(text: String): String =
|
||||
text.replace(TTS_WHITESPACE, " ").trim()
|
||||
|
||||
private fun ttsTextMatches(first: String, second: String): Boolean {
|
||||
val firstNormalized = normalizedTtsText(first)
|
||||
val secondNormalized = normalizedTtsText(second)
|
||||
if (firstNormalized.isBlank() || secondNormalized.isBlank()) return false
|
||||
return firstNormalized == secondNormalized ||
|
||||
firstNormalized.startsWith(secondNormalized) ||
|
||||
secondNormalized.startsWith(firstNormalized)
|
||||
}
|
||||
|
||||
private fun findUniqueTextMatch(chunks: List<TtsChunk>, text: String): Int? {
|
||||
val matches = chunks.mapIndexedNotNull { index, chunk ->
|
||||
index.takeIf { ttsTextMatches(chunk.text, text) }
|
||||
}
|
||||
return matches.singleOrNull()
|
||||
}
|
||||
|
|
@ -38,6 +38,20 @@ import org.json.JSONObject
|
|||
|
||||
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
|
||||
|
||||
internal fun readWebViewHitTestTypeOrNull(hitTestTypeProvider: () -> Int?): Int? {
|
||||
return try {
|
||||
hitTestTypeProvider()
|
||||
} catch (e: NullPointerException) {
|
||||
Timber.w(e, "WebView hit test state was unavailable for tap.")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isWebViewAnchorHitTestType(type: Int?): Boolean {
|
||||
return type == WebView.HitTestResult.SRC_ANCHOR_TYPE ||
|
||||
type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE
|
||||
}
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class InteractiveWebView(
|
||||
context: Context,
|
||||
|
|
@ -376,10 +390,11 @@ class InteractiveWebView(
|
|||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
Timber.d("onSingleTapConfirmed")
|
||||
|
||||
val hitTestResult = this@InteractiveWebView.hitTestResult
|
||||
val type = hitTestResult.type
|
||||
val type = readWebViewHitTestTypeOrNull {
|
||||
this@InteractiveWebView.hitTestResult?.type
|
||||
}
|
||||
|
||||
if (type == HitTestResult.SRC_ANCHOR_TYPE || type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
|
||||
if (isWebViewAnchorHitTestType(type)) {
|
||||
Timber.d("Tap was on a link. Consuming tap, not toggling app bars.")
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ typealias OpdsFeed = com.aryan.reader.shared.opds.OpdsFeed
|
|||
typealias OpdsAuthor = com.aryan.reader.shared.opds.OpdsAuthor
|
||||
typealias OpdsAcquisition = com.aryan.reader.shared.opds.OpdsAcquisition
|
||||
typealias OpdsEntry = com.aryan.reader.shared.opds.OpdsEntry
|
||||
typealias OpdsDownloadState = com.aryan.reader.shared.opds.SharedOpdsDownloadState
|
||||
typealias OpdsScreenState = com.aryan.reader.shared.opds.SharedOpdsScreenState
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.aryan.reader.shared.opds.SharedOpdsCatalogs
|
||||
import com.aryan.reader.shared.opds.SharedOpdsRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
|
|
@ -12,7 +13,7 @@ import timber.log.Timber
|
|||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
class OpdsRepository(context: Context) {
|
||||
class OpdsRepository(context: Context) : SharedOpdsRepository {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE)
|
||||
private val parser = OpdsParser()
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ class OpdsRepository(context: Context) {
|
|||
|
||||
private val httpClient = sharedHttpClient
|
||||
|
||||
fun getCatalogs(): List<OpdsCatalog> {
|
||||
override fun loadCatalogs(): List<OpdsCatalog> {
|
||||
val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
|
||||
val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString)
|
||||
val catalogs = decodedCatalogs.ifEmpty {
|
||||
|
|
@ -46,10 +47,12 @@ class OpdsRepository(context: Context) {
|
|||
return catalogs
|
||||
}
|
||||
|
||||
suspend fun getSearchTemplate(
|
||||
fun getCatalogs(): List<OpdsCatalog> = loadCatalogs()
|
||||
|
||||
override suspend fun getSearchTemplate(
|
||||
openSearchUrl: String,
|
||||
username: String? = null,
|
||||
password: String? = null
|
||||
username: String?,
|
||||
password: String?
|
||||
): String? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder().url(openSearchUrl).build()
|
||||
|
|
@ -65,7 +68,7 @@ class OpdsRepository(context: Context) {
|
|||
fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
|
||||
saveCatalogs(
|
||||
SharedOpdsCatalogs.addCatalog(
|
||||
catalogs = getCatalogs(),
|
||||
catalogs = loadCatalogs(),
|
||||
title = title,
|
||||
url = url,
|
||||
username = username,
|
||||
|
|
@ -76,14 +79,14 @@ class OpdsRepository(context: Context) {
|
|||
}
|
||||
|
||||
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
|
||||
saveCatalogs(SharedOpdsCatalogs.updateCatalog(getCatalogs(), id, title, url, username, password))
|
||||
saveCatalogs(SharedOpdsCatalogs.updateCatalog(loadCatalogs(), id, title, url, username, password))
|
||||
}
|
||||
|
||||
fun removeCatalog(id: String) {
|
||||
saveCatalogs(SharedOpdsCatalogs.removeCatalog(getCatalogs(), id))
|
||||
saveCatalogs(SharedOpdsCatalogs.removeCatalog(loadCatalogs(), id))
|
||||
}
|
||||
|
||||
private fun saveCatalogs(catalogs: List<OpdsCatalog>) {
|
||||
override fun saveCatalogs(catalogs: List<OpdsCatalog>) {
|
||||
prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) }
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +169,7 @@ class OpdsRepository(context: Context) {
|
|||
}
|
||||
|
||||
|
||||
suspend fun fetchFeed(url: String, username: String? = null, password: String? = null): Result<OpdsFeed> = withContext(Dispatchers.IO) {
|
||||
override suspend fun fetchFeed(url: String, username: String?, password: String?): Result<OpdsFeed> = withContext(Dispatchers.IO) {
|
||||
Timber.tag("OpdsDebug").d("Starting fetch for URL: $url")
|
||||
try {
|
||||
val client = getAuthenticatedClient(username, password)
|
||||
|
|
|
|||
|
|
@ -6,135 +6,106 @@ import android.net.Uri
|
|||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.shared.opds.SharedOpdsController
|
||||
import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer
|
||||
import com.aryan.reader.shared.opds.SharedOpdsSearch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Response
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = OpdsRepository(application)
|
||||
private val controller = SharedOpdsController(
|
||||
repository = repository,
|
||||
feedLoadErrorMessage = { error ->
|
||||
application.getString(R.string.opds_error_load_feed, error.message.orEmpty())
|
||||
},
|
||||
idFactory = { UUID.randomUUID().toString() }
|
||||
)
|
||||
|
||||
private val _uiState = MutableStateFlow(OpdsScreenState())
|
||||
private val _uiState = MutableStateFlow(controller.state)
|
||||
val uiState: StateFlow<OpdsScreenState> = _uiState.asStateFlow()
|
||||
|
||||
private val urlStack = mutableListOf<String>()
|
||||
|
||||
private val _downloadingEntries = MutableStateFlow<Set<String>>(emptySet())
|
||||
val downloadingEntries: StateFlow<Set<String>> = _downloadingEntries.asStateFlow()
|
||||
|
||||
private fun fetchUrl(url: String, isPagination: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
val catalog = _uiState.value.currentCatalog
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null, isViewingCatalog = true) }
|
||||
|
||||
val result = repository.fetchFeed(url, catalog?.username, catalog?.password)
|
||||
result.onSuccess { newFeed ->
|
||||
val template = newFeed.searchUrl ?: _uiState.value.searchUrlTemplate
|
||||
if (!isPagination) {
|
||||
if (urlStack.isEmpty() || urlStack.last() != url) {
|
||||
urlStack.add(url)
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = false, currentFeed = newFeed, searchUrlTemplate = template) }
|
||||
} else {
|
||||
_uiState.update { state ->
|
||||
val currentEntries = state.currentFeed?.entries ?: emptyList()
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
currentFeed = newFeed.copy(entries = currentEntries + newFeed.entries),
|
||||
searchUrlTemplate = template
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = getApplication<Application>().getString(R.string.opds_error_load_feed, e.message.orEmpty())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadNextPage() {
|
||||
val nextUrl = _uiState.value.currentFeed?.nextUrl
|
||||
if (nextUrl != null && !_uiState.value.isLoading) {
|
||||
fetchUrl(nextUrl, isPagination = true)
|
||||
viewModelScope.launch {
|
||||
controller.loadNextPage(::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
data class DownloadState(val isDownloading: Boolean, val progress: Float? = null)
|
||||
|
||||
private val _downloadingState = MutableStateFlow<Map<String, DownloadState>>(emptyMap())
|
||||
val downloadingState: StateFlow<Map<String, DownloadState>> = _downloadingState.asStateFlow()
|
||||
|
||||
fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) {
|
||||
val downloadUrl = acquisition.url
|
||||
val catalog = _uiState.value.currentCatalog
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_downloadingState.update { it + (entry.id to DownloadState(true, 0f)) }
|
||||
viewModelScope.launch {
|
||||
updateDownloadState(entry.id, OpdsDownloadState(isDownloading = true, progress = 0f))
|
||||
try {
|
||||
val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password)
|
||||
val request = Request.Builder().url(downloadUrl).build()
|
||||
val tempFile = withContext(Dispatchers.IO) {
|
||||
val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password)
|
||||
val request = Request.Builder().url(downloadUrl).build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw OpdsDownloadFailedException(
|
||||
context.getString(R.string.opds_error_download_failed, response.message)
|
||||
)
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body
|
||||
?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
|
||||
val contentLength = body.contentLength()
|
||||
val body = response.body
|
||||
?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
|
||||
val contentLength = body.contentLength()
|
||||
val ext = resolveOpdsDownloadExtension(acquisition, response)
|
||||
val safeTitle = SharedOpdsDownloadNamer.safeFileStem(entry.title).take(50)
|
||||
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
|
||||
|
||||
val ext = resolveOpdsDownloadExtension(acquisition, response)
|
||||
body.byteStream().use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var totalRead = 0L
|
||||
var lastProgressUpdate = System.currentTimeMillis()
|
||||
|
||||
val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50)
|
||||
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
|
||||
while (true) {
|
||||
val bytesRead = input.read(buffer)
|
||||
if (bytesRead == -1) break
|
||||
output.write(buffer, 0, bytesRead)
|
||||
totalRead += bytesRead
|
||||
|
||||
val input = body.byteStream()
|
||||
val output = tempFile.outputStream()
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var bytesRead: Int
|
||||
var totalRead = 0L
|
||||
var lastProgressUpdate = System.currentTimeMillis()
|
||||
|
||||
input.use { inp ->
|
||||
output.use { out ->
|
||||
while (inp.read(buffer).also { bytesRead = it } != -1) {
|
||||
out.write(buffer, 0, bytesRead)
|
||||
totalRead += bytesRead
|
||||
if (contentLength > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
// Throttle UI updates to 4-5 fps
|
||||
if (now - lastProgressUpdate > 200) {
|
||||
val progress = totalRead.toFloat() / contentLength.toFloat()
|
||||
_downloadingState.update { it + (entry.id to DownloadState(true, progress)) }
|
||||
lastProgressUpdate = now
|
||||
if (contentLength > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastProgressUpdate > 200) {
|
||||
val progress = (totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f)
|
||||
withContext(Dispatchers.Main) {
|
||||
updateDownloadState(
|
||||
entry.id,
|
||||
OpdsDownloadState(isDownloading = true, progress = progress)
|
||||
)
|
||||
}
|
||||
lastProgressUpdate = now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tempFile
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
onDownloaded(Uri.fromFile(tempFile))
|
||||
}
|
||||
} else {
|
||||
Timber.e("Download failed: ${response.code}")
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_failed, response.message)) }
|
||||
}
|
||||
|
||||
onDownloaded(Uri.fromFile(tempFile))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Download error")
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_error, e.message.orEmpty())) }
|
||||
val message = if (e is OpdsDownloadFailedException) {
|
||||
e.message.orEmpty()
|
||||
} else {
|
||||
context.getString(R.string.opds_error_download_error, e.message.orEmpty())
|
||||
}
|
||||
emitState(controller.setErrorMessage(message))
|
||||
} finally {
|
||||
_downloadingState.update { it - entry.id }
|
||||
updateDownloadState(entry.id, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -147,69 +118,55 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
)
|
||||
}
|
||||
|
||||
init {
|
||||
loadCatalogs()
|
||||
}
|
||||
|
||||
private fun loadCatalogs() {
|
||||
_uiState.update { it.copy(catalogs = repository.getCatalogs()) }
|
||||
}
|
||||
|
||||
fun addCatalog(title: String, url: String, username: String?, password: String?) {
|
||||
repository.addCatalog(title, url, username, password)
|
||||
loadCatalogs()
|
||||
emitState(controller.addCatalog(title, url, username, password))
|
||||
}
|
||||
|
||||
fun removeCatalog(id: String) {
|
||||
repository.removeCatalog(id)
|
||||
loadCatalogs()
|
||||
emitState(controller.removeCatalog(id))
|
||||
}
|
||||
|
||||
fun openCatalog(catalog: OpdsCatalog) {
|
||||
urlStack.clear()
|
||||
_uiState.update { it.copy(searchUrlTemplate = null, currentCatalog = catalog) }
|
||||
fetchUrl(catalog.url)
|
||||
}
|
||||
|
||||
fun openFeedUrl(url: String) {
|
||||
fetchUrl(url)
|
||||
}
|
||||
|
||||
fun navigateBack(): Boolean {
|
||||
if (urlStack.size > 1) {
|
||||
urlStack.removeAt(urlStack.lastIndex)
|
||||
val previousUrl = urlStack.last()
|
||||
urlStack.removeAt(urlStack.lastIndex)
|
||||
fetchUrl(previousUrl)
|
||||
return true
|
||||
} else {
|
||||
urlStack.clear()
|
||||
_uiState.update { it.copy(isViewingCatalog = false, currentFeed = null, searchUrlTemplate = null, currentCatalog = null) }
|
||||
return false
|
||||
viewModelScope.launch {
|
||||
controller.openCatalog(catalog, ::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
fun openFeedUrl(url: String) {
|
||||
viewModelScope.launch {
|
||||
controller.openFeedUrl(url, ::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateBack(): Boolean {
|
||||
val returnsToPreviousFeed = controller.hasFeedHistory()
|
||||
viewModelScope.launch {
|
||||
controller.navigateBack(::emitState)
|
||||
}
|
||||
return returnsToPreviousFeed
|
||||
}
|
||||
|
||||
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
|
||||
repository.updateCatalog(id, title, url, username, password)
|
||||
loadCatalogs()
|
||||
emitState(controller.updateCatalog(id, title, url, username, password))
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
val searchLink = _uiState.value.searchUrlTemplate ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
|
||||
val finalUrl = SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl ->
|
||||
val catalog = _uiState.value.currentCatalog
|
||||
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
|
||||
}
|
||||
|
||||
openFeedUrl(finalUrl)
|
||||
controller.search(query, ::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
emitState(controller.clearError())
|
||||
}
|
||||
|
||||
private fun updateDownloadState(entryId: String, downloadState: OpdsDownloadState?) {
|
||||
emitState(controller.updateDownloadState(entryId, downloadState))
|
||||
}
|
||||
|
||||
private fun emitState(state: OpdsScreenState) {
|
||||
_uiState.value = state
|
||||
}
|
||||
|
||||
private class OpdsDownloadFailedException(message: String) : Exception(message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import androidx.compose.ui.unit.Constraints
|
|||
import androidx.compose.ui.unit.Density
|
||||
import com.aryan.reader.SearchResult
|
||||
import com.aryan.reader.epub.EpubChapter
|
||||
import com.aryan.reader.epub.contentFilePath
|
||||
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
||||
import com.aryan.reader.paginatedreader.data.BookProcessingInput
|
||||
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
||||
|
|
@ -103,6 +104,14 @@ private data class PaginationRequest(val chapterIndex: Int, val priority: Int) :
|
|||
|
||||
private const val PAGE_INDEX_ANCHOR_SEPARATOR = "\u001F"
|
||||
|
||||
private fun String.normalizedImageSourceForNavigation(): String {
|
||||
return substringBefore('#')
|
||||
.substringBefore('?')
|
||||
.replace('\\', '/')
|
||||
.removePrefix("file://")
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
private data class TextRangeIndex(
|
||||
val pageInChapter: Int,
|
||||
val blockIndex: Int,
|
||||
|
|
@ -394,7 +403,7 @@ class BookPaginator(
|
|||
}
|
||||
|
||||
private fun chapterContentVersion(chapter: EpubChapter): Int {
|
||||
val backingFile = java.io.File(extractionBasePath, chapter.htmlFilePath)
|
||||
val backingFile = java.io.File(extractionBasePath, chapter.contentFilePath())
|
||||
return buildString {
|
||||
append(chapter.absPath)
|
||||
append('|')
|
||||
|
|
@ -656,6 +665,43 @@ class BookPaginator(
|
|||
finalPageIndex
|
||||
}
|
||||
|
||||
suspend fun findStablePageForImageSource(
|
||||
chapterIndex: Int,
|
||||
sourcePath: String,
|
||||
elementId: String?,
|
||||
ordinalInChapter: Int
|
||||
): Pair<Int, Locator>? = withContext(Dispatchers.IO) {
|
||||
val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null
|
||||
val imageBlocks = getAllBlocks(getBlocksForChapter(chapter, chapterIndex))
|
||||
.filterIsInstance<ImageBlock>()
|
||||
if (imageBlocks.isEmpty()) return@withContext null
|
||||
|
||||
val normalizedSource = sourcePath.normalizedImageSourceForNavigation()
|
||||
val normalizedFileName = normalizedSource.substringAfterLast('/')
|
||||
val matchingBySource = imageBlocks.filter { block ->
|
||||
val normalizedBlockPath = block.path.normalizedImageSourceForNavigation()
|
||||
normalizedBlockPath == normalizedSource ||
|
||||
normalizedBlockPath.endsWith("/$normalizedFileName") ||
|
||||
normalizedSource.endsWith("/${normalizedBlockPath.substringAfterLast('/')}")
|
||||
}
|
||||
val targetBlock = elementId
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { id -> imageBlocks.firstOrNull { it.elementId == id } }
|
||||
?: matchingBySource.getOrNull(ordinalInChapter.coerceAtLeast(0))
|
||||
?: matchingBySource.firstOrNull()
|
||||
?: return@withContext null
|
||||
|
||||
val locator = Locator(
|
||||
chapterIndex = chapterIndex,
|
||||
blockIndex = targetBlock.blockIndex,
|
||||
charOffset = 0
|
||||
)
|
||||
val targetPage = findStablePageForLocator(locator)
|
||||
?: findStableChapterStartPage(chapterIndex)
|
||||
?: return@withContext null
|
||||
targetPage to locator
|
||||
}
|
||||
|
||||
suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? {
|
||||
val pages = ensureChapterPaginated(chapterIndex)
|
||||
if (pages.isNullOrEmpty()) {
|
||||
|
|
@ -766,7 +812,7 @@ class BookPaginator(
|
|||
|
||||
var shouldIgnoreCache = false
|
||||
if (isCacheEmpty && isLazyChapter) {
|
||||
val file = java.io.File(extractionBasePath, chapter.htmlFilePath)
|
||||
val file = java.io.File(extractionBasePath, chapter.contentFilePath())
|
||||
if (file.exists() && file.length() > 0) {
|
||||
Timber.tag("ReflowPaginationDiag").w("getBlocksForChapter: Cache HIT but empty for lazy chapter $chapterIndex. Backing file exists (${file.length()} bytes). Ignoring cache.")
|
||||
shouldIgnoreCache = true
|
||||
|
|
@ -789,7 +835,7 @@ class BookPaginator(
|
|||
|
||||
var htmlToParse = chapter.htmlContent
|
||||
if (htmlToParse.isEmpty()) {
|
||||
val file = java.io.File(extractionBasePath, chapter.htmlFilePath)
|
||||
val file = java.io.File(extractionBasePath, chapter.contentFilePath())
|
||||
if (file.exists()) {
|
||||
Timber.tag("ReflowPaginationDiag").d("getBlocksForChapter: Lazy loading content from disk for chapter $chapterIndex: ${file.name} (${file.length()} bytes)")
|
||||
try {
|
||||
|
|
@ -1187,7 +1233,7 @@ class BookPaginator(
|
|||
Timber.tag("POS_DIAG").d("getPlainTextForChapter: chapterIndex=$chapterIndex, chapterTitle='${chapter.title}', hasInMemoryContent=${chapter.htmlContent.isNotEmpty()}")
|
||||
val htmlToParse = chapter.htmlContent.ifEmpty {
|
||||
try {
|
||||
val file = java.io.File(extractionBasePath, chapter.htmlFilePath)
|
||||
val file = java.io.File(extractionBasePath, chapter.contentFilePath())
|
||||
if (file.exists()) file.readText() else ""
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import androidx.compose.ui.text.TextStyle
|
|||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.aryan.reader.epub.EpubBook
|
||||
import com.aryan.reader.epub.contentFilePath
|
||||
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
||||
import com.aryan.reader.paginatedreader.data.ProcessedChapter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -67,7 +68,7 @@ class LocatorConverter(
|
|||
|
||||
val htmlToParse = chapter.htmlContent.ifBlank {
|
||||
try {
|
||||
val file = File(book.extractionBasePath, chapter.htmlFilePath)
|
||||
val file = File(book.extractionBasePath, chapter.contentFilePath())
|
||||
if (file.exists()) {
|
||||
val content = file.readText()
|
||||
content
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import androidx.work.Data
|
|||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.aryan.reader.epub.epubContentFilePath
|
||||
import com.aryan.reader.paginatedreader.CssParser
|
||||
import com.aryan.reader.paginatedreader.FontFaceInfo
|
||||
import com.aryan.reader.paginatedreader.MathMLRenderer
|
||||
|
|
@ -246,7 +247,7 @@ class BookProcessingWorker(
|
|||
if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) {
|
||||
Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}")
|
||||
val htmlToParse = chapter.htmlContent.ifBlank {
|
||||
val backingFile = File(extractionBasePath, chapter.htmlFilePath)
|
||||
val backingFile = File(extractionBasePath, epubContentFilePath(chapter.htmlFilePath))
|
||||
if (backingFile.exists()) {
|
||||
backingFile.readText()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ object NativePdfiumBridge {
|
|||
inkPointOffsets: IntArray,
|
||||
inkPointCounts: IntArray,
|
||||
inkPoints: FloatArray,
|
||||
inkNames: Array<String>,
|
||||
inkContents: Array<String>,
|
||||
textPageIndices: IntArray,
|
||||
textBounds: FloatArray,
|
||||
textColors: IntArray,
|
||||
|
|
@ -62,7 +64,16 @@ object NativePdfiumBridge {
|
|||
highlightRectOffsets: IntArray,
|
||||
highlightRectCounts: IntArray,
|
||||
highlightRects: FloatArray,
|
||||
highlightContents: Array<String>
|
||||
highlightNames: Array<String>,
|
||||
highlightContents: Array<String>,
|
||||
highlightCommentOffsets: IntArray,
|
||||
highlightCommentCounts: IntArray,
|
||||
highlightCommentParentIndices: IntArray,
|
||||
highlightCommentNames: Array<String>,
|
||||
highlightCommentAuthors: Array<String>,
|
||||
highlightCommentContents: Array<String>,
|
||||
highlightCommentCreatedDates: Array<String>,
|
||||
highlightCommentModifiedDates: Array<String>
|
||||
): Boolean
|
||||
|
||||
const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
enum class AnnotationType {
|
||||
INK, TEXT
|
||||
}
|
||||
|
||||
enum class InkType {
|
||||
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT
|
||||
}
|
||||
|
||||
data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
|
||||
111
app/src/main/java/com/aryan/reader/pdf/PdfBubbleZoom.kt
Normal file
111
app/src/main/java/com/aryan/reader/pdf/PdfBubbleZoom.kt
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.RectF
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.aryan.reader.ml.SpeechBubble
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
import android.graphics.Color as AndroidColor
|
||||
|
||||
internal data class ExpandedBubbleRender(
|
||||
val bitmap: Bitmap,
|
||||
val zoomFactor: Float
|
||||
)
|
||||
|
||||
internal 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)
|
||||
}
|
||||
|
||||
internal 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
|
||||
}
|
||||
|
||||
internal 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 safeRenderScale = safePdfBitmapRenderScale(
|
||||
contentWidth = bubbleBounds.width(),
|
||||
contentHeight = bubbleBounds.height(),
|
||||
requestedScale = renderScale
|
||||
)
|
||||
val cropWidth = (bubbleBounds.width() * safeRenderScale).roundToInt().coerceAtLeast(1)
|
||||
val cropHeight = (bubbleBounds.height() * safeRenderScale).roundToInt().coerceAtLeast(1)
|
||||
val bitmap = createBitmap(cropWidth, cropHeight)
|
||||
|
||||
try {
|
||||
page.renderPageBitmap(
|
||||
bitmap = bitmap,
|
||||
startX = (-bubbleBounds.left * safeRenderScale).roundToInt(),
|
||||
startY = (-bubbleBounds.top * safeRenderScale).roundToInt(),
|
||||
drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth),
|
||||
drawSizeY = (pageHeight * safeRenderScale).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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun safePdfBitmapRenderScale(
|
||||
contentWidth: Float,
|
||||
contentHeight: Float,
|
||||
requestedScale: Float
|
||||
): Float {
|
||||
if (contentWidth <= 0f || contentHeight <= 0f || requestedScale <= 0f) return 1f
|
||||
|
||||
val requestedWidth = contentWidth * requestedScale
|
||||
val requestedHeight = contentHeight * requestedScale
|
||||
val requestedBytes = requestedWidth.toDouble() * requestedHeight.toDouble() * 4.0
|
||||
val byteScale = sqrt(PDF_MAX_DRAW_BITMAP_BYTES.toDouble() / requestedBytes.coerceAtLeast(1.0))
|
||||
val dimensionScale = PDF_MAX_DRAW_BITMAP_DIMENSION_PX.toDouble() /
|
||||
max(requestedWidth, requestedHeight).toDouble().coerceAtLeast(1.0)
|
||||
val limiter = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01)
|
||||
return (requestedScale.toDouble() * limiter).coerceAtLeast(0.01).toFloat()
|
||||
}
|
||||
|
||||
internal const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L
|
||||
internal const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096
|
||||
|
|
@ -24,7 +24,6 @@ import android.graphics.Bitmap
|
|||
import android.net.Uri
|
||||
import timber.log.Timber
|
||||
import androidx.core.graphics.createBitmap
|
||||
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
|
@ -32,7 +31,6 @@ private const val TAG = "PdfCoverGenerator"
|
|||
|
||||
class PdfCoverGenerator(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val pdfiumCore = PdfiumCoreKt(Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Generates a Bitmap cover for the first page of a PDF.
|
||||
|
|
@ -48,37 +46,40 @@ class PdfCoverGenerator(context: Context) {
|
|||
appContext.contentResolver.openFileDescriptor(pdfUri, "r").use { pfd ->
|
||||
if (pfd == null) {
|
||||
Timber.e("Failed to open ParcelFileDescriptor for URI: $pdfUri")
|
||||
return@withContext null
|
||||
}
|
||||
pdfiumCore.newDocument(pfd).use { doc ->
|
||||
if (doc.getPageCount() == 0) {
|
||||
Timber.w("PDF has no pages, cannot generate cover: $pdfUri")
|
||||
return@withContext null
|
||||
}
|
||||
doc.openPage(0)?.use { page ->
|
||||
val originalWidth = page.getPageWidthPoint()
|
||||
val originalHeight = page.getPageHeightPoint()
|
||||
if (originalWidth <= 0 || originalHeight <= 0) {
|
||||
Timber.e("Invalid page dimensions for cover: $pdfUri")
|
||||
return@withContext null
|
||||
null
|
||||
} else {
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
PdfiumCoreProvider.core.newDocument(pfd).use { doc ->
|
||||
if (doc.getPageCount() == 0) {
|
||||
Timber.w("PDF has no pages, cannot generate cover: $pdfUri")
|
||||
return@withPdfium null
|
||||
}
|
||||
doc.openPage(0)?.use { page ->
|
||||
val originalWidth = page.getPageWidthPoint()
|
||||
val originalHeight = page.getPageHeightPoint()
|
||||
if (originalWidth <= 0 || originalHeight <= 0) {
|
||||
Timber.e("Invalid page dimensions for cover: $pdfUri")
|
||||
return@withPdfium null
|
||||
}
|
||||
|
||||
val aspectRatio = originalWidth.toFloat() / originalHeight.toFloat()
|
||||
val targetWidth = (targetHeight * aspectRatio).toInt()
|
||||
|
||||
if (targetWidth <= 0) {
|
||||
Timber.e("Calculated invalid bitmap width for cover: $targetWidth")
|
||||
return@withPdfium null
|
||||
}
|
||||
|
||||
val bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(
|
||||
bitmap = bitmap,
|
||||
startX = 0, startY = 0,
|
||||
drawSizeX = targetWidth, drawSizeY = targetHeight,
|
||||
renderAnnot = false
|
||||
)
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
|
||||
val aspectRatio = originalWidth.toFloat() / originalHeight.toFloat()
|
||||
val targetWidth = (targetHeight * aspectRatio).toInt()
|
||||
|
||||
if (targetWidth <= 0) {
|
||||
Timber.e("Calculated invalid bitmap width for cover: $targetWidth")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(
|
||||
bitmap = bitmap,
|
||||
startX = 0, startY = 0,
|
||||
drawSizeX = targetWidth, drawSizeY = targetHeight,
|
||||
renderAnnot = false
|
||||
)
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,21 +158,40 @@ internal fun shouldShowPdfAnnotationExportChoice(
|
|||
|
||||
internal fun getFastFileId(context: Context, uri: Uri): String {
|
||||
var result = uri.toString()
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"id.fast.start uri=$uri scheme=${uri.scheme}"
|
||||
)
|
||||
try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (uri.scheme == "file") {
|
||||
uri.path?.let {
|
||||
val file = java.io.File(it)
|
||||
result = "${file.name}_${file.length()}"
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"id.fast.file uri=$uri path=${file.absolutePath} exists=${file.exists()} " +
|
||||
"name=${file.name} size=${file.length()} mtime=${file.lastModified()} result=$result"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
|
||||
val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
|
||||
val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown"
|
||||
val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
|
||||
val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown"
|
||||
|
||||
result = "${name}_${size}"
|
||||
result = "${name}_${size}"
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"id.fast.content uri=$uri name=$name size=$size result=$result"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e(e, "id.fast.failed uri=$uri fallback=$result")
|
||||
Timber.e(e, "Failed to generate fast file ID")
|
||||
}
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i("id.fast.done uri=$uri result=$result")
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -81,6 +82,7 @@ import kotlinx.coroutines.withContext
|
|||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.aryan.reader.cardTitle
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
|
||||
|
|
@ -316,9 +318,12 @@ private fun PdfTabsDrawerPage(
|
|||
activeTabBookId: String?,
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
isTopTabStripVisible: Boolean,
|
||||
onTabSelected: (String) -> Unit,
|
||||
onTabClosed: (String) -> Unit,
|
||||
onNewTabClick: () -> Unit
|
||||
onNewTabClick: () -> Unit,
|
||||
onTopTabStripVisibilityChange: (Boolean) -> Unit,
|
||||
usePdfFileNameAsDisplayName: Boolean
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
|
|
@ -357,6 +362,28 @@ private fun PdfTabsDrawerPage(
|
|||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onTopTabStripVisibilityChange(!isTopTabStripVisible) }
|
||||
.padding(start = 16.dp, end = 12.dp, top = 10.dp, bottom = 10.dp)
|
||||
.testTag("PdfTopTabStripVisibilityToggle"),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.pdf_tabs_show_top_app_bar_tabs),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = isTopTabStripVisible,
|
||||
onCheckedChange = null
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (openTabs.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
|
|
@ -382,7 +409,8 @@ private fun PdfTabsDrawerPage(
|
|||
currentPage = currentPage,
|
||||
totalPages = totalPages,
|
||||
onTabSelected = onTabSelected,
|
||||
onTabClosed = onTabClosed
|
||||
onTabClosed = onTabClosed,
|
||||
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -397,7 +425,8 @@ private fun PdfDrawerTabItem(
|
|||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
onTabSelected: (String) -> Unit,
|
||||
onTabClosed: (String) -> Unit
|
||||
onTabClosed: (String) -> Unit,
|
||||
usePdfFileNameAsDisplayName: Boolean
|
||||
) {
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
val containerColor by animateColorAsState(
|
||||
|
|
@ -485,7 +514,7 @@ private fun PdfDrawerTabItem(
|
|||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = tab.customName ?: tab.title ?: tab.displayName,
|
||||
text = tab.cardTitle(usePdfFileNameAsDisplayName),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
color = contentColor,
|
||||
|
|
@ -542,11 +571,14 @@ internal fun PdfNavigationDrawerContent(
|
|||
isTabsEnabled: Boolean = false,
|
||||
openTabs: List<RecentFileItem> = emptyList(),
|
||||
activeTabBookId: String? = null,
|
||||
usePdfFileNameAsDisplayName: Boolean = false,
|
||||
isTopTabStripVisible: Boolean = true,
|
||||
customHighlightColors: Map<PdfHighlightColor, Color>,
|
||||
onPageSelected: (Int) -> Unit,
|
||||
onTabSelected: (String) -> Unit = {},
|
||||
onTabClosed: (String) -> Unit = {},
|
||||
onNewTabClick: () -> Unit = {},
|
||||
onTopTabStripVisibilityChange: (Boolean) -> Unit = {},
|
||||
onRenameBookmark: (PdfBookmark, String) -> Unit,
|
||||
onDeleteBookmark: (PdfBookmark) -> Unit,
|
||||
onDeleteHighlight: (PdfUserHighlight) -> Unit,
|
||||
|
|
@ -601,6 +633,7 @@ internal fun PdfNavigationDrawerContent(
|
|||
activeTabBookId = activeTabBookId,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalPages,
|
||||
isTopTabStripVisible = isTopTabStripVisible,
|
||||
onTabSelected = { bookId ->
|
||||
if (bookId == activeTabBookId) {
|
||||
onCloseDrawer()
|
||||
|
|
@ -610,7 +643,9 @@ internal fun PdfNavigationDrawerContent(
|
|||
}
|
||||
},
|
||||
onTabClosed = onTabClosed,
|
||||
onNewTabClick = onNewTabClick
|
||||
onNewTabClick = onNewTabClick,
|
||||
onTopTabStripVisibilityChange = onTopTabStripVisibilityChange,
|
||||
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
|
||||
)
|
||||
|
||||
PdfDrawerSection.CHAPTERS -> { // Chapters Page
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.graphics.RectF
|
||||
import timber.log.Timber
|
||||
|
||||
data class EmbeddedAnnotation(
|
||||
val index: Int,
|
||||
val subtype: Int,
|
||||
val rect: RectF,
|
||||
val contents: String?,
|
||||
val author: String?,
|
||||
val name: String?,
|
||||
val inReplyTo: String?,
|
||||
val replies: MutableList<EmbeddedAnnotation> = mutableListOf()
|
||||
)
|
||||
|
||||
internal fun groupEmbeddedAnnotationsForDisplay(
|
||||
annotations: List<EmbeddedAnnotation>
|
||||
): List<EmbeddedAnnotation> {
|
||||
if (annotations.isEmpty()) return emptyList()
|
||||
|
||||
val annotMap = annotations
|
||||
.filter { !it.name.isNullOrBlank() }
|
||||
.associateBy { it.name }
|
||||
val orphans = mutableListOf<EmbeddedAnnotation>()
|
||||
|
||||
annotations.forEach { annot ->
|
||||
if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) {
|
||||
Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}")
|
||||
annotMap[annot.inReplyTo]?.replies?.add(annot)
|
||||
} else {
|
||||
orphans.add(annot)
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}")
|
||||
|
||||
val groupedRoots = mutableListOf<MutableList<EmbeddedAnnotation>>()
|
||||
orphans.forEach { annot ->
|
||||
val match = groupedRoots.find { group ->
|
||||
val root = group.first()
|
||||
val inflatedRoot = RectF(root.rect).apply { inset(-10f, -10f) }
|
||||
RectF.intersects(inflatedRoot, annot.rect)
|
||||
}
|
||||
if (match != null) {
|
||||
Timber.tag("PdfCommentDebug").w(
|
||||
"Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!"
|
||||
)
|
||||
match.add(annot)
|
||||
} else {
|
||||
groupedRoots.add(mutableListOf(annot))
|
||||
}
|
||||
}
|
||||
|
||||
return groupedRoots.map { group ->
|
||||
val root = group.first()
|
||||
if (group.size > 1) {
|
||||
root.replies.addAll(group.drop(1))
|
||||
}
|
||||
root
|
||||
}.filter {
|
||||
!it.contents.isNullOrBlank() || it.replies.any { reply -> !reply.contents.isNullOrBlank() }
|
||||
}
|
||||
}
|
||||
|
|
@ -96,7 +96,10 @@ import com.aryan.reader.pdf.ocr.OcrElement
|
|||
import com.aryan.reader.pdf.ocr.OcrLine
|
||||
import com.aryan.reader.pdf.ocr.OcrResult
|
||||
import com.aryan.reader.pdf.ocr.OcrSymbol
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
|
||||
import timber.log.Timber
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
enum class OcrLanguage(@StringRes val displayNameRes: Int) {
|
||||
|
|
@ -135,7 +138,8 @@ data class PdfUserHighlight(
|
|||
val color: PdfHighlightColor,
|
||||
val text: String,
|
||||
val range: Pair<Int, Int>,
|
||||
val note: String? = null
|
||||
val note: String? = null,
|
||||
val comments: List<SharedPdfAnnotationComment> = emptyList()
|
||||
)
|
||||
|
||||
internal data class CustomPdfMenuState(
|
||||
|
|
@ -699,6 +703,13 @@ fun PdfHighlightColorRow(
|
|||
}
|
||||
}
|
||||
|
||||
private enum class PdfAnnotationSheetSection {
|
||||
NOTE,
|
||||
COMMENTS
|
||||
}
|
||||
|
||||
private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PdfAnnotationBottomSheet(
|
||||
|
|
@ -709,7 +720,8 @@ fun PdfAnnotationBottomSheet(
|
|||
onPaletteClick: (() -> Unit)? = null,
|
||||
onColorChange: (PdfHighlightColor) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String) -> Unit,
|
||||
onSave: (String, List<SharedPdfAnnotationComment>) -> Unit,
|
||||
onUpdate: (String, List<SharedPdfAnnotationComment>) -> Unit = { _, _ -> },
|
||||
onDelete: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onDictionary: () -> Unit,
|
||||
|
|
@ -718,6 +730,24 @@ fun PdfAnnotationBottomSheet(
|
|||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var noteText by remember { mutableStateOf(highlight.note ?: "") }
|
||||
var comments by remember(highlight.id) { mutableStateOf(highlight.comments) }
|
||||
var selectedSection by remember(highlight.id) { mutableStateOf(PdfAnnotationSheetSection.NOTE) }
|
||||
var commentText by remember(highlight.id) { mutableStateOf("") }
|
||||
var replyTargetId by remember(highlight.id) { mutableStateOf<String?>(null) }
|
||||
var editingCommentId by remember(highlight.id) { mutableStateOf<String?>(null) }
|
||||
var commentAuthor by remember(highlight.id) {
|
||||
mutableStateOf(
|
||||
highlight.comments
|
||||
.lastOrNull { it.author.isNotBlank() }
|
||||
?.author
|
||||
?: DEFAULT_PDF_COMMENT_AUTHOR
|
||||
)
|
||||
}
|
||||
|
||||
fun persistComments(nextComments: List<SharedPdfAnnotationComment>) {
|
||||
comments = nextComments
|
||||
onUpdate(noteText, nextComments)
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
|
|
@ -775,23 +805,98 @@ fun PdfAnnotationBottomSheet(
|
|||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = noteText,
|
||||
onValueChange = { noteText = it },
|
||||
placeholder = { Text(stringResource(R.string.placeholder_add_note), color = effectiveText.copy(alpha = 0.5f)) },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp),
|
||||
maxLines = 5,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = effectiveText.copy(alpha = 0.3f),
|
||||
focusedTextColor = effectiveText,
|
||||
unfocusedTextColor = effectiveText
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
PdfAnnotationSheetTabs(
|
||||
selectedSection = selectedSection,
|
||||
commentCount = comments.count { it.contents.isNotBlank() },
|
||||
effectiveText = effectiveText,
|
||||
onSectionChange = { selectedSection = it }
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (selectedSection == PdfAnnotationSheetSection.NOTE) {
|
||||
OutlinedTextField(
|
||||
value = noteText,
|
||||
onValueChange = { noteText = it },
|
||||
placeholder = { Text(stringResource(R.string.placeholder_add_note), color = effectiveText.copy(alpha = 0.5f)) },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp),
|
||||
maxLines = 5,
|
||||
colors = pdfAnnotationTextFieldColors(effectiveText),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
} else {
|
||||
PdfHighlightCommentsEditor(
|
||||
comments = comments,
|
||||
commentText = commentText,
|
||||
commentAuthor = commentAuthor,
|
||||
replyTargetId = replyTargetId,
|
||||
editingCommentId = editingCommentId,
|
||||
effectiveText = effectiveText,
|
||||
onCommentTextChange = { commentText = it },
|
||||
onCommentAuthorChange = { commentAuthor = it },
|
||||
onReply = {
|
||||
editingCommentId = null
|
||||
replyTargetId = it.id
|
||||
commentText = ""
|
||||
},
|
||||
onCancelReply = { replyTargetId = null },
|
||||
onEdit = { comment ->
|
||||
editingCommentId = comment.id
|
||||
replyTargetId = null
|
||||
commentText = comment.contents
|
||||
commentAuthor = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
|
||||
},
|
||||
onCancelEdit = {
|
||||
editingCommentId = null
|
||||
commentText = ""
|
||||
},
|
||||
onDelete = { comment ->
|
||||
val nextComments = comments.withoutCommentThread(comment.id)
|
||||
persistComments(nextComments)
|
||||
if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) {
|
||||
replyTargetId = null
|
||||
}
|
||||
if (editingCommentId != null && (editingCommentId == comment.id || nextComments.none { it.id == editingCommentId })) {
|
||||
editingCommentId = null
|
||||
commentText = ""
|
||||
}
|
||||
},
|
||||
onAddComment = {
|
||||
val contents = commentText.trim()
|
||||
if (contents.isNotBlank()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val author = commentAuthor.trim().ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
|
||||
val nextComments = if (editingCommentId != null) {
|
||||
comments.map { comment ->
|
||||
if (comment.id == editingCommentId) {
|
||||
comment.copy(
|
||||
author = author,
|
||||
contents = contents,
|
||||
modifiedAt = now
|
||||
)
|
||||
} else {
|
||||
comment
|
||||
}
|
||||
}
|
||||
} else {
|
||||
comments + SharedPdfAnnotationComment(
|
||||
id = UUID.randomUUID().toString(),
|
||||
parentId = replyTargetId,
|
||||
author = author,
|
||||
contents = contents,
|
||||
createdAt = now,
|
||||
modifiedAt = now
|
||||
)
|
||||
}
|
||||
persistComments(nextComments)
|
||||
commentText = ""
|
||||
replyTargetId = null
|
||||
editingCommentId = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
|
|
@ -811,19 +916,326 @@ fun PdfAnnotationBottomSheet(
|
|||
Text(stringResource(R.string.action_delete))
|
||||
}
|
||||
Button(
|
||||
onClick = { onSave(noteText) },
|
||||
onClick = { onSave(noteText, comments) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.action_save_note))
|
||||
Text(stringResource(R.string.action_done))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfAnnotationSheetTabs(
|
||||
selectedSection: PdfAnnotationSheetSection,
|
||||
commentCount: Int,
|
||||
effectiveText: Color,
|
||||
onSectionChange: (PdfAnnotationSheetSection) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = effectiveText.copy(alpha = 0.06f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(4.dp)) {
|
||||
PdfAnnotationSheetTab(
|
||||
label = stringResource(R.string.label_note),
|
||||
selected = selectedSection == PdfAnnotationSheetSection.NOTE,
|
||||
effectiveText = effectiveText,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onSectionChange(PdfAnnotationSheetSection.NOTE) }
|
||||
)
|
||||
PdfAnnotationSheetTab(
|
||||
label = "${stringResource(R.string.label_comments)} ($commentCount)",
|
||||
selected = selectedSection == PdfAnnotationSheetSection.COMMENTS,
|
||||
effectiveText = effectiveText,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onSectionChange(PdfAnnotationSheetSection.COMMENTS) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfAnnotationSheetTab(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
effectiveText: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else effectiveText,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = modifier
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfHighlightCommentsEditor(
|
||||
comments: List<SharedPdfAnnotationComment>,
|
||||
commentText: String,
|
||||
commentAuthor: String,
|
||||
replyTargetId: String?,
|
||||
editingCommentId: String?,
|
||||
effectiveText: Color,
|
||||
onCommentTextChange: (String) -> Unit,
|
||||
onCommentAuthorChange: (String) -> Unit,
|
||||
onReply: (SharedPdfAnnotationComment) -> Unit,
|
||||
onCancelReply: () -> Unit,
|
||||
onEdit: (SharedPdfAnnotationComment) -> Unit,
|
||||
onCancelEdit: () -> Unit,
|
||||
onDelete: (SharedPdfAnnotationComment) -> Unit,
|
||||
onAddComment: () -> Unit
|
||||
) {
|
||||
val commentIds = comments.filter { it.contents.isNotBlank() }.map { it.id }.toSet()
|
||||
val visibleComments = comments
|
||||
.filter { it.contents.isNotBlank() }
|
||||
.map { comment ->
|
||||
if (comment.parentId != null && comment.parentId !in commentIds) {
|
||||
comment.copy(parentId = null)
|
||||
} else {
|
||||
comment
|
||||
}
|
||||
}
|
||||
val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId }
|
||||
val editingComment = visibleComments.firstOrNull { it.id == editingCommentId }
|
||||
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
PdfHighlightCommentThread(
|
||||
comments = visibleComments,
|
||||
parentId = null,
|
||||
depth = 0,
|
||||
visitedIds = emptySet(),
|
||||
effectiveText = effectiveText,
|
||||
onReply = onReply,
|
||||
onEdit = onEdit,
|
||||
onDelete = onDelete
|
||||
)
|
||||
}
|
||||
|
||||
if (editingComment != null || replyTarget != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (editingComment != null) {
|
||||
stringResource(R.string.label_editing_comment)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.label_replying_to,
|
||||
replyTarget?.author?.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }.orEmpty()
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = effectiveText.copy(alpha = 0.7f),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = if (editingComment != null) onCancelEdit else onCancelReply) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = commentAuthor,
|
||||
onValueChange = onCommentAuthorChange,
|
||||
label = { Text(stringResource(R.string.author)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
colors = pdfAnnotationTextFieldColors(effectiveText),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = commentText,
|
||||
onValueChange = onCommentTextChange,
|
||||
placeholder = {
|
||||
Text(
|
||||
stringResource(R.string.placeholder_add_comment),
|
||||
color = effectiveText.copy(alpha = 0.5f)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
|
||||
maxLines = 4,
|
||||
colors = pdfAnnotationTextFieldColors(effectiveText),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onAddComment, enabled = commentText.isNotBlank()) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (editingComment != null) R.string.action_save_comment else R.string.action_add_comment
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfHighlightCommentThread(
|
||||
comments: List<SharedPdfAnnotationComment>,
|
||||
parentId: String?,
|
||||
depth: Int,
|
||||
visitedIds: Set<String>,
|
||||
effectiveText: Color,
|
||||
onReply: (SharedPdfAnnotationComment) -> Unit,
|
||||
onEdit: (SharedPdfAnnotationComment) -> Unit,
|
||||
onDelete: (SharedPdfAnnotationComment) -> Unit
|
||||
) {
|
||||
comments
|
||||
.filter { it.parentId == parentId }
|
||||
.sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id }))
|
||||
.forEach { comment ->
|
||||
if (comment.id in visitedIds) return@forEach
|
||||
PdfHighlightCommentItem(
|
||||
comment = comment,
|
||||
depth = depth,
|
||||
effectiveText = effectiveText,
|
||||
onReply = { onReply(comment) },
|
||||
onEdit = { onEdit(comment) },
|
||||
onDelete = { onDelete(comment) }
|
||||
)
|
||||
PdfHighlightCommentThread(
|
||||
comments = comments,
|
||||
parentId = comment.id,
|
||||
depth = depth + 1,
|
||||
visitedIds = visitedIds + comment.id,
|
||||
effectiveText = effectiveText,
|
||||
onReply = onReply,
|
||||
onEdit = onEdit,
|
||||
onDelete = onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfHighlightCommentItem(
|
||||
comment: SharedPdfAnnotationComment,
|
||||
depth: Int,
|
||||
effectiveText: Color,
|
||||
onReply: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val indentSize = (depth * 16).dp
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = indentSize, top = 6.dp, bottom = 6.dp)
|
||||
) {
|
||||
if (depth > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.outlineVariant)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR },
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val timestamp = comment.createdAt.formatPdfCommentTimestamp()
|
||||
if (timestamp.isNotBlank()) {
|
||||
Text(
|
||||
text = timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = effectiveText.copy(alpha = 0.55f)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = comment.contents,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = effectiveText
|
||||
)
|
||||
Row {
|
||||
TextButton(onClick = onReply) {
|
||||
Text(stringResource(R.string.action_reply))
|
||||
}
|
||||
TextButton(onClick = onEdit) {
|
||||
Text(stringResource(R.string.label_edit))
|
||||
}
|
||||
TextButton(onClick = onDelete) {
|
||||
Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun pdfAnnotationTextFieldColors(effectiveText: Color) =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = effectiveText.copy(alpha = 0.3f),
|
||||
focusedTextColor = effectiveText,
|
||||
unfocusedTextColor = effectiveText
|
||||
)
|
||||
|
||||
private fun List<SharedPdfAnnotationComment>.withoutCommentThread(commentId: String): List<SharedPdfAnnotationComment> {
|
||||
val childrenByParentId = groupBy { it.parentId }
|
||||
val idsToRemove = mutableSetOf<String>()
|
||||
|
||||
fun collect(id: String) {
|
||||
if (!idsToRemove.add(id)) return
|
||||
childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) }
|
||||
}
|
||||
|
||||
collect(commentId)
|
||||
return filterNot { it.id in idsToRemove }
|
||||
}
|
||||
|
||||
private fun Long.formatPdfCommentTimestamp(): String {
|
||||
if (this <= 0L) return ""
|
||||
return runCatching {
|
||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(this))
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfBottomSheetToolButton(
|
||||
icon: Int,
|
||||
|
|
|
|||
|
|
@ -7,26 +7,18 @@ import androidx.media3.common.util.UnstableApi
|
|||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.tts.TtsPlaybackManager
|
||||
|
||||
internal enum class SaveMode {
|
||||
ORIGINAL, ANNOTATED
|
||||
}
|
||||
internal typealias SaveMode = com.aryan.reader.shared.SaveMode
|
||||
|
||||
enum class SearchHighlightMode {
|
||||
FOCUSED, ALL
|
||||
}
|
||||
typealias SearchHighlightMode = com.aryan.reader.shared.SearchHighlightMode
|
||||
|
||||
internal sealed interface HistoryAction {
|
||||
data class Add(val pageIndex: Int, val annotation: PdfAnnotation) : HistoryAction
|
||||
data class Remove(val items: Map<Int, List<PdfAnnotation>>) : HistoryAction
|
||||
}
|
||||
|
||||
internal enum class DockLocation {
|
||||
TOP, BOTTOM, FLOATING
|
||||
}
|
||||
internal typealias DockLocation = com.aryan.reader.shared.DockLocation
|
||||
|
||||
internal enum class DisplayMode {
|
||||
PAGINATION, VERTICAL_SCROLL
|
||||
}
|
||||
internal typealias DisplayMode = com.aryan.reader.shared.PdfDisplayMode
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Suppress("unused")
|
||||
|
|
|
|||
|
|
@ -154,6 +154,15 @@ fun VerticalScrollbar(
|
|||
|
||||
@Composable
|
||||
internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
|
||||
PageScrubbingAnimation(
|
||||
pageLabel = "Page $currentPage of $totalPages"
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PageScrubbingAnimation(
|
||||
pageLabel: String
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -178,7 +187,7 @@ internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
|
|||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Page $currentPage of $totalPages",
|
||||
text = pageLabel,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
|
@ -188,9 +197,16 @@ internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
|
|||
|
||||
@Composable
|
||||
internal fun ThumbnailWithIndicator(
|
||||
thumbnail: Bitmap, modifier: Modifier = Modifier, onClick: () -> Unit
|
||||
thumbnail: Bitmap,
|
||||
modifier: Modifier = Modifier,
|
||||
borderColor: Color = Color.Unspecified,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val borderColor = MaterialTheme.colorScheme.primary
|
||||
val effectiveBorderColor = if (borderColor == Color.Unspecified) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
borderColor
|
||||
}
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
|
|
@ -198,7 +214,7 @@ internal fun ThumbnailWithIndicator(
|
|||
.height(64.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
border = BorderStroke(2.dp, borderColor)
|
||||
border = BorderStroke(2.dp, effectiveBorderColor)
|
||||
) {
|
||||
Image(
|
||||
bitmap = thumbnail.asImageBitmap(),
|
||||
|
|
@ -211,7 +227,7 @@ internal fun ThumbnailWithIndicator(
|
|||
.offset(y = (-4).dp)
|
||||
.size(8.dp)
|
||||
.rotate(45f)
|
||||
.background(borderColor))
|
||||
.background(effectiveBorderColor))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
357
app/src/main/java/com/aryan/reader/pdf/PdfOneHandZoom.kt
Normal file
357
app/src/main/java/com/aryan/reader/pdf/PdfOneHandZoom.kt
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
||||
import androidx.compose.ui.input.pointer.PointerId
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToDown
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import timber.log.Timber
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.pow
|
||||
|
||||
internal const val PDF_ONE_HAND_ZOOM_TRACE_TAG = "PdfOneHandZoomTrace"
|
||||
internal const val PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS = 90L
|
||||
internal const val PDF_ONE_HAND_ZOOM_DRAG_DISTANCE_FOR_DOUBLE_DP = 240f
|
||||
|
||||
internal enum class PdfSecondTapZoomAction {
|
||||
QUICK_DOUBLE_TAP,
|
||||
ONE_HAND_ZOOM,
|
||||
HELD_NO_MOVEMENT
|
||||
}
|
||||
|
||||
internal fun classifyPdfSecondTapZoomAction(
|
||||
pressDurationMillis: Long,
|
||||
totalDragY: Float,
|
||||
movementSlopPx: Float,
|
||||
holdTimeoutMillis: Long = PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS
|
||||
): PdfSecondTapZoomAction {
|
||||
return when {
|
||||
pressDurationMillis < holdTimeoutMillis -> PdfSecondTapZoomAction.QUICK_DOUBLE_TAP
|
||||
abs(totalDragY) >= movementSlopPx -> PdfSecondTapZoomAction.ONE_HAND_ZOOM
|
||||
else -> PdfSecondTapZoomAction.HELD_NO_MOVEMENT
|
||||
}
|
||||
}
|
||||
|
||||
internal fun pdfOneHandZoomScale(
|
||||
startScale: Float,
|
||||
totalDragY: Float,
|
||||
dragDistanceForDoublePx: Float,
|
||||
minScale: Float,
|
||||
maxScale: Float
|
||||
): Float {
|
||||
val safeStart = startScale.takeIf { it.isFinite() && it > 0f } ?: minScale
|
||||
val safeDistance = dragDistanceForDoublePx.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
val scaleMultiplier = 2f.pow(totalDragY / safeDistance)
|
||||
return (safeStart * scaleMultiplier).coerceIn(minScale, maxScale)
|
||||
}
|
||||
|
||||
internal fun clampCenteredPdfCameraOffset(
|
||||
scale: Float,
|
||||
offset: Offset,
|
||||
viewportSize: Size,
|
||||
contentSize: Size
|
||||
): Offset {
|
||||
if (viewportSize.width <= 0f || viewportSize.height <= 0f || scale <= 1f) {
|
||||
return Offset.Zero
|
||||
}
|
||||
val maxOffsetX = ((contentSize.width * scale) - viewportSize.width).coerceAtLeast(0f) / 2f
|
||||
val maxOffsetY = ((contentSize.height * scale) - viewportSize.height).coerceAtLeast(0f) / 2f
|
||||
return Offset(
|
||||
x = offset.x.coerceIn(-maxOffsetX, maxOffsetX),
|
||||
y = offset.y.coerceIn(-maxOffsetY, maxOffsetY)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun centeredPdfCameraOffsetForScaleChange(
|
||||
previousScale: Float,
|
||||
nextScale: Float,
|
||||
previousOffset: Offset,
|
||||
pivot: Offset,
|
||||
viewportSize: Size,
|
||||
contentSize: Size
|
||||
): Offset {
|
||||
val safePreviousScale = previousScale.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
val ratio = nextScale / safePreviousScale
|
||||
val viewportCenter = Offset(viewportSize.width / 2f, viewportSize.height / 2f)
|
||||
val targetOffset = previousOffset * ratio + (pivot - viewportCenter) * (1f - ratio)
|
||||
return clampCenteredPdfCameraOffset(
|
||||
scale = nextScale,
|
||||
offset = targetOffset,
|
||||
viewportSize = viewportSize,
|
||||
contentSize = contentSize
|
||||
)
|
||||
}
|
||||
|
||||
internal fun topLeftPdfPanForScaleChange(
|
||||
previousScale: Float,
|
||||
nextScale: Float,
|
||||
previousPan: Offset,
|
||||
pivot: Offset
|
||||
): Offset {
|
||||
val safePreviousScale = previousScale.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
val contentPivot = (pivot - previousPan) / safePreviousScale
|
||||
return pivot - (contentPivot * nextScale)
|
||||
}
|
||||
|
||||
private fun Offset.traceString(): String = "(${x.toInt()},${y.toInt()})"
|
||||
|
||||
private fun PointerInputChange.traceString(): String {
|
||||
return "id=$id pos=${position.traceString()} prev=${previousPosition.traceString()} pressed=$pressed consumed=$isConsumed"
|
||||
}
|
||||
|
||||
private fun traceOneHandZoom(message: String) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(message)
|
||||
}
|
||||
|
||||
internal suspend fun PointerInputScope.detectPdfTapAndOneHandZoomGestures(
|
||||
viewConfiguration: ViewConfiguration,
|
||||
canStartOneHandZoom: () -> Boolean,
|
||||
canHandleQuickDoubleTap: () -> Boolean = { true },
|
||||
consumeSingleTap: Boolean = true,
|
||||
onTap: (Offset) -> Unit,
|
||||
onQuickDoubleTap: (Offset) -> Unit,
|
||||
onOneHandZoomHoldStart: (Offset) -> Unit,
|
||||
onOneHandZoom: (pivot: Offset, totalDragY: Float) -> Unit,
|
||||
onOneHandZoomEnd: (started: Boolean) -> Unit
|
||||
) {
|
||||
awaitEachGesture {
|
||||
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
||||
traceOneHandZoom(
|
||||
"detector.firstDown ${firstDown.traceString()} consumeSingleTap=$consumeSingleTap " +
|
||||
"doubleTapTimeout=${viewConfiguration.doubleTapTimeoutMillis} holdTimeout=$PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS"
|
||||
)
|
||||
val firstUp = waitForUpOrCancellation()
|
||||
if (firstUp == null) {
|
||||
traceOneHandZoom("detector.firstUpCanceled first=${firstDown.traceString()}")
|
||||
return@awaitEachGesture
|
||||
}
|
||||
traceOneHandZoom("detector.firstUp ${firstUp.traceString()}")
|
||||
|
||||
val secondDown = awaitPdfSecondDown(
|
||||
firstPointerId = firstDown.id,
|
||||
timeoutMillis = viewConfiguration.doubleTapTimeoutMillis
|
||||
)
|
||||
|
||||
if (secondDown == null) {
|
||||
traceOneHandZoom(
|
||||
"detector.singleTap noSecondDown consume=$consumeSingleTap firstUpConsumedBefore=${firstUp.isConsumed} " +
|
||||
"tap=${firstDown.position.traceString()}"
|
||||
)
|
||||
if (consumeSingleTap) firstUp.consume()
|
||||
onTap(firstDown.position)
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
val pivot = secondDown.position
|
||||
var latestPosition = pivot
|
||||
var quickDoubleTapUp: PointerInputChange? = null
|
||||
var canceled = false
|
||||
val oneHandAllowed = canStartOneHandZoom()
|
||||
val movementSlopPx = max(2f, viewConfiguration.touchSlop * 0.35f)
|
||||
var shouldStartOneHandZoom = false
|
||||
traceOneHandZoom(
|
||||
"detector.secondDown ${secondDown.traceString()} oneHandAllowed=$oneHandAllowed " +
|
||||
"quickAllowed=${canHandleQuickDoubleTap()} movementSlop=$movementSlopPx touchSlop=${viewConfiguration.touchSlop}"
|
||||
)
|
||||
|
||||
try {
|
||||
withTimeout(PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS) {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == secondDown.id }
|
||||
if (change == null) {
|
||||
canceled = true
|
||||
traceOneHandZoom(
|
||||
"detector.preHoldCancel missingSecondPointer changes=${event.changes.joinToString { it.traceString() }}"
|
||||
)
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
latestPosition = change.position
|
||||
|
||||
if (change.changedToUp()) {
|
||||
quickDoubleTapUp = change
|
||||
traceOneHandZoom("detector.preHoldQuickUp ${change.traceString()}")
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
if (change.isConsumed) {
|
||||
canceled = true
|
||||
traceOneHandZoom(
|
||||
"detector.preHoldCancel consumedByOther ${change.traceString()} " +
|
||||
"allChanges=${event.changes.joinToString { it.traceString() }}"
|
||||
)
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
if (oneHandAllowed) {
|
||||
val delta = change.position - pivot
|
||||
val isVerticalZoomDrag = abs(delta.y) >= movementSlopPx &&
|
||||
abs(delta.y) >= abs(delta.x) * 1.1f
|
||||
if (isVerticalZoomDrag) {
|
||||
shouldStartOneHandZoom = true
|
||||
traceOneHandZoom(
|
||||
"detector.preHoldStart verticalDrag delta=${delta.traceString()} " +
|
||||
"slop=$movementSlopPx change=${change.traceString()}"
|
||||
)
|
||||
change.consume()
|
||||
return@withTimeout
|
||||
} else if (delta.getDistance() >= viewConfiguration.touchSlop) {
|
||||
canceled = true
|
||||
traceOneHandZoom(
|
||||
"detector.preHoldCancel nonZoomMove delta=${delta.traceString()} " +
|
||||
"distance=${delta.getDistance()} touchSlop=${viewConfiguration.touchSlop}"
|
||||
)
|
||||
return@withTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: PointerEventTimeoutCancellationException) {
|
||||
// The second tap is being held, so the quick double-tap action is suppressed.
|
||||
shouldStartOneHandZoom = true
|
||||
traceOneHandZoom(
|
||||
"detector.holdTimeout pivot=${pivot.traceString()} latest=${latestPosition.traceString()} " +
|
||||
"oneHandAllowed=$oneHandAllowed"
|
||||
)
|
||||
}
|
||||
|
||||
if (canceled) {
|
||||
traceOneHandZoom("detector.end canceledBeforeAction latest=${latestPosition.traceString()}")
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
if (quickDoubleTapUp != null) {
|
||||
val quickAllowed = canHandleQuickDoubleTap()
|
||||
traceOneHandZoom(
|
||||
"detector.quickDoubleTap fire quickAllowed=$quickAllowed upConsumedBefore=${quickDoubleTapUp?.isConsumed} " +
|
||||
"pivot=${pivot.traceString()}"
|
||||
)
|
||||
if (quickAllowed) {
|
||||
quickDoubleTapUp?.consume()
|
||||
}
|
||||
onQuickDoubleTap(pivot)
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
if (!oneHandAllowed) {
|
||||
traceOneHandZoom("detector.oneHandBlocked waitingForUp pivot=${pivot.traceString()}")
|
||||
waitForUpOrCancellation()
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
if (!shouldStartOneHandZoom) {
|
||||
traceOneHandZoom("detector.end noAction shouldStart=false latest=${latestPosition.traceString()}")
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
var zoomStarted = false
|
||||
|
||||
fun updateZoom(position: Offset) {
|
||||
val totalDragY = position.y - pivot.y
|
||||
val action = classifyPdfSecondTapZoomAction(
|
||||
pressDurationMillis = PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS,
|
||||
totalDragY = totalDragY,
|
||||
movementSlopPx = movementSlopPx
|
||||
)
|
||||
if (action == PdfSecondTapZoomAction.ONE_HAND_ZOOM) {
|
||||
if (!zoomStarted) {
|
||||
traceOneHandZoom(
|
||||
"detector.oneHandZoomStart dragY=$totalDragY pivot=${pivot.traceString()} " +
|
||||
"position=${position.traceString()} slop=$movementSlopPx"
|
||||
)
|
||||
}
|
||||
zoomStarted = true
|
||||
}
|
||||
if (zoomStarted) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
|
||||
"detector.oneHandZoomUpdate dragY=$totalDragY pivot=${pivot.traceString()} position=${position.traceString()}"
|
||||
)
|
||||
onOneHandZoom(pivot, totalDragY)
|
||||
}
|
||||
}
|
||||
|
||||
traceOneHandZoom(
|
||||
"detector.oneHandHoldStart pivot=${pivot.traceString()} latest=${latestPosition.traceString()}"
|
||||
)
|
||||
onOneHandZoomHoldStart(pivot)
|
||||
try {
|
||||
updateZoom(latestPosition)
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == secondDown.id }
|
||||
if (change == null) {
|
||||
traceOneHandZoom(
|
||||
"detector.postHoldEnd missingSecondPointer changes=${event.changes.joinToString { it.traceString() }}"
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
latestPosition = change.position
|
||||
if (change.isConsumed) {
|
||||
traceOneHandZoom(
|
||||
"detector.postHoldEnd consumedByOther ${change.traceString()} zoomStarted=$zoomStarted"
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
val isPositionChanged = change.positionChanged()
|
||||
val isUp = change.changedToUp()
|
||||
updateZoom(latestPosition)
|
||||
|
||||
if (isPositionChanged || zoomStarted || isUp) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
|
||||
"detector.consumePostHold changed=$isPositionChanged zoomStarted=$zoomStarted up=$isUp " +
|
||||
change.traceString()
|
||||
)
|
||||
change.consume()
|
||||
}
|
||||
|
||||
if (isUp) {
|
||||
traceOneHandZoom(
|
||||
"detector.oneHandPointerUp zoomStarted=$zoomStarted latest=${latestPosition.traceString()}"
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
traceOneHandZoom(
|
||||
"detector.oneHandEnd zoomStarted=$zoomStarted latest=${latestPosition.traceString()}"
|
||||
)
|
||||
onOneHandZoomEnd(zoomStarted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun androidx.compose.ui.input.pointer.AwaitPointerEventScope.awaitPdfSecondDown(
|
||||
firstPointerId: PointerId,
|
||||
timeoutMillis: Long
|
||||
): PointerInputChange? {
|
||||
return try {
|
||||
withTimeout(timeoutMillis) {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val secondDown = event.changes.firstOrNull {
|
||||
it.id != firstPointerId && it.changedToDown()
|
||||
} ?: event.changes.firstOrNull {
|
||||
it.id == firstPointerId && it.changedToDown()
|
||||
}
|
||||
if (secondDown != null) return@withTimeout secondDown
|
||||
}
|
||||
null
|
||||
}
|
||||
} catch (_: PointerEventTimeoutCancellationException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.pdf.data.PdfTextBox
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal fun remapPdfAnnotationsForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
annotations: Map<Int, List<PdfAnnotation>>
|
||||
): Map<Int, List<PdfAnnotation>> {
|
||||
if (annotations.isEmpty()) return emptyMap()
|
||||
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = annotations.keys
|
||||
)
|
||||
val remapped = linkedMapOf<Int, MutableList<PdfAnnotation>>()
|
||||
|
||||
annotations.toSortedMap().forEach { (sourcePageIndex, pageAnnotations) ->
|
||||
val targetPageIndex = mapping[sourcePageIndex] ?: return@forEach
|
||||
pageAnnotations.forEach { annotation ->
|
||||
remapped.getOrPut(targetPageIndex) { mutableListOf() }
|
||||
.add(annotation.copy(pageIndex = targetPageIndex))
|
||||
}
|
||||
}
|
||||
|
||||
return remapped.mapValues { (_, pageAnnotations) -> pageAnnotations.toList() }
|
||||
}
|
||||
|
||||
internal fun remapPdfTextBoxesForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
textBoxes: List<PdfTextBox>
|
||||
): List<PdfTextBox> {
|
||||
if (textBoxes.isEmpty()) return emptyList()
|
||||
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = textBoxes.map { it.pageIndex }
|
||||
)
|
||||
|
||||
return textBoxes.mapNotNull { box ->
|
||||
mapping[box.pageIndex]?.let { targetPageIndex ->
|
||||
box.copy(pageIndex = targetPageIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun remapPdfUserHighlightsForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
highlights: List<PdfUserHighlight>
|
||||
): List<PdfUserHighlight> {
|
||||
if (highlights.isEmpty()) return emptyList()
|
||||
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = highlights.map { it.pageIndex }
|
||||
)
|
||||
|
||||
return highlights.mapNotNull { highlight ->
|
||||
mapping[highlight.pageIndex]?.let { targetPageIndex ->
|
||||
highlight.copy(pageIndex = targetPageIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun remapPdfHistoryActionsForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
actions: List<HistoryAction>
|
||||
): List<HistoryAction> {
|
||||
if (actions.isEmpty()) return emptyList()
|
||||
|
||||
return actions.mapNotNull { action ->
|
||||
when (action) {
|
||||
is HistoryAction.Add -> {
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = listOf(action.pageIndex)
|
||||
)
|
||||
val targetPageIndex = mapping[action.pageIndex] ?: return@mapNotNull null
|
||||
action.copy(
|
||||
pageIndex = targetPageIndex,
|
||||
annotation = action.annotation.copy(pageIndex = targetPageIndex)
|
||||
)
|
||||
}
|
||||
is HistoryAction.Remove -> {
|
||||
val remappedItems = remapPdfAnnotationsForLayoutChange(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
annotations = action.items
|
||||
)
|
||||
remappedItems.takeIf { it.isNotEmpty() }?.let(HistoryAction::Remove)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun remapPdfBookmarksJsonForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
currentBookmarksJson: String
|
||||
): String {
|
||||
if (currentBookmarksJson.isBlank()) return "[]"
|
||||
|
||||
val jsonArray = JSONArray(currentBookmarksJson)
|
||||
val sourcePageIndices = buildList {
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val pageIndex = jsonArray.optJSONObject(i)?.optInt("pageIndex", Int.MIN_VALUE)
|
||||
if (pageIndex != null && pageIndex != Int.MIN_VALUE) add(pageIndex)
|
||||
}
|
||||
}
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = sourcePageIndices
|
||||
)
|
||||
val newArray = JSONArray()
|
||||
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val obj = jsonArray.getJSONObject(i)
|
||||
val sourcePageIndex = obj.optInt("pageIndex", Int.MIN_VALUE)
|
||||
val targetPageIndex = mapping[sourcePageIndex] ?: continue
|
||||
val newObj = JSONObject(obj.toString())
|
||||
newObj.put("pageIndex", targetPageIndex)
|
||||
newObj.put("totalPages", updatedLayout.size)
|
||||
newArray.put(newObj)
|
||||
}
|
||||
|
||||
return newArray.toString()
|
||||
}
|
||||
|
||||
internal fun buildPdfPageIndexMapping(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
sourcePageIndices: Iterable<Int>
|
||||
): Map<Int, Int> {
|
||||
val distinctSourcePageIndices = sourcePageIndices.toSet()
|
||||
if (distinctSourcePageIndices.isEmpty()) return emptyMap()
|
||||
|
||||
val minimumCurrentPageCount = maxOf(
|
||||
currentLayout.size,
|
||||
distinctSourcePageIndices.maxOrNull()?.plus(1) ?: 0
|
||||
)
|
||||
val effectiveCurrentLayout = currentLayout.withDefaultPdfPagesUntil(minimumCurrentPageCount)
|
||||
val currentTokens = effectiveCurrentLayout.toOccurrenceTokens()
|
||||
val updatedTokenIndices = updatedLayout.toOccurrenceTokens()
|
||||
.mapIndexed { index, token -> token to index }
|
||||
.toMap()
|
||||
|
||||
return distinctSourcePageIndices.mapNotNull { sourcePageIndex ->
|
||||
val token = currentTokens.getOrNull(sourcePageIndex) ?: return@mapNotNull null
|
||||
val targetPageIndex = updatedTokenIndices[token] ?: return@mapNotNull null
|
||||
sourcePageIndex to targetPageIndex
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun List<VirtualPage>.withDefaultPdfPagesUntil(pageCount: Int): List<VirtualPage> {
|
||||
if (size >= pageCount) return this
|
||||
return this + (size until pageCount).map { VirtualPage.PdfPage(it) }
|
||||
}
|
||||
|
||||
private fun List<VirtualPage>.toOccurrenceTokens(): List<VirtualPageOccurrenceToken> {
|
||||
val seen = mutableMapOf<VirtualPageKey, Int>()
|
||||
return map { page ->
|
||||
val key = page.toVirtualPageKey()
|
||||
val occurrence = seen.getOrDefault(key, 0)
|
||||
seen[key] = occurrence + 1
|
||||
VirtualPageOccurrenceToken(key, occurrence)
|
||||
}
|
||||
}
|
||||
|
||||
private fun VirtualPage.toVirtualPageKey(): VirtualPageKey {
|
||||
return when (this) {
|
||||
is VirtualPage.PdfPage -> VirtualPageKey.Pdf(pdfIndex)
|
||||
is VirtualPage.BlankPage -> VirtualPageKey.Blank(id)
|
||||
}
|
||||
}
|
||||
|
||||
private data class VirtualPageOccurrenceToken(
|
||||
val key: VirtualPageKey,
|
||||
val occurrence: Int
|
||||
)
|
||||
|
||||
private sealed interface VirtualPageKey {
|
||||
data class Pdf(val pdfIndex: Int) : VirtualPageKey
|
||||
data class Blank(val id: String) : VirtualPageKey
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
22
app/src/main/java/com/aryan/reader/pdf/PdfPageLayoutDebug.kt
Normal file
22
app/src/main/java/com/aryan/reader/pdf/PdfPageLayoutDebug.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
|
||||
internal const val PDF_BLANK_PAGE_PERSISTENCE_TAG = "PdfBlankPagePersist"
|
||||
|
||||
internal fun List<VirtualPage>.pdfLayoutDebugSummary(maxPages: Int = 16): String {
|
||||
val blankPages = filterIsInstance<VirtualPage.BlankPage>()
|
||||
val sample = take(maxPages).mapIndexed { displayIndex, page ->
|
||||
when (page) {
|
||||
is VirtualPage.PdfPage -> "$displayIndex:P${page.pdfIndex}"
|
||||
is VirtualPage.BlankPage ->
|
||||
"$displayIndex:B(${page.id.take(8)},${page.width}x${page.height},manual=${page.wasManuallyAdded})"
|
||||
}
|
||||
}.let { pages ->
|
||||
if (size > maxPages) pages + "...(+${size - maxPages})" else pages
|
||||
}
|
||||
|
||||
return "size=$size pdf=${count { it is VirtualPage.PdfPage }} " +
|
||||
"blank=${blankPages.size} manualBlank=${blankPages.count { it.wasManuallyAdded }} " +
|
||||
"pages=${sample.joinToString(prefix = "[", postfix = "]")}"
|
||||
}
|
||||
25
app/src/main/java/com/aryan/reader/pdf/PdfPageLinks.kt
Normal file
25
app/src/main/java/com/aryan/reader/pdf/PdfPageLinks.kt
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.graphics.Rect
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
|
||||
enum class LinkSource {
|
||||
ANNOTATION, TEXT_CONTENT
|
||||
}
|
||||
|
||||
data class PageLink(
|
||||
val highlightBounds: Rect,
|
||||
val tapBounds: Rect,
|
||||
val url: String?,
|
||||
val destPageIdx: Int?,
|
||||
val source: LinkSource
|
||||
)
|
||||
|
||||
internal fun pdfRenderPageId(documentKey: String, pageIndex: Int, virtualPage: VirtualPage?): String {
|
||||
val sourcePageId = when (virtualPage) {
|
||||
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
|
||||
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
|
||||
null -> "PDF_$pageIndex"
|
||||
}
|
||||
return "$documentKey:$sourcePageId"
|
||||
}
|
||||
422
app/src/main/java/com/aryan/reader/pdf/PdfPageRenderResources.kt
Normal file
422
app/src/main/java/com/aryan/reader/pdf/PdfPageRenderResources.kt
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.set
|
||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import android.graphics.Color as AndroidColor
|
||||
|
||||
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
|
||||
|
||||
object PdfInkGeometry {
|
||||
fun calculateFountainPenPoints(
|
||||
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
|
||||
): Pair<List<Offset>, List<Offset>> {
|
||||
if (points.size < 2) return Pair(emptyList(), emptyList())
|
||||
|
||||
if (points.size % 50 == 0) {
|
||||
Timber.tag("FountainPenDebug").d(
|
||||
"Calculate Points: PWidth=$pageWidth, PHeight=$pageHeight, BaseW=$baseWidth, Pts=${points.size}"
|
||||
)
|
||||
}
|
||||
|
||||
val leftSide = mutableListOf<Offset>()
|
||||
val rightSide = mutableListOf<Offset>()
|
||||
|
||||
val computedWidths = FloatArray(points.size)
|
||||
computedWidths[0] = baseWidth
|
||||
|
||||
val velocityFactor = 300f
|
||||
|
||||
for (i in 1 until points.size) {
|
||||
val p1 = points[i - 1]
|
||||
val p2 = points[i]
|
||||
|
||||
val dxNorm = p2.x - p1.x
|
||||
val dyNorm = p2.y - p1.y
|
||||
val aspect = if (pageWidth > 0 && pageHeight > 0) pageHeight / pageWidth else 1f
|
||||
val distNorm = sqrt(dxNorm * dxNorm + (dyNorm * aspect) * (dyNorm * aspect))
|
||||
|
||||
val timeDelta = (p2.timestamp - p1.timestamp).coerceAtLeast(1)
|
||||
val velocityNorm = distNorm / timeDelta
|
||||
|
||||
val targetWidth = (baseWidth * (1f / (1f + velocityNorm * velocityFactor))).coerceIn(
|
||||
baseWidth * 0.2f, baseWidth * 1.4f
|
||||
)
|
||||
|
||||
computedWidths[i] = computedWidths[i - 1] * 0.6f + targetWidth * 0.4f
|
||||
|
||||
if (i < 5) {
|
||||
Timber.tag("FountainPenDebug").v(
|
||||
"Pt[$i]: dt=$timeDelta, velNorm=$velocityNorm, width=${computedWidths[i]} (base=$baseWidth)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (i in 0 until points.size - 1) {
|
||||
val pCurrent = points[i]
|
||||
val pNext = points[i + 1]
|
||||
|
||||
val curX = pCurrent.x * pageWidth
|
||||
val curY = pCurrent.y * pageHeight
|
||||
val nextX = pNext.x * pageWidth
|
||||
val nextY = pNext.y * pageHeight
|
||||
|
||||
val angle = atan2(nextY - curY, nextX - curX)
|
||||
val normalAngle = angle - (PI / 2f).toFloat()
|
||||
|
||||
val w = computedWidths[i] / 2f
|
||||
|
||||
leftSide.add(Offset((curX + cos(normalAngle) * w), (curY + sin(normalAngle) * w)))
|
||||
rightSide.add(Offset((curX - cos(normalAngle) * w), (curY - sin(normalAngle) * w)))
|
||||
}
|
||||
|
||||
val lastIdx = points.lastIndex
|
||||
val lastP = points[lastIdx]
|
||||
val prevP = points[lastIdx - 1]
|
||||
|
||||
val lastX = lastP.x * pageWidth
|
||||
val lastY = lastP.y * pageHeight
|
||||
val prevX = prevP.x * pageWidth
|
||||
val prevY = prevP.y * pageHeight
|
||||
|
||||
val lastAngle = atan2(lastY - prevY, lastX - prevX)
|
||||
val lastNormal = lastAngle - (PI / 2f).toFloat()
|
||||
val lastW = computedWidths[lastIdx] / 2f
|
||||
|
||||
leftSide.add(Offset((lastX + cos(lastNormal) * lastW), (lastY + sin(lastNormal) * lastW)))
|
||||
rightSide.add(Offset((lastX - cos(lastNormal) * lastW), (lastY - sin(lastNormal) * lastW)))
|
||||
|
||||
return Pair(leftSide, rightSide)
|
||||
}
|
||||
}
|
||||
|
||||
internal object PdfBitmapPool {
|
||||
private val pool = ConcurrentLinkedQueue<Bitmap>()
|
||||
private const val MAX_POOL_SIZE = 4
|
||||
|
||||
fun get(width: Int, height: Int): Bitmap {
|
||||
val iterator = pool.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val bitmap = iterator.next()
|
||||
if (bitmap.width == width && bitmap.height == height && !bitmap.isRecycled) {
|
||||
iterator.remove()
|
||||
bitmap.eraseColor(AndroidColor.TRANSPARENT)
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
return createBitmap(width, height)
|
||||
}
|
||||
|
||||
fun get(size: Int): Bitmap = get(size, size)
|
||||
|
||||
fun recycle(bitmap: Bitmap) {
|
||||
// Overflow bitmaps are left for GC; HWUI may still reference recently drawn bitmaps.
|
||||
if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) {
|
||||
pool.offer(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
while (!pool.isEmpty()) {
|
||||
pool.poll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal object PdfThumbnailCache {
|
||||
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
|
||||
private val cacheSize = maxMemory / 8
|
||||
|
||||
private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int)
|
||||
|
||||
private val memoryCache = object : LruCache<String, CacheEntry>(cacheSize) {
|
||||
override fun sizeOf(key: String, entry: CacheEntry): Int {
|
||||
return entry.sizeKb
|
||||
}
|
||||
}
|
||||
|
||||
fun get(pageId: String): Bitmap? {
|
||||
return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled }
|
||||
}
|
||||
|
||||
fun put(pageId: String, bitmap: Bitmap) {
|
||||
if (get(pageId) == null) {
|
||||
val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1)
|
||||
memoryCache.put(pageId, CacheEntry(bitmap, sizeKb))
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
memoryCache.evictAll()
|
||||
}
|
||||
}
|
||||
|
||||
internal object PdfTextureGenerator {
|
||||
private var noiseBitmap: Bitmap? = null
|
||||
|
||||
fun getNoiseTexture(): Bitmap {
|
||||
if (noiseBitmap == null) {
|
||||
val size = 256
|
||||
val bitmap = createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
for (x in 0 until size) {
|
||||
for (y in 0 until size) {
|
||||
val isGrain = Math.random() > 0.4
|
||||
if (isGrain) {
|
||||
val alpha = (Math.random() * 100 + 100).toInt()
|
||||
bitmap[x, y] = AndroidColor.argb(alpha, 0, 0, 0)
|
||||
} else {
|
||||
bitmap[x, y] = AndroidColor.TRANSPARENT
|
||||
}
|
||||
}
|
||||
}
|
||||
noiseBitmap = bitmap
|
||||
}
|
||||
return noiseBitmap!!
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface AnnotationRenderData {
|
||||
data class Standard(
|
||||
val path: Path,
|
||||
val color: Color,
|
||||
val strokeWidth: Float,
|
||||
val cap: StrokeCap,
|
||||
val blendMode: BlendMode
|
||||
) : AnnotationRenderData
|
||||
|
||||
data class Fountain(val path: Path, val color: Color) : AnnotationRenderData
|
||||
|
||||
data class Pencil(
|
||||
val path: android.graphics.Path,
|
||||
val color: Color,
|
||||
val strokeWidth: Float,
|
||||
val velocityAlpha: Float
|
||||
) : AnnotationRenderData
|
||||
}
|
||||
|
||||
internal object PdfAnnotationRenderHelper {
|
||||
fun createRenderData(annot: PdfAnnotation, widthPx: Int, heightPx: Int): AnnotationRenderData? {
|
||||
val startTime = System.nanoTime()
|
||||
if (annot.points.isEmpty()) return null
|
||||
|
||||
if (annot.points.size == 1) {
|
||||
val point = annot.points[0]
|
||||
val x = point.x * widthPx
|
||||
val y = point.y * heightPx
|
||||
|
||||
val path = if (annot.inkType == InkType.PENCIL) android.graphics.Path() else Path()
|
||||
|
||||
if (path is android.graphics.Path) {
|
||||
path.moveTo(x, y)
|
||||
path.lineTo(x, y)
|
||||
return AnnotationRenderData.Pencil(
|
||||
path = path,
|
||||
color = annot.color,
|
||||
strokeWidth = annot.strokeWidth * widthPx,
|
||||
velocityAlpha = 1.0f
|
||||
)
|
||||
} else if (path is Path) {
|
||||
if (annot.inkType == InkType.FOUNTAIN_PEN) {
|
||||
val radius = (annot.strokeWidth * widthPx) / 2f
|
||||
path.addOval(
|
||||
androidx.compose.ui.geometry.Rect(
|
||||
center = Offset(x, y), radius = radius
|
||||
)
|
||||
)
|
||||
return AnnotationRenderData.Fountain(path = path, color = annot.color)
|
||||
}
|
||||
|
||||
path.moveTo(x, y)
|
||||
path.lineTo(x, y)
|
||||
|
||||
val cap = when (annot.inkType) {
|
||||
InkType.HIGHLIGHTER -> StrokeCap.Butt
|
||||
InkType.HIGHLIGHTER_ROUND -> StrokeCap.Round
|
||||
else -> StrokeCap.Round
|
||||
}
|
||||
|
||||
return AnnotationRenderData.Standard(
|
||||
path = path,
|
||||
color = annot.color,
|
||||
strokeWidth = annot.strokeWidth * widthPx,
|
||||
cap = cap,
|
||||
blendMode = BlendMode.SrcOver
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val result = when (annot.inkType) {
|
||||
InkType.PENCIL -> {
|
||||
val path = android.graphics.Path()
|
||||
val first = annot.points[0]
|
||||
path.moveTo(first.x * widthPx, first.y * heightPx)
|
||||
var totalDist = 0f
|
||||
for (i in 1 until annot.points.size) {
|
||||
val p0 = annot.points[i - 1]
|
||||
val p1 = annot.points[i]
|
||||
val p0x = p0.x * widthPx
|
||||
val p0y = p0.y * heightPx
|
||||
val p1x = p1.x * widthPx
|
||||
val p1y = p1.y * heightPx
|
||||
val midX = (p0x + p1x) / 2f
|
||||
val midY = (p0y + p1y) / 2f
|
||||
val dx = p1x - p0x
|
||||
val dy = p1y - p0y
|
||||
totalDist += sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (i == 1) path.lineTo(midX, midY)
|
||||
else path.quadTo(p0x, p0y, midX, midY)
|
||||
}
|
||||
val last = annot.points.last()
|
||||
path.lineTo(last.x * widthPx, last.y * heightPx)
|
||||
|
||||
val duration =
|
||||
(annot.points.last().timestamp - annot.points.first().timestamp).coerceAtLeast(1)
|
||||
val velocity = totalDist / duration
|
||||
val velocityAlphaFactor = (1f - (velocity - 0.2f) / 1.8f).coerceIn(0.4f, 1.0f)
|
||||
|
||||
AnnotationRenderData.Pencil(
|
||||
path = path,
|
||||
color = annot.color,
|
||||
strokeWidth = annot.strokeWidth * widthPx,
|
||||
velocityAlpha = velocityAlphaFactor
|
||||
)
|
||||
}
|
||||
|
||||
InkType.FOUNTAIN_PEN -> {
|
||||
val baseStrokeWidth = annot.strokeWidth * widthPx
|
||||
val path = Path()
|
||||
|
||||
val (leftSide, rightSide) = PdfInkGeometry.calculateFountainPenPoints(
|
||||
annot.points, baseStrokeWidth, widthPx.toFloat(), heightPx.toFloat()
|
||||
)
|
||||
|
||||
if (leftSide.isNotEmpty()) {
|
||||
path.moveTo(leftSide[0].x, leftSide[0].y)
|
||||
|
||||
for (i in 1 until leftSide.size) {
|
||||
path.lineTo(leftSide[i].x, leftSide[i].y)
|
||||
}
|
||||
|
||||
for (i in rightSide.size - 1 downTo 0) {
|
||||
path.lineTo(rightSide[i].x, rightSide[i].y)
|
||||
}
|
||||
|
||||
path.close()
|
||||
}
|
||||
|
||||
AnnotationRenderData.Fountain(path = path, color = annot.color)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val path = Path()
|
||||
val first = annot.points[0]
|
||||
path.moveTo(first.x * widthPx, first.y * heightPx)
|
||||
for (i in 1 until annot.points.size) {
|
||||
val p0 = annot.points[i - 1]
|
||||
val p1 = annot.points[i]
|
||||
val p0x = p0.x * widthPx
|
||||
val p0y = p0.y * heightPx
|
||||
val p1x = p1.x * widthPx
|
||||
val p1y = p1.y * heightPx
|
||||
val midX = (p0x + p1x) / 2f
|
||||
val midY = (p0y + p1y) / 2f
|
||||
if (i == 1) path.lineTo(midX, midY)
|
||||
else path.quadraticTo(p0x, p0y, midX, midY)
|
||||
}
|
||||
val last = annot.points.last()
|
||||
path.lineTo(last.x * widthPx, last.y * heightPx)
|
||||
|
||||
val blendMode = when (annot.inkType) {
|
||||
InkType.HIGHLIGHTER, InkType.HIGHLIGHTER_ROUND -> BlendMode.Multiply
|
||||
else -> BlendMode.SrcOver
|
||||
}
|
||||
|
||||
val cap = when (annot.inkType) {
|
||||
InkType.HIGHLIGHTER -> StrokeCap.Butt
|
||||
InkType.HIGHLIGHTER_ROUND -> StrokeCap.Round
|
||||
else -> StrokeCap.Round
|
||||
}
|
||||
|
||||
AnnotationRenderData.Standard(
|
||||
path = path,
|
||||
color = annot.color,
|
||||
strokeWidth = annot.strokeWidth * widthPx,
|
||||
cap = cap,
|
||||
blendMode = blendMode
|
||||
)
|
||||
}
|
||||
}
|
||||
val duration = (System.nanoTime() - startTime) / 1_000_000f
|
||||
if (duration > 1f) {
|
||||
Timber.tag("PdfPerf").v("Path Gen: Type=${annot.inkType}, Pts=${annot.points.size}, Time=${duration}ms")
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class PdfDrawingState {
|
||||
var currentAnnotation by mutableStateOf<PdfAnnotation?>(null)
|
||||
private set
|
||||
private val currentPoints = mutableListOf<PdfPoint>()
|
||||
|
||||
fun onDrawStart(pageIndex: Int, point: PdfPoint, type: InkType, color: Color, width: Float) {
|
||||
currentPoints.clear()
|
||||
currentPoints.add(point)
|
||||
currentAnnotation = PdfAnnotation(
|
||||
type = AnnotationType.INK,
|
||||
inkType = type,
|
||||
pageIndex = pageIndex,
|
||||
points = currentPoints.toList(),
|
||||
color = color,
|
||||
strokeWidth = width
|
||||
)
|
||||
}
|
||||
|
||||
fun onDraw(point: PdfPoint) {
|
||||
currentPoints.add(point)
|
||||
currentAnnotation = currentAnnotation?.copy(points = currentPoints.toList())
|
||||
}
|
||||
|
||||
fun onDrawCancel() {
|
||||
currentAnnotation = null
|
||||
currentPoints.clear()
|
||||
}
|
||||
|
||||
fun onDrawEnd(): PdfAnnotation? {
|
||||
val finalAnnot = currentAnnotation
|
||||
currentAnnotation = null
|
||||
currentPoints.clear()
|
||||
return finalAnnot
|
||||
}
|
||||
|
||||
fun updateDrag(point: PdfPoint) {
|
||||
if (currentPoints.isNotEmpty()) {
|
||||
val start = currentPoints.first()
|
||||
currentPoints.clear()
|
||||
currentPoints.add(start)
|
||||
currentPoints.add(point)
|
||||
currentAnnotation = currentAnnotation?.copy(points = currentPoints.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@ import androidx.compose.ui.graphics.toArgb
|
|||
import androidx.core.content.edit
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.ReaderTheme
|
||||
import com.aryan.reader.ReaderTexture
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import com.aryan.reader.shared.BuiltInPdfReaderThemes
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
|
||||
internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll"
|
||||
internal const val SETTINGS_PREFS_NAME = "epub_reader_settings"
|
||||
|
|
@ -33,7 +33,6 @@ private const val PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX = "pdf_as_local_speed_"
|
|||
private const val PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX = "pdf_as_local_min_"
|
||||
private const val PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX = "pdf_as_local_max_"
|
||||
private const val PDF_SCROLL_LOCKED_PREFIX = "pdf_sl_local_"
|
||||
internal const val PDF_FULL_SCREEN_PREFIX = "pdf_fs_local_"
|
||||
private const val PDF_MUSICIAN_MODE_KEY = "pdf_musician_mode_enabled"
|
||||
private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
|
||||
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
|
||||
|
|
@ -47,17 +46,21 @@ internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
|
|||
internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
|
||||
internal const val PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY = "pdf_vertical_page_gap_visible"
|
||||
internal const val PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY = "pdf_page_number_overlay_visible"
|
||||
internal const val PDF_TOP_TAB_STRIP_VISIBLE_KEY = "pdf_top_tab_strip_visible"
|
||||
internal const val PDF_PAGE_SPREAD_MODE_KEY = "pdf_page_spread_mode"
|
||||
internal const val PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY = "pdf_first_page_standalone_in_spread"
|
||||
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version"
|
||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2
|
||||
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 3
|
||||
|
||||
enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) {
|
||||
DICTIONARY(R.string.tool_external_apps, "Top Bar"),
|
||||
THEME(R.string.tooltip_theme_desc, "Top Bar"),
|
||||
BRIGHTNESS(R.string.tool_brightness, "Top Bar"),
|
||||
LOCK_PANNING(R.string.tooltip_lock_pan, "Top Bar"),
|
||||
FILE_INFO(R.string.file_information, "Overflow Menu"),
|
||||
VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"),
|
||||
TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"),
|
||||
FULL_SCREEN(R.string.tooltip_fullscreen, "Top Bar"),
|
||||
SLIDER(R.string.tool_navigation_slider, "Bottom Bar"),
|
||||
TOC(R.string.tool_sidebar, "Bottom Bar"),
|
||||
SEARCH(R.string.action_search, "Bottom Bar"),
|
||||
|
|
@ -83,38 +86,41 @@ enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) {
|
|||
internal fun defaultPdfHiddenTools(): Set<String> {
|
||||
return setOf(
|
||||
PdfReaderTool.SCREEN_ORIENTATION.name,
|
||||
PdfReaderTool.HIGHLIGHT_ALL.name
|
||||
PdfReaderTool.HIGHLIGHT_ALL.name,
|
||||
PdfReaderTool.BRIGHTNESS.name
|
||||
)
|
||||
}
|
||||
|
||||
internal fun defaultPdfToolOrder(): List<PdfReaderTool> = PdfReaderTool.entries.toList()
|
||||
|
||||
internal fun defaultPdfBottomTools(): Set<String> {
|
||||
return PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||
internal fun isPdfReaderToolAvailable(tool: PdfReaderTool): Boolean {
|
||||
return BuildConfig.IS_PRO || tool != PdfReaderTool.OCR_LANGUAGE
|
||||
}
|
||||
|
||||
val PdfBuiltInThemes = listOf(
|
||||
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
|
||||
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
|
||||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
|
||||
ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
|
||||
ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
|
||||
ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
|
||||
ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
|
||||
ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
|
||||
ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||
)
|
||||
internal fun defaultPdfToolOrder(): List<PdfReaderTool> = PdfReaderTool.entries.filter(::isPdfReaderToolAvailable)
|
||||
|
||||
internal fun defaultPdfBottomTools(): Set<String> {
|
||||
return defaultPdfToolOrder().filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||
}
|
||||
|
||||
val PdfBuiltInThemes = BuiltInPdfReaderThemes
|
||||
|
||||
private fun sanitizePdfToolNameSet(
|
||||
toolNames: Set<String>,
|
||||
includeTool: (PdfReaderTool) -> Boolean = { true }
|
||||
): Set<String> {
|
||||
return toolNames.mapNotNull { toolName ->
|
||||
PdfReaderTool.entries
|
||||
.firstOrNull { it.name == toolName }
|
||||
?.takeIf { isPdfReaderToolAvailable(it) && includeTool(it) }
|
||||
?.name
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
|
||||
val savedHiddenTools = sanitizePdfToolNameSet(prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty())
|
||||
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
|
||||
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
|
||||
val migratedHiddenTools = savedHiddenTools + defaultPdfHiddenTools()
|
||||
val migratedHiddenTools = sanitizePdfToolNameSet(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion))
|
||||
prefs.edit {
|
||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
|
||||
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||
|
|
@ -124,10 +130,20 @@ internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
|||
return savedHiddenTools
|
||||
}
|
||||
|
||||
private fun pdfHiddenToolsIntroducedAfter(defaultsVersion: Int): Set<String> {
|
||||
return buildSet {
|
||||
if (defaultsVersion < 2) {
|
||||
add(PdfReaderTool.SCREEN_ORIENTATION.name)
|
||||
add(PdfReaderTool.HIGHLIGHT_ALL.name)
|
||||
}
|
||||
if (defaultsVersion < 3) add(PdfReaderTool.BRIGHTNESS.name)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit {
|
||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools)
|
||||
putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfToolNameSet(hiddenTools))
|
||||
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
|
||||
}
|
||||
}
|
||||
|
|
@ -138,24 +154,41 @@ internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
|
|||
?.split(',')
|
||||
?.filter { it.isNotBlank() }
|
||||
?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
|
||||
?.filter(::isPdfReaderToolAvailable)
|
||||
.orEmpty()
|
||||
return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct()
|
||||
}
|
||||
|
||||
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PDF_TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
|
||||
prefs.edit {
|
||||
putString(
|
||||
PDF_TOOL_ORDER_KEY,
|
||||
toolOrder.filter(::isPdfReaderToolAvailable).joinToString(",") { it.name }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun loadPdfBottomTools(context: Context): Set<String> {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val defaultBottomTools = defaultPdfBottomTools()
|
||||
return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools
|
||||
return sanitizePdfToolNameSet(
|
||||
toolNames = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools,
|
||||
includeTool = { it.category == "Bottom Bar" }
|
||||
)
|
||||
}
|
||||
|
||||
internal fun savePdfBottomTools(context: Context, bottomTools: Set<String>) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putStringSet(PDF_BOTTOM_TOOLS_KEY, bottomTools) }
|
||||
prefs.edit {
|
||||
putStringSet(
|
||||
PDF_BOTTOM_TOOLS_KEY,
|
||||
sanitizePdfToolNameSet(
|
||||
toolNames = bottomTools,
|
||||
includeTool = { it.category == "Bottom Bar" }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> {
|
||||
|
|
@ -217,6 +250,38 @@ internal fun loadPdfPageNumberOverlayVisible(context: Context): Boolean {
|
|||
return prefs.getBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, true)
|
||||
}
|
||||
|
||||
internal fun savePdfPageSpreadMode(context: Context, mode: ReaderPageSpreadMode) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PDF_PAGE_SPREAD_MODE_KEY, mode.name) }
|
||||
}
|
||||
|
||||
internal fun loadPdfPageSpreadMode(context: Context): ReaderPageSpreadMode {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val modeName = prefs.getString(PDF_PAGE_SPREAD_MODE_KEY, ReaderPageSpreadMode.SINGLE.name)
|
||||
return runCatching { ReaderPageSpreadMode.valueOf(modeName ?: ReaderPageSpreadMode.SINGLE.name) }
|
||||
.getOrDefault(ReaderPageSpreadMode.SINGLE)
|
||||
}
|
||||
|
||||
internal fun savePdfFirstPageStandaloneInSpread(context: Context, isEnabled: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY, isEnabled) }
|
||||
}
|
||||
|
||||
internal fun loadPdfFirstPageStandaloneInSpread(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY, false)
|
||||
}
|
||||
|
||||
internal fun savePdfTopTabStripVisible(context: Context, isVisible: Boolean) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putBoolean(PDF_TOP_TAB_STRIP_VISIBLE_KEY, isVisible) }
|
||||
}
|
||||
|
||||
internal fun loadPdfTopTabStripVisible(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(PDF_TOP_TAB_STRIP_VISIBLE_KEY, true)
|
||||
}
|
||||
|
||||
internal fun savePdfThemeId(context: Context, themeId: String) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(PDF_THEME_KEY, themeId) }
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
|
|
@ -72,6 +73,8 @@ import androidx.compose.ui.window.DialogProperties
|
|||
import com.aryan.reader.R
|
||||
import com.aryan.reader.epubreader.OptionSegmentedControl
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import com.aryan.reader.epubreader.titleRes
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
|
||||
|
||||
enum class PdfFlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
|
||||
|
|
@ -114,7 +117,7 @@ fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem>
|
|||
}
|
||||
|
||||
private val pdfReorderableToolbarTools = setOf(
|
||||
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
|
||||
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.BRIGHTNESS, PdfReaderTool.LOCK_PANNING,
|
||||
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
||||
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
||||
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
|
||||
|
|
@ -126,11 +129,12 @@ internal fun buildPdfToolbarItems(
|
|||
toolOrder: List<PdfReaderTool>,
|
||||
bottomTools: Set<String>
|
||||
): List<PdfFlatToolItem> {
|
||||
val toolbarTools = toolOrder.filter { it in pdfReorderableToolbarTools }
|
||||
val availableToolOrder = toolOrder.filter(::isPdfReaderToolAvailable)
|
||||
val toolbarTools = availableToolOrder.filter { it in pdfReorderableToolbarTools }
|
||||
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
||||
val moreTools = toolOrder.filter { it !in pdfReorderableToolbarTools }
|
||||
val moreTools = availableToolOrder.filter { it !in pdfReorderableToolbarTools }
|
||||
|
||||
val list = mutableListOf<PdfFlatToolItem>()
|
||||
|
||||
|
|
@ -541,7 +545,9 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
|||
when (tool) {
|
||||
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.BRIGHTNESS -> Icon(painterResource(id = R.drawable.contrast), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
|
||||
|
|
@ -556,9 +562,14 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
|||
|
||||
@Composable
|
||||
fun PdfVisualOptionsSheet(
|
||||
displayMode: DisplayMode,
|
||||
systemUiMode: SystemUiMode,
|
||||
pageSpreadMode: ReaderPageSpreadMode,
|
||||
firstPageStandaloneInSpread: Boolean,
|
||||
showVerticalPageGap: Boolean,
|
||||
showPageNumberOverlay: Boolean,
|
||||
onPageSpreadModeChange: (ReaderPageSpreadMode) -> Unit,
|
||||
onFirstPageStandaloneInSpreadChange: (Boolean) -> Unit,
|
||||
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
||||
onShowVerticalPageGapChange: (Boolean) -> Unit,
|
||||
onShowPageNumberOverlayChange: (Boolean) -> Unit,
|
||||
|
|
@ -606,6 +617,35 @@ fun PdfVisualOptionsSheet(
|
|||
|
||||
Text(stringResource(R.string.visual_options_page_layout), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (displayMode == DisplayMode.PAGINATION) {
|
||||
Text(
|
||||
stringResource(R.string.visual_options_pdf_page_spread),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OptionSegmentedControl(
|
||||
options = ReaderPageSpreadMode.entries,
|
||||
selectedOption = pageSpreadMode,
|
||||
onOptionSelected = onPageSpreadModeChange,
|
||||
getLabel = {
|
||||
when (it) {
|
||||
ReaderPageSpreadMode.SINGLE -> stringResource(R.string.visual_options_pdf_spread_single)
|
||||
ReaderPageSpreadMode.TWO_PAGE -> stringResource(R.string.visual_options_pdf_spread_two)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
PdfVisualOptionSwitchRow(
|
||||
title = stringResource(R.string.visual_options_pdf_first_page_alone),
|
||||
description = stringResource(R.string.visual_options_pdf_first_page_alone_desc),
|
||||
checked = firstPageStandaloneInSpread,
|
||||
onCheckedChange = onFirstPageStandaloneInSpreadChange
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
PdfVisualOptionSwitchRow(
|
||||
title = stringResource(R.string.visual_options_remove_page_gap),
|
||||
description = stringResource(R.string.visual_options_remove_page_gap_desc),
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import androidx.compose.runtime.derivedStateOf
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -55,6 +56,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
|
@ -85,6 +87,63 @@ enum class HandlePosition {
|
|||
TOP, BOTTOM, AUTO
|
||||
}
|
||||
|
||||
private const val TEXT_BOX_DRAG_PILL_VISUAL_WIDTH_DP = 48f
|
||||
private const val TEXT_BOX_DRAG_PILL_VISUAL_HEIGHT_DP = 24f
|
||||
private const val TEXT_BOX_DRAG_PILL_TOUCH_WIDTH_DP = 72f
|
||||
private const val TEXT_BOX_DRAG_PILL_TOUCH_HEIGHT_DP = 48f
|
||||
private const val TEXT_BOX_DRAG_PILL_GAP_DP = 8f
|
||||
|
||||
internal data class TextBoxChromeLayout(
|
||||
val containerWidthPx: Float,
|
||||
val containerHeightPx: Float,
|
||||
val contentWidthPx: Float,
|
||||
val contentHeightPx: Float,
|
||||
val contentOffsetX: Float,
|
||||
val contentOffsetY: Float,
|
||||
val outerTranslationX: Float,
|
||||
val outerTranslationY: Float,
|
||||
val dragPillLeftPx: Float,
|
||||
val dragPillTopPx: Float
|
||||
)
|
||||
|
||||
internal fun calculateTextBoxChromeLayout(
|
||||
textBoundsPx: Rect,
|
||||
isSelected: Boolean,
|
||||
isHandleAtTop: Boolean,
|
||||
handleSizePx: Float,
|
||||
dragPillWidthPx: Float,
|
||||
dragPillHeightPx: Float,
|
||||
dragPillGapPx: Float
|
||||
): TextBoxChromeLayout {
|
||||
val halfHandlePx = handleSizePx / 2f
|
||||
val contentWidthPx = textBoundsPx.width + handleSizePx
|
||||
val contentHeightPx = textBoundsPx.height + handleSizePx
|
||||
val dragPillTrackHeightPx = if (isSelected) dragPillHeightPx + dragPillGapPx else 0f
|
||||
val containerWidthPx = maxOf(contentWidthPx, if (isSelected) dragPillWidthPx else contentWidthPx)
|
||||
val containerHeightPx = contentHeightPx + dragPillTrackHeightPx
|
||||
val contentOffsetX = (containerWidthPx - contentWidthPx) / 2f
|
||||
val contentOffsetY = if (isSelected && isHandleAtTop) dragPillTrackHeightPx else 0f
|
||||
val dragPillLeftPx = (containerWidthPx - dragPillWidthPx) / 2f
|
||||
val dragPillTopPx = if (isSelected && isHandleAtTop) {
|
||||
0f
|
||||
} else {
|
||||
containerHeightPx - dragPillHeightPx
|
||||
}
|
||||
|
||||
return TextBoxChromeLayout(
|
||||
containerWidthPx = containerWidthPx,
|
||||
containerHeightPx = containerHeightPx,
|
||||
contentWidthPx = contentWidthPx,
|
||||
contentHeightPx = contentHeightPx,
|
||||
contentOffsetX = contentOffsetX,
|
||||
contentOffsetY = contentOffsetY,
|
||||
outerTranslationX = textBoundsPx.left - halfHandlePx - contentOffsetX,
|
||||
outerTranslationY = textBoundsPx.top - halfHandlePx - contentOffsetY,
|
||||
dragPillLeftPx = dragPillLeftPx,
|
||||
dragPillTopPx = dragPillTopPx
|
||||
)
|
||||
}
|
||||
|
||||
// Eagerly consumes pointer events so parent scaled pan/zoom gestures don't intercept it
|
||||
suspend fun PointerInputScope.detectEagerDragGestures(
|
||||
onDragStart: (Offset) -> Unit,
|
||||
|
|
@ -95,14 +154,14 @@ suspend fun PointerInputScope.detectEagerDragGestures(
|
|||
awaitEachGesture {
|
||||
var dragStarted = false
|
||||
try {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
down.consume() // Consume immediately
|
||||
onDragStart(down.position)
|
||||
dragStarted = true
|
||||
val pointerId = down.id
|
||||
var canceled = false
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == pointerId }
|
||||
if (change == null) {
|
||||
canceled = true
|
||||
|
|
@ -151,6 +210,13 @@ fun ResizableTextBox(
|
|||
|
||||
val density = LocalDensity.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val currentOnBoundsChanged by rememberUpdatedState(onBoundsChanged)
|
||||
val currentOnTextChanged by rememberUpdatedState(onTextChanged)
|
||||
val currentOnSelect by rememberUpdatedState(onSelect)
|
||||
val currentOnDragStart by rememberUpdatedState(onDragStart)
|
||||
val currentOnDrag by rememberUpdatedState(onDrag)
|
||||
val currentOnDragEnd by rememberUpdatedState(onDragEnd)
|
||||
val currentOnDragCancel by rememberUpdatedState(onDragCancel)
|
||||
|
||||
// Counter-scale fixed sizes so they render proportionally regardless of the zoom level
|
||||
val handleSize = (10f / scale).dp
|
||||
|
|
@ -236,170 +302,207 @@ fun ResizableTextBox(
|
|||
}
|
||||
}
|
||||
|
||||
val dragPillTouchWidth = (TEXT_BOX_DRAG_PILL_TOUCH_WIDTH_DP / scale).dp
|
||||
val dragPillTouchHeight = (TEXT_BOX_DRAG_PILL_TOUCH_HEIGHT_DP / scale).dp
|
||||
val dragPillWidthPx = with(density) { dragPillTouchWidth.toPx() }
|
||||
val dragPillHeightPx = with(density) { dragPillTouchHeight.toPx() }
|
||||
val dragPillGapPx = with(density) { (TEXT_BOX_DRAG_PILL_GAP_DP / scale).dp.toPx() }
|
||||
val chromeLayout = calculateTextBoxChromeLayout(
|
||||
textBoundsPx = currentRectPx,
|
||||
isSelected = isSelected,
|
||||
isHandleAtTop = isHandleAtTop,
|
||||
handleSizePx = handleSizePx,
|
||||
dragPillWidthPx = dragPillWidthPx,
|
||||
dragPillHeightPx = dragPillHeightPx,
|
||||
dragPillGapPx = dragPillGapPx
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.zIndex(if (isSelected) 10f else 0f)
|
||||
.graphicsLayer {
|
||||
translationX = currentRectPx.left - halfHandlePx
|
||||
translationY = currentRectPx.top - halfHandlePx
|
||||
translationX = chromeLayout.outerTranslationX
|
||||
translationY = chromeLayout.outerTranslationY
|
||||
}
|
||||
.size(
|
||||
width = with(density) { (currentRectPx.width + handleSizePx).toDp() },
|
||||
height = with(density) { (currentRectPx.height + handleSizePx).toDp() }
|
||||
width = with(density) { chromeLayout.containerWidthPx.toDp() },
|
||||
height = with(density) { chromeLayout.containerHeightPx.toDp() }
|
||||
)
|
||||
) {
|
||||
// --- 1. Content Body ---
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(handleSize / 2)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]")
|
||||
onSelect()
|
||||
}
|
||||
.offset {
|
||||
IntOffset(
|
||||
chromeLayout.contentOffsetX.roundToInt(),
|
||||
chromeLayout.contentOffsetY.roundToInt()
|
||||
)
|
||||
}
|
||||
.then(
|
||||
if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier
|
||||
.size(
|
||||
width = with(density) { chromeLayout.contentWidthPx.toDp() },
|
||||
height = with(density) { chromeLayout.contentHeightPx.toDp() }
|
||||
)
|
||||
.zIndex(1f)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = box.text,
|
||||
onValueChange = onTextChanged,
|
||||
// --- 1. Content Body ---
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = TextStyle(
|
||||
color = box.color,
|
||||
background = box.backgroundColor,
|
||||
fontFamily = fontFamily,
|
||||
fontSize = with(LocalDensity.current) {
|
||||
(box.fontSize * pageHeightPx).toSp()
|
||||
},
|
||||
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||
textDecoration = run {
|
||||
val decs = mutableListOf<TextDecoration>()
|
||||
if (box.isUnderline) decs.add(TextDecoration.Underline)
|
||||
if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough)
|
||||
if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs)
|
||||
.padding(handleSize / 2)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]")
|
||||
currentOnSelect()
|
||||
}
|
||||
}
|
||||
),
|
||||
cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary),
|
||||
enabled = isEditMode && isSelected,
|
||||
readOnly = !isEditMode
|
||||
)
|
||||
.then(
|
||||
if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier
|
||||
)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = box.text,
|
||||
onValueChange = currentOnTextChanged,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = TextStyle(
|
||||
color = box.color,
|
||||
background = box.backgroundColor,
|
||||
fontFamily = fontFamily,
|
||||
fontSize = with(LocalDensity.current) {
|
||||
(box.fontSize * pageHeightPx).toSp()
|
||||
},
|
||||
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||
textDecoration = run {
|
||||
val decs = mutableListOf<TextDecoration>()
|
||||
if (box.isUnderline) decs.add(TextDecoration.Underline)
|
||||
if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough)
|
||||
if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs)
|
||||
}
|
||||
),
|
||||
cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary),
|
||||
enabled = isEditMode && isSelected,
|
||||
readOnly = !isEditMode
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE }
|
||||
|
||||
fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset {
|
||||
return when (handle) {
|
||||
ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx)
|
||||
ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx)
|
||||
ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx)
|
||||
ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2)
|
||||
ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h)
|
||||
ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h)
|
||||
ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h)
|
||||
ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2)
|
||||
else -> Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
handles.forEach { handle ->
|
||||
val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
(center.x - handleTouchSizePx / 2).roundToInt(),
|
||||
(center.y - handleTouchSizePx / 2).roundToInt()
|
||||
)
|
||||
}
|
||||
.size(handleTouchSize)
|
||||
.pointerInput(box.id, handle, pageWidthPx, pageHeightPx) {
|
||||
detectEagerDragGestures(
|
||||
onDragStart = {
|
||||
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle")
|
||||
isDraggingOrResizing = true
|
||||
},
|
||||
onDragEnd = {
|
||||
isDraggingOrResizing = false
|
||||
val normalized = Rect(
|
||||
left = currentRectPx.left / pageWidthPx,
|
||||
top = currentRectPx.top / pageHeightPx,
|
||||
right = currentRectPx.right / pageWidthPx,
|
||||
bottom = currentRectPx.bottom / pageHeightPx
|
||||
)
|
||||
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized")
|
||||
currentOnBoundsChanged(normalized)
|
||||
},
|
||||
onDragCancel = { isDraggingOrResizing = false }
|
||||
) { change, dragAmount ->
|
||||
Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount")
|
||||
|
||||
var l = currentRectPx.left
|
||||
var t = currentRectPx.top
|
||||
var r = currentRectPx.right
|
||||
var b = currentRectPx.bottom
|
||||
val dx = dragAmount.x
|
||||
val dy = dragAmount.y
|
||||
val minSize = 50f / scale
|
||||
|
||||
when (handle) {
|
||||
ResizeHandle.TOP_LEFT -> {
|
||||
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
}
|
||||
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
ResizeHandle.TOP_RIGHT -> {
|
||||
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
}
|
||||
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
ResizeHandle.BOTTOM_RIGHT -> {
|
||||
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
}
|
||||
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
ResizeHandle.BOTTOM_LEFT -> {
|
||||
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
}
|
||||
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
else -> {}
|
||||
}
|
||||
currentRectPx = Rect(l, t, r, b)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(handleSize)
|
||||
.background(handleColor, CircleShape)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE }
|
||||
|
||||
fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset {
|
||||
return when (handle) {
|
||||
ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx)
|
||||
ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx)
|
||||
ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx)
|
||||
ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2)
|
||||
ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h)
|
||||
ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h)
|
||||
ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h)
|
||||
ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2)
|
||||
else -> Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
handles.forEach { handle ->
|
||||
val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
(center.x - handleTouchSizePx / 2).roundToInt(),
|
||||
(center.y - handleTouchSizePx / 2).roundToInt()
|
||||
)
|
||||
}
|
||||
.size(handleTouchSize)
|
||||
.pointerInput(onBoundsChanged) {
|
||||
detectEagerDragGestures(
|
||||
onDragStart = {
|
||||
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle")
|
||||
isDraggingOrResizing = true
|
||||
},
|
||||
onDragEnd = {
|
||||
isDraggingOrResizing = false
|
||||
val normalized = Rect(
|
||||
left = currentRectPx.left / pageWidthPx,
|
||||
top = currentRectPx.top / pageHeightPx,
|
||||
right = currentRectPx.right / pageWidthPx,
|
||||
bottom = currentRectPx.bottom / pageHeightPx
|
||||
)
|
||||
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized")
|
||||
onBoundsChanged(normalized)
|
||||
},
|
||||
onDragCancel = { isDraggingOrResizing = false }
|
||||
) { change, dragAmount ->
|
||||
Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount")
|
||||
|
||||
var l = currentRectPx.left
|
||||
var t = currentRectPx.top
|
||||
var r = currentRectPx.right
|
||||
var b = currentRectPx.bottom
|
||||
val dx = dragAmount.x
|
||||
val dy = dragAmount.y
|
||||
val minSize = 50f / scale
|
||||
|
||||
when (handle) {
|
||||
ResizeHandle.TOP_LEFT -> {
|
||||
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
}
|
||||
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
ResizeHandle.TOP_RIGHT -> {
|
||||
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
|
||||
}
|
||||
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
ResizeHandle.BOTTOM_RIGHT -> {
|
||||
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
|
||||
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
}
|
||||
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
ResizeHandle.BOTTOM_LEFT -> {
|
||||
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
|
||||
}
|
||||
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
|
||||
else -> {}
|
||||
}
|
||||
currentRectPx = Rect(l, t, r, b)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(handleSize)
|
||||
.background(handleColor, CircleShape)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DragPill(
|
||||
isDarkMode = isDarkMode,
|
||||
scale = scale,
|
||||
modifier = Modifier
|
||||
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter)
|
||||
.offset(y = if (isHandleAtTop) (-32f / scale).dp else (32f / scale).dp)
|
||||
.offset {
|
||||
IntOffset(
|
||||
chromeLayout.dragPillLeftPx.roundToInt(),
|
||||
chromeLayout.dragPillTopPx.roundToInt()
|
||||
)
|
||||
}
|
||||
.size(width = dragPillTouchWidth, height = dragPillTouchHeight)
|
||||
.zIndex(20f)
|
||||
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) {
|
||||
.pointerInput(box.id, pageWidthPx, pageHeightPx) {
|
||||
detectEagerDragGestures(
|
||||
onDragStart = { offset ->
|
||||
Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset")
|
||||
isDraggingOrResizing = true
|
||||
onDragStart(offset)
|
||||
currentOnDragStart(offset)
|
||||
},
|
||||
onDragEnd = {
|
||||
isDraggingOrResizing = false
|
||||
|
|
@ -410,12 +513,12 @@ fun ResizableTextBox(
|
|||
bottom = currentRectPx.bottom / pageHeightPx
|
||||
)
|
||||
Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized")
|
||||
onBoundsChanged(normalized)
|
||||
onDragEnd()
|
||||
currentOnBoundsChanged(normalized)
|
||||
currentOnDragEnd()
|
||||
},
|
||||
onDragCancel = {
|
||||
isDraggingOrResizing = false
|
||||
onDragCancel()
|
||||
currentOnDragCancel()
|
||||
}
|
||||
) { change, dragAmount ->
|
||||
val w = currentRectPx.width
|
||||
|
|
@ -426,7 +529,7 @@ fun ResizableTextBox(
|
|||
val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h))
|
||||
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
|
||||
currentRectPx = newRect
|
||||
onDrag(dragAmount, newRect)
|
||||
currentOnDrag(dragAmount, newRect)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -440,20 +543,28 @@ private fun DragPill(
|
|||
isDarkMode: Boolean,
|
||||
scale: Float = 1f
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.size(width = (48f / scale).dp, height = (24f / scale).dp),
|
||||
shape = CircleShape,
|
||||
color = if (isDarkMode) Color.White else Color.Black,
|
||||
contentColor = if (isDarkMode) Color.Black else Color.White,
|
||||
shadowElevation = (4f / scale).dp
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.drag_handle),
|
||||
contentDescription = stringResource(R.string.content_desc_drag_text_box),
|
||||
modifier = Modifier.size((20f / scale).dp)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(
|
||||
width = (TEXT_BOX_DRAG_PILL_VISUAL_WIDTH_DP / scale).dp,
|
||||
height = (TEXT_BOX_DRAG_PILL_VISUAL_HEIGHT_DP / scale).dp
|
||||
),
|
||||
shape = CircleShape,
|
||||
color = if (isDarkMode) Color.White else Color.Black,
|
||||
contentColor = if (isDarkMode) Color.Black else Color.White,
|
||||
shadowElevation = (4f / scale).dp
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.drag_handle),
|
||||
contentDescription = stringResource(R.string.content_desc_drag_text_box),
|
||||
modifier = Modifier.size((20f / scale).dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,22 +133,23 @@ object PdfToHtmlGenerator {
|
|||
headerFooterStrings: Set<String>
|
||||
): String {
|
||||
return try {
|
||||
doc.openPage(pageIdx)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val charCount = textPage.textPageCountChars()
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
doc.openPage(pageIdx)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val charCount = textPage.textPageCountChars()
|
||||
|
||||
val pagePtr = getNativePointer(page)
|
||||
val textPagePtr = getNativePointer(textPage)
|
||||
|
||||
val imageElements = mutableListOf<ImageElement>()
|
||||
val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
|
||||
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
|
||||
for (i in 0 until objCount) {
|
||||
if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) {
|
||||
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) {
|
||||
val bbox = FloatArray(4)
|
||||
if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
|
||||
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
|
||||
val topY = bbox[3]
|
||||
val dimens = IntArray(2)
|
||||
val pixels = PdfiumEngineProvider.bridge.extractImagePixels(pagePtr, i, dimens)
|
||||
val pixels = NativePdfiumBridge.extractImagePixels(pagePtr, i, dimens)
|
||||
if (pixels != null && dimens[0] > 0 && dimens[1] > 0) {
|
||||
try {
|
||||
val bmp = Bitmap.createBitmap(pixels, dimens[0], dimens[1], Bitmap.Config.ARGB_8888)
|
||||
|
|
@ -179,12 +180,10 @@ object PdfToHtmlGenerator {
|
|||
val flags: IntArray?
|
||||
val charBoxes: FloatArray?
|
||||
|
||||
synchronized(PdfiumEngineProvider.lock) {
|
||||
sizes = PdfiumEngineProvider.bridge.getPageFontSizes(textPagePtr, actualCount)
|
||||
weights = PdfiumEngineProvider.bridge.getPageFontWeights(textPagePtr, actualCount)
|
||||
flags = PdfiumEngineProvider.bridge.getPageFontFlags(textPagePtr, actualCount)
|
||||
charBoxes = PdfiumEngineProvider.bridge.getPageCharBoxes(textPagePtr, actualCount)
|
||||
}
|
||||
sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount)
|
||||
weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount)
|
||||
flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount)
|
||||
charBoxes = NativePdfiumBridge.getPageCharBoxes(textPagePtr, actualCount)
|
||||
|
||||
if (sizes == null || weights == null || flags == null) {
|
||||
return@use buildFallbackPageSection(pageNumber, rawText)
|
||||
|
|
@ -302,8 +301,9 @@ object PdfToHtmlGenerator {
|
|||
}
|
||||
|
||||
buildPageHtml(pageNumber, finalElements, headerFooterStrings)
|
||||
}
|
||||
} ?: buildEmptyPageSection(pageNumber)
|
||||
}
|
||||
} ?: buildEmptyPageSection(pageNumber)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).w(e, "Error extracting page $pageIdx")
|
||||
buildEmptyPageSection(pageNumber)
|
||||
|
|
@ -506,17 +506,19 @@ object PdfToHtmlGenerator {
|
|||
|
||||
for (pageIdx in samplePages) {
|
||||
try {
|
||||
doc.openPage(pageIdx)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val charCount = textPage.textPageCountChars()
|
||||
if (charCount <= 0) return@use
|
||||
val rawText = textPage.textPageGetText(0, charCount) ?: return@use
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
doc.openPage(pageIdx)?.use { page ->
|
||||
page.openTextPage().use { textPage ->
|
||||
val charCount = textPage.textPageCountChars()
|
||||
if (charCount <= 0) return@use
|
||||
val rawText = textPage.textPageGetText(0, charCount) ?: return@use
|
||||
|
||||
val lines = rawText.split('\n').map { it.trim() }.filter { it.length > 2 }
|
||||
if (lines.isNotEmpty()) {
|
||||
val edgeLines = lines.take(2) + lines.takeLast(2)
|
||||
for (line in edgeLines) {
|
||||
frequency[line] = (frequency[line] ?: 0) + 1
|
||||
val lines = rawText.split('\n').map { it.trim() }.filter { it.length > 2 }
|
||||
if (lines.isNotEmpty()) {
|
||||
val edgeLines = lines.take(2) + lines.takeLast(2)
|
||||
for (line in edgeLines) {
|
||||
frequency[line] = (frequency[line] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import com.aryan.reader.SearchState
|
|||
import com.aryan.reader.SearchTopBar
|
||||
import com.aryan.reader.TooltipIconButton
|
||||
import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||
import com.aryan.reader.cardTitle
|
||||
import com.aryan.reader.epubreader.SystemUiMode
|
||||
import kotlin.collections.isNotEmpty
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ internal val PdfTabStripHeight = 44.dp
|
|||
private val pdfToolbarTools = setOf(
|
||||
PdfReaderTool.DICTIONARY,
|
||||
PdfReaderTool.THEME,
|
||||
PdfReaderTool.BRIGHTNESS,
|
||||
PdfReaderTool.LOCK_PANNING,
|
||||
PdfReaderTool.SLIDER,
|
||||
PdfReaderTool.TOC,
|
||||
|
|
@ -63,6 +65,61 @@ private val pdfToolbarTools = setOf(
|
|||
PdfReaderTool.SCREEN_ORIENTATION
|
||||
)
|
||||
|
||||
internal enum class PdfOverflowMenuSection {
|
||||
CUSTOMIZE_TOOLBAR,
|
||||
HIDDEN_TOOLS,
|
||||
OCR_LANGUAGE,
|
||||
VISUAL_OPTIONS,
|
||||
READING_MODE,
|
||||
TAP_TO_TURN,
|
||||
KEEP_SCREEN_ON,
|
||||
AUTO_SCROLL,
|
||||
TTS_SETTINGS,
|
||||
BOOKMARK,
|
||||
PAGE_MANAGEMENT,
|
||||
REFLOW,
|
||||
FILE_ACTIONS,
|
||||
FILE_INFO
|
||||
}
|
||||
|
||||
internal fun pdfOverflowMenuSections(
|
||||
hiddenTools: Set<String>,
|
||||
hasHiddenToolbarTools: Boolean,
|
||||
isPro: Boolean,
|
||||
effectiveFileType: FileType,
|
||||
hasFileInfo: Boolean = true
|
||||
): List<PdfOverflowMenuSection> = buildList {
|
||||
add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR)
|
||||
if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS)
|
||||
if (isPro && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
|
||||
add(PdfOverflowMenuSection.OCR_LANGUAGE)
|
||||
}
|
||||
if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) add(PdfOverflowMenuSection.VISUAL_OPTIONS)
|
||||
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) add(PdfOverflowMenuSection.READING_MODE)
|
||||
if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) add(PdfOverflowMenuSection.TAP_TO_TURN)
|
||||
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) add(PdfOverflowMenuSection.KEEP_SCREEN_ON)
|
||||
if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) add(PdfOverflowMenuSection.AUTO_SCROLL)
|
||||
if (
|
||||
!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name) ||
|
||||
!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
||||
) {
|
||||
add(PdfOverflowMenuSection.TTS_SETTINGS)
|
||||
}
|
||||
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) add(PdfOverflowMenuSection.BOOKMARK)
|
||||
if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) add(PdfOverflowMenuSection.PAGE_MANAGEMENT)
|
||||
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) add(PdfOverflowMenuSection.REFLOW)
|
||||
if (
|
||||
!hiddenTools.contains(PdfReaderTool.SHARE.name) ||
|
||||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) ||
|
||||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name))
|
||||
) {
|
||||
add(PdfOverflowMenuSection.FILE_ACTIONS)
|
||||
}
|
||||
if (hasFileInfo && !hiddenTools.contains(PdfReaderTool.FILE_INFO.name)) {
|
||||
add(PdfOverflowMenuSection.FILE_INFO)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun PdfTopBar(
|
||||
|
|
@ -76,6 +133,7 @@ internal fun PdfTopBar(
|
|||
isLoadingDocument: Boolean,
|
||||
errorMessage: String?,
|
||||
currentPageForDisplay: Int,
|
||||
currentPageLabel: String? = null,
|
||||
totalPages: Int,
|
||||
pagerStatePageCount: Int,
|
||||
hiddenTools: Set<String>,
|
||||
|
|
@ -87,6 +145,7 @@ internal fun PdfTopBar(
|
|||
isRightToLeftPagination: Boolean,
|
||||
isKeepScreenOn: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
isSliderActive: Boolean,
|
||||
isBookmarked: Boolean,
|
||||
canDeletePage: Boolean,
|
||||
isReflowingThisBook: Boolean,
|
||||
|
|
@ -95,9 +154,11 @@ internal fun PdfTopBar(
|
|||
isTabsEnabled: Boolean,
|
||||
openTabs: List<RecentFileItem>,
|
||||
activeTabBookId: String?,
|
||||
usePdfFileNameAsDisplayName: Boolean,
|
||||
effectiveFileType: FileType,
|
||||
onNavigateBack: () -> Unit,
|
||||
onShowThemePanel: () -> Unit,
|
||||
onShowBrightnessControl: () -> Unit,
|
||||
onToggleScrollLock: () -> Unit,
|
||||
onShowDictionarySettings: () -> Unit,
|
||||
onShowPenPlayground: () -> Unit,
|
||||
|
|
@ -125,6 +186,7 @@ internal fun PdfTopBar(
|
|||
onShowTtsSettings: () -> Unit,
|
||||
onShowTtsReplacements: () -> Unit,
|
||||
onToggleBookmark: () -> Unit,
|
||||
onShowFileInfo: () -> Unit,
|
||||
onInsertPage: () -> Unit,
|
||||
onDeletePage: () -> Unit,
|
||||
onReflowAction: () -> Unit,
|
||||
|
|
@ -176,7 +238,8 @@ internal fun PdfTopBar(
|
|||
val titleText = when {
|
||||
isLoadingDocument -> stringResource(R.string.loading_pdf)
|
||||
errorMessage != null -> stringResource(R.string.error_loading_pdf)
|
||||
totalPages > 0 && pagerStatePageCount > 0 -> stringResource(R.string.page_of_pages, currentPageForDisplay + 1, totalPages)
|
||||
totalPages > 0 && pagerStatePageCount > 0 -> currentPageLabel
|
||||
?: stringResource(R.string.page_of_pages, currentPageForDisplay + 1, totalPages)
|
||||
totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page)
|
||||
else -> stringResource(R.string.pdf_viewer)
|
||||
}
|
||||
|
|
@ -199,6 +262,13 @@ internal fun PdfTopBar(
|
|||
) {
|
||||
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.BRIGHTNESS -> TooltipIconButton(
|
||||
text = stringResource(R.string.reader_brightness_title),
|
||||
description = stringResource(R.string.reader_brightness_system_desc),
|
||||
onClick = onShowBrightnessControl
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
|
||||
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
|
||||
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
|
||||
|
|
@ -219,7 +289,11 @@ internal fun PdfTopBar(
|
|||
onClick = onShowSlider,
|
||||
enabled = !isTtsPlayingOrLoading
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
|
||||
Icon(
|
||||
painterResource(id = R.drawable.slider),
|
||||
contentDescription = stringResource(R.string.content_desc_navigate_slider),
|
||||
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
PdfReaderTool.TOC -> TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_toc),
|
||||
|
|
@ -321,267 +395,264 @@ internal fun PdfTopBar(
|
|||
}
|
||||
) {
|
||||
val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) }
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_customize_toolbar)) },
|
||||
onClick = { showMoreMenu = false; onShowCustomizeTools() },
|
||||
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.title_customize_toolbar), modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
if (hiddenToolbarTools.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
|
||||
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showHiddenToolsExpanded) {
|
||||
hiddenToolbarTools.forEach { tool ->
|
||||
HiddenPdfToolMenuItem(
|
||||
tool = tool,
|
||||
isTtsPlayingOrLoading = isTtsPlayingOrLoading,
|
||||
showAllTextHighlights = showAllTextHighlights,
|
||||
isHighlightingLoading = isHighlightingLoading,
|
||||
isEditMode = isEditMode,
|
||||
isTtsSessionActive = isTtsSessionActive,
|
||||
closeMenu = {
|
||||
showHiddenToolsExpanded = false
|
||||
showMoreMenu = false
|
||||
},
|
||||
onShowThemePanel = onShowThemePanel,
|
||||
onToggleScrollLock = onToggleScrollLock,
|
||||
onShowDictionarySettings = onShowDictionarySettings,
|
||||
onShowSlider = onShowSlider,
|
||||
onShowToc = onShowToc,
|
||||
onSearchClick = onSearchClick,
|
||||
onToggleHighlights = onToggleHighlights,
|
||||
onShowAiHub = onShowAiHub,
|
||||
onToggleEditMode = onToggleEditMode,
|
||||
onToggleTts = onToggleTts,
|
||||
onShowScreenOrientation = onShowScreenOrientation
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_ocr_language)) },
|
||||
onClick = { showMoreMenu = false; onShowOcrLanguage() }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_visual_options)) },
|
||||
onClick = { showMoreMenu = false; onShowVisualOptions() },
|
||||
leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
|
||||
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showReadingModeExpanded) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(false)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(true)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
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)) },
|
||||
onClick = { onToggleKeepScreenOn(); showMoreMenu = false },
|
||||
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_auto_scroll)) },
|
||||
enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL,
|
||||
onClick = { showMoreMenu = false; onStartAutoScroll() }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)
|
||||
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
|
||||
if (showTtsVoiceSettings || showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
||||
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showTtsSettingsExpanded) {
|
||||
if (showTtsVoiceSettings) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
if (showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||
onClick = { showMoreMenu = false; onShowTtsReplacements() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
|
||||
onClick = { showMoreMenu = false; onToggleBookmark() }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_insert_blank_page)) },
|
||||
onClick = { showMoreMenu = false; onInsertPage() }
|
||||
)
|
||||
if (canDeletePage) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_delete_page)) },
|
||||
onClick = { showMoreMenu = false; onDeletePage() },
|
||||
colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.error)
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
||||
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
|
||||
onClick = { showMoreMenu = false; onReflowAction() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
|
||||
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
|
||||
val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)
|
||||
if (showShareAction || showSaveCopyAction || showPrintAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_share_save_print)) },
|
||||
onClick = { showFileActionsExpanded = !showFileActionsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f)
|
||||
pdfOverflowMenuSections(
|
||||
hiddenTools = hiddenTools,
|
||||
hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(),
|
||||
isPro = BuildConfig.IS_PRO,
|
||||
effectiveFileType = effectiveFileType
|
||||
).forEachIndexed { index, section ->
|
||||
if (index > 0) HorizontalDivider()
|
||||
when (section) {
|
||||
PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_customize_toolbar)) },
|
||||
onClick = { showMoreMenu = false; onShowCustomizeTools() },
|
||||
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.title_customize_toolbar), modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showFileActionsExpanded) {
|
||||
if (showShareAction) {
|
||||
PdfOverflowMenuSection.HIDDEN_TOOLS -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_share)) },
|
||||
onClick = { showMoreMenu = false; onShare() },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
||||
text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
|
||||
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showHiddenToolsExpanded) {
|
||||
hiddenToolbarTools.forEach { tool ->
|
||||
HiddenPdfToolMenuItem(
|
||||
tool = tool,
|
||||
isTtsPlayingOrLoading = isTtsPlayingOrLoading,
|
||||
showAllTextHighlights = showAllTextHighlights,
|
||||
isHighlightingLoading = isHighlightingLoading,
|
||||
isEditMode = isEditMode,
|
||||
isTtsSessionActive = isTtsSessionActive,
|
||||
isSliderActive = isSliderActive,
|
||||
closeMenu = {
|
||||
showHiddenToolsExpanded = false
|
||||
showMoreMenu = false
|
||||
},
|
||||
onShowThemePanel = onShowThemePanel,
|
||||
onShowBrightnessControl = onShowBrightnessControl,
|
||||
onToggleScrollLock = onToggleScrollLock,
|
||||
onShowDictionarySettings = onShowDictionarySettings,
|
||||
onShowSlider = onShowSlider,
|
||||
onShowToc = onShowToc,
|
||||
onSearchClick = onSearchClick,
|
||||
onToggleHighlights = onToggleHighlights,
|
||||
onShowAiHub = onShowAiHub,
|
||||
onToggleEditMode = onToggleEditMode,
|
||||
onToggleTts = onToggleTts,
|
||||
onShowScreenOrientation = onShowScreenOrientation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PdfOverflowMenuSection.FILE_INFO -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.file_information)) },
|
||||
onClick = { showMoreMenu = false; onShowFileInfo() },
|
||||
leadingIcon = { Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
if (showSaveCopyAction) {
|
||||
PdfOverflowMenuSection.OCR_LANGUAGE -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
||||
onClick = { showMoreMenu = false; onSaveCopy() },
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
||||
text = { Text(stringResource(R.string.menu_ocr_language)) },
|
||||
onClick = { showMoreMenu = false; onShowOcrLanguage() }
|
||||
)
|
||||
}
|
||||
if (showPrintAction) {
|
||||
PdfOverflowMenuSection.VISUAL_OPTIONS -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_print)) },
|
||||
onClick = { showMoreMenu = false; onPrint() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
text = { Text(stringResource(R.string.menu_visual_options)) },
|
||||
onClick = { showMoreMenu = false; onShowVisualOptions() },
|
||||
leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.READING_MODE -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
|
||||
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showReadingModeExpanded) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
|
||||
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(false)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = {
|
||||
onSetRightToLeftPagination(true)
|
||||
onChangeDisplayMode(DisplayMode.PAGINATION)
|
||||
showMoreMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
PdfOverflowMenuSection.TAP_TO_TURN -> {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.KEEP_SCREEN_ON -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
|
||||
onClick = { onToggleKeepScreenOn(); showMoreMenu = false },
|
||||
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.AUTO_SCROLL -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_auto_scroll)) },
|
||||
enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL,
|
||||
onClick = { showMoreMenu = false; onStartAutoScroll() }
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.TTS_SETTINGS -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_settings)) },
|
||||
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showTtsSettingsExpanded) {
|
||||
if (showTtsVoiceSettings) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
|
||||
enabled = !isTtsSessionActive,
|
||||
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
if (showTtsReplacements) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
|
||||
onClick = { showMoreMenu = false; onShowTtsReplacements() },
|
||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PdfOverflowMenuSection.BOOKMARK -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
|
||||
onClick = { showMoreMenu = false; onToggleBookmark() }
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.PAGE_MANAGEMENT -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_insert_blank_page)) },
|
||||
onClick = { showMoreMenu = false; onInsertPage() }
|
||||
)
|
||||
if (canDeletePage) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_delete_page)) },
|
||||
onClick = { showMoreMenu = false; onDeletePage() },
|
||||
colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.error)
|
||||
)
|
||||
}
|
||||
}
|
||||
PdfOverflowMenuSection.REFLOW -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
|
||||
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
|
||||
onClick = { showMoreMenu = false; onReflowAction() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
PdfOverflowMenuSection.FILE_ACTIONS -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_share_save_print)) },
|
||||
onClick = { showFileActionsExpanded = !showFileActionsExpanded },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f)
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showFileActionsExpanded) {
|
||||
if (showShareAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_share)) },
|
||||
onClick = { showMoreMenu = false; onShare() },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
if (showSaveCopyAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
|
||||
onClick = { showMoreMenu = false; onSaveCopy() },
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
if (showPrintAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_print)) },
|
||||
onClick = { showMoreMenu = false; onPrint() },
|
||||
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -608,7 +679,7 @@ internal fun PdfTopBar(
|
|||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = tab.customName ?: tab.title ?: tab.displayName,
|
||||
text = tab.cardTitle(usePdfFileNameAsDisplayName),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 140.dp),
|
||||
|
|
@ -645,8 +716,10 @@ private fun HiddenPdfToolMenuItem(
|
|||
isHighlightingLoading: Boolean,
|
||||
isEditMode: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
isSliderActive: Boolean,
|
||||
closeMenu: () -> Unit,
|
||||
onShowThemePanel: () -> Unit,
|
||||
onShowBrightnessControl: () -> Unit,
|
||||
onToggleScrollLock: () -> Unit,
|
||||
onShowDictionarySettings: () -> Unit,
|
||||
onShowSlider: () -> Unit,
|
||||
|
|
@ -671,6 +744,7 @@ private fun HiddenPdfToolMenuItem(
|
|||
closeMenu()
|
||||
when (tool) {
|
||||
PdfReaderTool.THEME -> onShowThemePanel()
|
||||
PdfReaderTool.BRIGHTNESS -> onShowBrightnessControl()
|
||||
PdfReaderTool.LOCK_PANNING -> onToggleScrollLock()
|
||||
PdfReaderTool.DICTIONARY -> onShowDictionarySettings()
|
||||
PdfReaderTool.SLIDER -> onShowSlider()
|
||||
|
|
@ -688,8 +762,14 @@ private fun HiddenPdfToolMenuItem(
|
|||
when (tool) {
|
||||
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.BRIGHTNESS -> Icon(painterResource(id = R.drawable.contrast), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SLIDER -> Icon(
|
||||
painterResource(id = R.drawable.slider),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
PdfReaderTool.HIGHLIGHT_ALL -> {
|
||||
|
|
@ -702,7 +782,12 @@ private fun HiddenPdfToolMenuItem(
|
|||
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingIcon = if (tool == PdfReaderTool.SLIDER && isSliderActive) {
|
||||
{
|
||||
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
|
||||
}
|
||||
} else null
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -849,8 +934,10 @@ fun PdfBottomBar(
|
|||
isHighlightingLoading: Boolean,
|
||||
isEditMode: Boolean,
|
||||
isTtsSessionActive: Boolean,
|
||||
isSliderActive: Boolean,
|
||||
ttsErrorMessage: String?,
|
||||
onShowThemePanel: () -> Unit,
|
||||
onShowBrightnessControl: () -> Unit,
|
||||
onToggleScrollLock: () -> Unit,
|
||||
onShowDictionarySettings: () -> Unit,
|
||||
onShowSlider: () -> Unit,
|
||||
|
|
@ -893,6 +980,13 @@ fun PdfBottomBar(
|
|||
) {
|
||||
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.BRIGHTNESS -> TooltipIconButton(
|
||||
text = stringResource(R.string.reader_brightness_title),
|
||||
description = stringResource(R.string.reader_brightness_system_desc),
|
||||
onClick = onShowBrightnessControl
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_lock_pan),
|
||||
description = stringResource(R.string.tooltip_lock_pan_desc),
|
||||
|
|
@ -913,7 +1007,11 @@ fun PdfBottomBar(
|
|||
onClick = onShowSlider,
|
||||
enabled = !isTtsPlayingOrLoading
|
||||
) {
|
||||
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
|
||||
Icon(
|
||||
painterResource(id = R.drawable.slider),
|
||||
contentDescription = stringResource(R.string.content_desc_navigate_slider),
|
||||
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
PdfReaderTool.TOC -> TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_toc),
|
||||
|
|
|
|||
35
app/src/main/java/com/aryan/reader/pdf/PdfVerticalPerfLog.kt
Normal file
35
app/src/main/java/com/aryan/reader/pdf/PdfVerticalPerfLog.kt
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import timber.log.Timber
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object PdfVerticalPerfLog {
|
||||
const val TAG = "PdfVerticalPerf"
|
||||
const val SAMPLE_INTERVAL_MS = 250L
|
||||
|
||||
fun nowNanos(): Long = System.nanoTime()
|
||||
|
||||
fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000L
|
||||
|
||||
fun d(message: String) {
|
||||
Timber.tag(TAG).d(message)
|
||||
}
|
||||
|
||||
fun i(message: String) {
|
||||
Timber.tag(TAG).i(message)
|
||||
}
|
||||
|
||||
fun w(message: String) {
|
||||
Timber.tag(TAG).w(message)
|
||||
}
|
||||
|
||||
fun f(value: Float): String {
|
||||
if (value.isNaN() || value.isInfinite()) return value.toString()
|
||||
return (value * 10f).roundToInt().let { rounded ->
|
||||
if (rounded % 10 == 0) (rounded / 10).toString()
|
||||
else (rounded / 10f).toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun xy(x: Float, y: Float): String = "(${f(x)},${f(y)})"
|
||||
}
|
||||
|
|
@ -42,7 +42,6 @@ import androidx.compose.foundation.gestures.calculateCentroid
|
|||
import androidx.compose.foundation.gestures.calculateCentroidSize
|
||||
import androidx.compose.foundation.gestures.calculatePan
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -88,6 +87,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.input.pointer.PointerType
|
||||
import androidx.compose.ui.input.pointer.isPrimaryPressed
|
||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||
|
|
@ -116,6 +116,7 @@ import com.aryan.reader.pdf.data.VirtualPage
|
|||
import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx
|
||||
import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
|
@ -128,6 +129,18 @@ import kotlin.math.min
|
|||
import kotlin.math.roundToInt
|
||||
|
||||
private const val SCROLL_BOUNDS_TAG = "PdfScrollBounds"
|
||||
private const val VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS = 220L
|
||||
|
||||
internal fun resolvePdfVerticalPageBackgroundColor(
|
||||
activeTheme: com.aryan.reader.ReaderTheme
|
||||
): Color {
|
||||
val resolved = when (activeTheme.id) {
|
||||
"no_theme", "system" -> Color.White
|
||||
"reverse" -> Color.Black
|
||||
else -> activeTheme.backgroundColor
|
||||
}
|
||||
return if (resolved.isSpecified) resolved else Color.White
|
||||
}
|
||||
|
||||
@Stable
|
||||
class VerticalPdfReaderState {
|
||||
|
|
@ -333,11 +346,7 @@ internal fun PdfVerticalReader(
|
|||
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
||||
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
|
||||
val verticalPageBackgroundColor = remember(activeTheme) {
|
||||
when (activeTheme.id) {
|
||||
"no_theme", "system" -> Color.White
|
||||
"reverse" -> Color.Black
|
||||
else -> activeTheme.backgroundColor
|
||||
}
|
||||
resolvePdfVerticalPageBackgroundColor(activeTheme)
|
||||
}
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
|
||||
val imeInsets = WindowInsets.ime
|
||||
|
|
@ -374,6 +383,22 @@ internal fun PdfVerticalReader(
|
|||
var isFastFlinging by remember { mutableStateOf(false) }
|
||||
var isInteracting by remember { mutableStateOf(false) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var isTileRenderIdleCooldownActive by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(isInteracting, isFlinging) {
|
||||
if (isInteracting || isFlinging) {
|
||||
if (!isTileRenderIdleCooldownActive) {
|
||||
PdfVerticalPerfLog.d("tile-render-cooldown active=true reason=busy")
|
||||
}
|
||||
isTileRenderIdleCooldownActive = true
|
||||
} else if (isTileRenderIdleCooldownActive) {
|
||||
delay(VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS)
|
||||
PdfVerticalPerfLog.d(
|
||||
"tile-render-cooldown active=false idleFor=${VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS}ms"
|
||||
)
|
||||
isTileRenderIdleCooldownActive = false
|
||||
}
|
||||
}
|
||||
|
||||
val layoutState = remember(ratios, constraints.maxWidth, constraints.maxHeight, density, showPageGap, dividerHeightPxInt) {
|
||||
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
|
||||
|
|
@ -417,9 +442,48 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(layoutInfo, totalDocHeight, screenWidth, screenHeight, fitZoom, headerHeightPx, footerHeightPx) {
|
||||
PdfVerticalPerfLog.i(
|
||||
"layout-ready pages=${layoutInfo.size} totalH=${PdfVerticalPerfLog.f(totalDocHeight)} " +
|
||||
"screen=${PdfVerticalPerfLog.xy(screenWidth, screenHeight)} chrome=${PdfVerticalPerfLog.xy(headerHeightPx, footerHeightPx)} " +
|
||||
"fitZoom=${PdfVerticalPerfLog.f(fitZoom)} firstPageH=${PdfVerticalPerfLog.f(layoutInfo.firstOrNull()?.height ?: 0f)}"
|
||||
)
|
||||
}
|
||||
|
||||
val zoomAnimatable = remember { Animatable(fitZoom) }
|
||||
val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) }
|
||||
val panYAnimatable = remember { Animatable(0f) }
|
||||
val dragCameraUpdates = remember {
|
||||
Channel<Triple<Float, Float, Float>>(Channel.CONFLATED)
|
||||
}
|
||||
val oneHandZoomDistancePx = with(density) {
|
||||
PDF_ONE_HAND_ZOOM_DRAG_DISTANCE_FOR_DOUBLE_DP.dp.toPx()
|
||||
}
|
||||
var oneHandZoomStartZoom by remember { mutableFloatStateOf(fitZoom) }
|
||||
var oneHandZoomStartPan by remember { mutableStateOf(Offset.Zero) }
|
||||
var oneHandZoomPivotScreen by remember { mutableStateOf(Offset.Zero) }
|
||||
var isVerticalOneHandZooming by remember { mutableStateOf(false) }
|
||||
val latestIsVerticalOneHandZooming by rememberUpdatedState(isVerticalOneHandZooming)
|
||||
|
||||
DisposableEffect(dragCameraUpdates) {
|
||||
onDispose {
|
||||
dragCameraUpdates.close()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(dragCameraUpdates) {
|
||||
for ((targetZoom, targetPanX, targetPanY) in dragCameraUpdates) {
|
||||
if (zoomAnimatable.value != targetZoom) {
|
||||
zoomAnimatable.snapTo(targetZoom)
|
||||
}
|
||||
if (panXAnimatable.value != targetPanX) {
|
||||
panXAnimatable.snapTo(targetPanX)
|
||||
}
|
||||
if (panYAnimatable.value != targetPanY) {
|
||||
panYAnimatable.snapTo(targetPanY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(zoomAnimatable.value, panXAnimatable.value, panYAnimatable.value) {
|
||||
onZoomAndPanChanged?.invoke(zoomAnimatable.value, Offset(panXAnimatable.value, panYAnimatable.value))
|
||||
|
|
@ -601,6 +665,21 @@ internal fun PdfVerticalReader(
|
|||
return clampValues(targetZoom, targetPanX, targetPanY)
|
||||
}
|
||||
|
||||
fun updatePanBoundsForZoom(finalZoom: Float) {
|
||||
val zoomedDocWidth = screenWidth * finalZoom
|
||||
val (finalMinX, finalMaxX) = if (zoomedDocWidth < screenWidth) {
|
||||
val centeredX = (screenWidth - zoomedDocWidth) / 2f
|
||||
centeredX to centeredX
|
||||
} else {
|
||||
-(zoomedDocWidth - screenWidth) to 0f
|
||||
}
|
||||
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
|
||||
|
||||
val zoomedDocHeight = totalDocHeight * finalZoom
|
||||
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
|
||||
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
|
||||
}
|
||||
|
||||
LaunchedEffect(resetZoomTrigger) {
|
||||
if (resetZoomTrigger != 0L && zoomAnimatable.value > fitZoom && !isScrollLocked) {
|
||||
scope.launch {
|
||||
|
|
@ -864,23 +943,39 @@ internal fun PdfVerticalReader(
|
|||
|
||||
LaunchedEffect(isInteracting) {
|
||||
Timber.tag("PdfTouchDebug").i("VerticalReader: isInteracting changed to $isInteracting")
|
||||
PdfVerticalPerfLog.i(
|
||||
"interaction-state interacting=$isInteracting dragging=$isDragging flinging=$isFlinging " +
|
||||
"zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}"
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(highResScale) {
|
||||
Timber.tag("PdfPerformance").i("VerticalReader HighResScale changed to: $highResScale")
|
||||
PdfVerticalPerfLog.i(
|
||||
"high-res-scale scale=${PdfVerticalPerfLog.f(highResScale)} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} " +
|
||||
"interacting=$isInteracting flinging=$isFlinging fastFlinging=$isFastFlinging"
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { isInteracting || (isFlinging && isFastFlinging) }.collectLatest { isBusy ->
|
||||
snapshotFlow { isInteracting || isFlinging }.collectLatest { isBusy ->
|
||||
Timber.tag("PdfDrawPerf").d(
|
||||
"VerticalReader Interaction State: isBusy=$isBusy (Interacting=$isInteracting, Flinging=$isFlinging, Fast=$isFastFlinging)"
|
||||
)
|
||||
PdfVerticalPerfLog.d(
|
||||
"render-resolution-gate busy=$isBusy interacting=$isInteracting flinging=$isFlinging " +
|
||||
"fastFlinging=$isFastFlinging highRes=${PdfVerticalPerfLog.f(highResScale)} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)}"
|
||||
)
|
||||
|
||||
if (!isBusy) {
|
||||
delay(50)
|
||||
val target = zoomAnimatable.value
|
||||
if (highResScale != target) {
|
||||
Timber.tag("PdfDrawPerf").v("VerticalReader: Updating highResScale to $target")
|
||||
PdfVerticalPerfLog.i(
|
||||
"high-res-scale-update from=${PdfVerticalPerfLog.f(highResScale)} to=${PdfVerticalPerfLog.f(target)} " +
|
||||
"pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}"
|
||||
)
|
||||
highResScale = target
|
||||
}
|
||||
}
|
||||
|
|
@ -893,7 +988,7 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
|
||||
LaunchedEffect(zoomAnimatable.value) {
|
||||
if (!isInteracting && !(isFlinging && isFastFlinging)) {
|
||||
if (!isInteracting && !isFlinging) {
|
||||
if (highResScale != zoomAnimatable.value) {
|
||||
highResScale = zoomAnimatable.value
|
||||
}
|
||||
|
|
@ -1071,6 +1166,91 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
}
|
||||
|
||||
val onDoubleTapDragZoomStart: (Offset) -> Unit = {
|
||||
if (!isScrollLocked) {
|
||||
isVerticalOneHandZooming = true
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.oneHandStart requestedPivot=$it center=(${(screenWidth / 2f).toInt()},${(screenHeight / 2f).toInt()}) " +
|
||||
"zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})"
|
||||
)
|
||||
oneHandZoomPivotScreen = Offset(screenWidth / 2f, screenHeight / 2f)
|
||||
oneHandZoomStartZoom = zoomAnimatable.value
|
||||
oneHandZoomStartPan = Offset(panXAnimatable.value, panYAnimatable.value)
|
||||
isInteracting = true
|
||||
isDragging = true
|
||||
scope.launch {
|
||||
zoomAnimatable.stop()
|
||||
panXAnimatable.stop()
|
||||
panYAnimatable.stop()
|
||||
panXAnimatable.updateBounds(null, null)
|
||||
panYAnimatable.updateBounds(null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onDoubleTapDragZoom: (Offset, Float) -> Unit = { _, totalDragY ->
|
||||
if (!isScrollLocked) {
|
||||
val screenDragY = totalDragY * oneHandZoomStartZoom
|
||||
val targetZoom = pdfOneHandZoomScale(
|
||||
startScale = oneHandZoomStartZoom,
|
||||
totalDragY = screenDragY,
|
||||
dragDistanceForDoublePx = oneHandZoomDistancePx,
|
||||
minScale = fitZoom,
|
||||
maxScale = 5f
|
||||
)
|
||||
val rawPan = topLeftPdfPanForScaleChange(
|
||||
previousScale = oneHandZoomStartZoom,
|
||||
nextScale = targetZoom,
|
||||
previousPan = oneHandZoomStartPan,
|
||||
pivot = oneHandZoomPivotScreen
|
||||
)
|
||||
val (finalZoom, finalX, finalY) = clampCamera(targetZoom, rawPan.x, rawPan.y)
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
|
||||
"vertical.oneHandUpdate dragY=$totalDragY screenDragY=$screenDragY " +
|
||||
"targetZoom=$targetZoom finalZoom=$finalZoom pan=($finalX,$finalY)"
|
||||
)
|
||||
onZoomChange(finalZoom)
|
||||
dragCameraUpdates.trySend(Triple(finalZoom, finalX, finalY))
|
||||
}
|
||||
}
|
||||
|
||||
val onDoubleTapDragZoomEnd: () -> Unit = {
|
||||
val wasOneHandZooming = isVerticalOneHandZooming
|
||||
isVerticalOneHandZooming = false
|
||||
if (!isScrollLocked || wasOneHandZooming) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.oneHandEnd zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})"
|
||||
)
|
||||
isInteracting = false
|
||||
isDragging = false
|
||||
val currentZoom = zoomAnimatable.value
|
||||
if (currentZoom > fitZoom && currentZoom < fitZoom * 1.05f) {
|
||||
scope.launch {
|
||||
val (finalZoom, finalX, finalY) = clampCamera(
|
||||
fitZoom,
|
||||
panXAnimatable.value,
|
||||
panYAnimatable.value
|
||||
)
|
||||
coroutineScope {
|
||||
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(180, easing = FastOutSlowInEasing)) }
|
||||
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(180, easing = FastOutSlowInEasing)) }
|
||||
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(180, easing = FastOutSlowInEasing)) }
|
||||
}
|
||||
onZoomChange(finalZoom)
|
||||
updatePanBoundsForZoom(finalZoom)
|
||||
}
|
||||
} else {
|
||||
updatePanBoundsForZoom(currentZoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val currentOnPageClick by rememberUpdatedState(onPageClick)
|
||||
val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom)
|
||||
val currentOnDoubleTapDragZoomStart by rememberUpdatedState(onDoubleTapDragZoomStart)
|
||||
val currentOnDoubleTapDragZoom by rememberUpdatedState(onDoubleTapDragZoom)
|
||||
val currentOnDoubleTapDragZoomEnd by rememberUpdatedState(onDoubleTapDragZoomEnd)
|
||||
|
||||
val globalDrawingModifier = Modifier.pointerInput(
|
||||
isEditMode,
|
||||
layoutInfo,
|
||||
|
|
@ -1180,31 +1360,86 @@ internal fun PdfVerticalReader(
|
|||
.fillMaxSize()
|
||||
.background(if (showPageGap) Color.Transparent else verticalPageBackgroundColor)
|
||||
.then(globalDrawingModifier)
|
||||
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode, isScrollLocked) {
|
||||
Timber.tag("PdfTouchDebug").v(
|
||||
"VerticalReader: TapPointerInput init. isEditMode=$isEditMode"
|
||||
)
|
||||
|
||||
// Vertical zoom gestures live here so page tap handlers do not steal
|
||||
// alternating double-tap-hold attempts.
|
||||
.pointerInput(
|
||||
layoutInfo,
|
||||
isEditMode,
|
||||
selectedTool,
|
||||
isStylusOnlyMode,
|
||||
isScrollLocked
|
||||
) {
|
||||
val isTapDetectionAllowed = !isEditMode ||
|
||||
selectedTool == InkType.TEXT ||
|
||||
isStylusOnlyMode
|
||||
|
||||
if (!isTapDetectionAllowed) return@pointerInput
|
||||
if (!isTapDetectionAllowed) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.rootDetector.disabled edit=$isEditMode tool=$selectedTool stylusOnly=$isStylusOnlyMode"
|
||||
)
|
||||
return@pointerInput
|
||||
}
|
||||
|
||||
detectTapGestures(onTap = {
|
||||
if (!isEditMode) {
|
||||
Timber.tag("PdfTouchDebug").d("VerticalReader: Tap detected")
|
||||
selectionClearTrigger++
|
||||
onPageClick()
|
||||
} else if (selectedTool == InkType.TEXT) {
|
||||
onPageClick()
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.rootDetector.enabled scrollLocked=$isScrollLocked edit=$isEditMode " +
|
||||
"tool=$selectedTool pages=${layoutInfo.size} zoom=${zoomAnimatable.value}"
|
||||
)
|
||||
|
||||
fun isOverPage(screenOffset: Offset): Boolean {
|
||||
val zoom = zoomAnimatable.value.takeIf { it > 0f } ?: fitZoom
|
||||
val docX = (screenOffset.x - panXAnimatable.value) / zoom
|
||||
val docY = (screenOffset.y - panYAnimatable.value) / zoom
|
||||
return layoutInfo.any { page ->
|
||||
docX >= 0f &&
|
||||
docX <= page.width &&
|
||||
docY >= page.y &&
|
||||
docY <= page.y + page.height
|
||||
}
|
||||
}, onDoubleTap = { offset ->
|
||||
if (!isScrollLocked) {
|
||||
Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected")
|
||||
onDoubleTapToZoom(offset)
|
||||
}
|
||||
|
||||
detectPdfTapAndOneHandZoomGestures(
|
||||
viewConfiguration = viewConfiguration,
|
||||
canStartOneHandZoom = { !isScrollLocked },
|
||||
canHandleQuickDoubleTap = { !isScrollLocked },
|
||||
consumeSingleTap = false,
|
||||
onTap = { offset ->
|
||||
val overPage = isOverPage(offset)
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.rootTap offset=$offset overPage=$overPage"
|
||||
)
|
||||
if (!overPage) {
|
||||
selectionClearTrigger++
|
||||
currentOnPageClick()
|
||||
}
|
||||
},
|
||||
onQuickDoubleTap = { offset ->
|
||||
if (!isScrollLocked) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.rootQuickDoubleTap offset=$offset zoom=${zoomAnimatable.value}"
|
||||
)
|
||||
currentOnDoubleTapToZoom(offset)
|
||||
}
|
||||
},
|
||||
onOneHandZoomHoldStart = { offset ->
|
||||
if (!isScrollLocked) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.rootOneHandHoldStart offset=$offset"
|
||||
)
|
||||
currentOnDoubleTapDragZoomStart(offset)
|
||||
}
|
||||
},
|
||||
onOneHandZoom = { offset, totalDragY ->
|
||||
if (!isScrollLocked) {
|
||||
currentOnDoubleTapDragZoom(offset, totalDragY)
|
||||
}
|
||||
},
|
||||
onOneHandZoomEnd = { _ ->
|
||||
if (!isScrollLocked || latestIsVerticalOneHandZooming) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d("vertical.rootOneHandEnd")
|
||||
currentOnDoubleTapDragZoomEnd()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
.pointerInput(
|
||||
totalDocHeight,
|
||||
|
|
@ -1224,8 +1459,25 @@ internal fun PdfVerticalReader(
|
|||
)
|
||||
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.scrollDetector.down consumed=${down.isConsumed} pos=${down.position} " +
|
||||
"zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})"
|
||||
)
|
||||
isInteracting = true
|
||||
isDragging = false
|
||||
val gestureStartNanos = PdfVerticalPerfLog.nowNanos()
|
||||
var gestureLastSampleMs = System.currentTimeMillis()
|
||||
var gestureEventCount = 0
|
||||
var gestureConsumedEventCount = 0
|
||||
var gestureCanceledEventCount = 0
|
||||
var gestureZoomEventCount = 0
|
||||
var gestureMaxPanDelta = 0f
|
||||
PdfVerticalPerfLog.i(
|
||||
"gesture-start type=${down.type} scrollLocked=$isScrollLocked edit=$isEditMode tool=$selectedTool " +
|
||||
"zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} highRes=${PdfVerticalPerfLog.f(highResScale)} " +
|
||||
"pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)} currentPage=${state.currentPage} " +
|
||||
"visible=${state.firstVisiblePage}-${state.lastVisiblePage}"
|
||||
)
|
||||
|
||||
Timber.tag("PointerTypeDebug").d("VerticalReader: Input Type detected: ${down.type}")
|
||||
|
||||
|
|
@ -1282,10 +1534,28 @@ internal fun PdfVerticalReader(
|
|||
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
gestureEventCount++
|
||||
val isMultiTouch = event.changes.size > 1
|
||||
val canceled = event.changes.any { it.isConsumed } && !isMultiTouch
|
||||
|
||||
if (latestIsVerticalOneHandZooming && !isMultiTouch) {
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
|
||||
"vertical.scrollDetector.skipOneHandActive events=$gestureEventCount " +
|
||||
"changes=${event.changes.joinToString { change ->
|
||||
"pressed=${change.pressed},consumed=${change.isConsumed},moved=${change.positionChanged()}"
|
||||
}}"
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (canceled) {
|
||||
gestureCanceledEventCount++
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.scrollDetector.canceledByConsumed mode=$gestureDisambiguationMode " +
|
||||
"events=$gestureEventCount changes=${event.changes.joinToString { change ->
|
||||
"pressed=${change.pressed},consumed=${change.isConsumed},moved=${change.positionChanged()}"
|
||||
}}"
|
||||
)
|
||||
Timber.tag("PdfTouchDebug").v(
|
||||
"VerticalReader: Event Canceled (Child consumed?)."
|
||||
)
|
||||
|
|
@ -1311,7 +1581,11 @@ internal fun PdfVerticalReader(
|
|||
)
|
||||
|
||||
totalPanDistance += panMagnitude
|
||||
gestureMaxPanDelta = max(gestureMaxPanDelta, panMagnitude)
|
||||
gestureZoomAccumulator *= zoomChange
|
||||
if (abs(zoomChange - 1f) > 0.001f) {
|
||||
gestureZoomEventCount++
|
||||
}
|
||||
|
||||
val isZoomPastSlop = abs(gestureZoomAccumulator - 1f) > 0.05f
|
||||
val isPanPastSlop = totalPanDistance > touchSlop
|
||||
|
|
@ -1320,11 +1594,17 @@ internal fun PdfVerticalReader(
|
|||
if (isPanPastSlop || isZoomPastSlop) {
|
||||
if (spanMagnitude > panMagnitude * 1.5f) {
|
||||
gestureDisambiguationMode = 2
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.scrollDetector.modeZoom span=$spanMagnitude pan=$panMagnitude totalPan=$totalPanDistance"
|
||||
)
|
||||
Timber.tag("PdfTouchDebug").d(
|
||||
"Locked to ZOOM (Span > Pan * 1.5)"
|
||||
)
|
||||
} else {
|
||||
gestureDisambiguationMode = 1
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.scrollDetector.modePan span=$spanMagnitude pan=$panMagnitude totalPan=$totalPanDistance"
|
||||
)
|
||||
Timber.tag("PdfTouchDebug").d(
|
||||
"Locked to PAN (Pan Dominant)"
|
||||
)
|
||||
|
|
@ -1333,6 +1613,9 @@ internal fun PdfVerticalReader(
|
|||
} else if (gestureDisambiguationMode == 1) {
|
||||
if (spanMagnitude > (panMagnitude * 3f) && spanMagnitude > 4f) {
|
||||
gestureDisambiguationMode = 2
|
||||
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
|
||||
"vertical.scrollDetector.modePanToZoom span=$spanMagnitude pan=$panMagnitude"
|
||||
)
|
||||
Timber.tag("PdfTouchDebug").d(
|
||||
"Breakout: Switching PAN -> ZOOM"
|
||||
)
|
||||
|
|
@ -1379,15 +1662,29 @@ internal fun PdfVerticalReader(
|
|||
onZoomChange(accumulatedZoom)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
zoomAnimatable.snapTo(accumulatedZoom)
|
||||
panXAnimatable.snapTo(accumulatedPanX)
|
||||
panYAnimatable.snapTo(accumulatedPanY)
|
||||
}
|
||||
dragCameraUpdates.trySend(
|
||||
Triple(accumulatedZoom, accumulatedPanX, accumulatedPanY)
|
||||
)
|
||||
|
||||
val consumedChanges = event.changes.count { it.positionChanged() }
|
||||
event.changes.forEach {
|
||||
if (it.positionChanged()) it.consume()
|
||||
}
|
||||
if (consumedChanges > 0) {
|
||||
gestureConsumedEventCount++
|
||||
}
|
||||
|
||||
val nowMs = System.currentTimeMillis()
|
||||
if (nowMs - gestureLastSampleMs >= PdfVerticalPerfLog.SAMPLE_INTERVAL_MS) {
|
||||
gestureLastSampleMs = nowMs
|
||||
PdfVerticalPerfLog.d(
|
||||
"gesture-drag-sample events=$gestureEventCount consumed=$gestureConsumedEventCount " +
|
||||
"mode=$gestureDisambiguationMode multi=$isMultiTouch panDelta=${PdfVerticalPerfLog.f(panMagnitude)} " +
|
||||
"totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} zoomChange=${PdfVerticalPerfLog.f(zoomChange)} " +
|
||||
"zoom=${PdfVerticalPerfLog.f(accumulatedZoom)} pan=${PdfVerticalPerfLog.xy(accumulatedPanX, accumulatedPanY)} " +
|
||||
"highRes=${PdfVerticalPerfLog.f(highResScale)}"
|
||||
)
|
||||
}
|
||||
|
||||
if (event.changes.isNotEmpty()) {
|
||||
velocityTrackerAccumulator += panChange
|
||||
|
|
@ -1405,75 +1702,102 @@ internal fun PdfVerticalReader(
|
|||
}
|
||||
isDragging = false
|
||||
|
||||
val validFlingCondition = panLocked
|
||||
val gestureDurationMs = PdfVerticalPerfLog.elapsedMs(gestureStartNanos)
|
||||
|
||||
if (validFlingCondition) {
|
||||
if (panLocked) {
|
||||
val velocity = tracker.calculateVelocity()
|
||||
val flingSensitivity = 2.0f
|
||||
val minFlingVelocity = 250f
|
||||
val (finalZoom, finalX, finalY) = clampCamera(
|
||||
accumulatedZoom, accumulatedPanX, accumulatedPanY
|
||||
)
|
||||
val zoomedDocWidth = screenWidth * finalZoom
|
||||
val zoomedDocHeight = totalDocHeight * finalZoom
|
||||
|
||||
scope.launch {
|
||||
isFlinging = true
|
||||
try {
|
||||
if (accumulatedZoom !in fitZoom..5f) {
|
||||
zoomAnimatable.animateTo(
|
||||
finalZoom, animationSpec = tween(300)
|
||||
)
|
||||
}
|
||||
onZoomChange(zoomAnimatable.targetValue)
|
||||
val zoomedDocWidth = screenWidth * finalZoom
|
||||
val zoomedDocHeight = totalDocHeight * finalZoom
|
||||
|
||||
val flingMinX: Float
|
||||
val flingMaxX: Float
|
||||
if (zoomedDocWidth < screenWidth) {
|
||||
val centeredX = (screenWidth - zoomedDocWidth) / 2f
|
||||
flingMinX = centeredX
|
||||
flingMaxX = centeredX
|
||||
} else {
|
||||
flingMinX = -(zoomedDocWidth - screenWidth)
|
||||
flingMaxX = 0f
|
||||
}
|
||||
|
||||
val minPanY =
|
||||
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
|
||||
headerHeightPx
|
||||
)
|
||||
Timber.tag(SCROLL_BOUNDS_TAG).i("Fling Logic:")
|
||||
Timber.tag(SCROLL_BOUNDS_TAG)
|
||||
.d("- totalDocHeight: $totalDocHeight, zoom: $finalZoom -> zoomedDocHeight: $zoomedDocHeight")
|
||||
Timber.tag(SCROLL_BOUNDS_TAG)
|
||||
.d("- Fling bounds set to Y:[$minPanY, $headerHeightPx]")
|
||||
panXAnimatable.updateBounds(flingMinX, flingMaxX)
|
||||
panYAnimatable.updateBounds(minPanY, headerHeightPx)
|
||||
|
||||
coroutineScope {
|
||||
launch {
|
||||
val rawX = velocity.x * flingSensitivity
|
||||
val flingX = if (abs(rawX) > minFlingVelocity && !isScrollLocked) rawX
|
||||
else 0f
|
||||
|
||||
if (flingX != 0f) panXAnimatable.animateDecay(
|
||||
flingX, decay
|
||||
)
|
||||
}
|
||||
launch {
|
||||
val rawY = velocity.y * flingSensitivity
|
||||
val flingY = if (abs(rawY) > minFlingVelocity) rawY
|
||||
else 0f
|
||||
|
||||
if (flingY != 0f) panYAnimatable.animateDecay(
|
||||
flingY, decay
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isFlinging = false
|
||||
}
|
||||
val flingMinX: Float
|
||||
val flingMaxX: Float
|
||||
if (zoomedDocWidth < screenWidth) {
|
||||
val centeredX = (screenWidth - zoomedDocWidth) / 2f
|
||||
flingMinX = centeredX
|
||||
flingMaxX = centeredX
|
||||
} else {
|
||||
flingMinX = -(zoomedDocWidth - screenWidth)
|
||||
flingMaxX = 0f
|
||||
}
|
||||
|
||||
val minPanY =
|
||||
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
|
||||
headerHeightPx
|
||||
)
|
||||
val rawX = velocity.x * flingSensitivity
|
||||
val rawY = velocity.y * flingSensitivity
|
||||
val flingX = if (abs(rawX) > minFlingVelocity && !isScrollLocked) rawX else 0f
|
||||
val flingY = if (abs(rawY) > minFlingVelocity) rawY else 0f
|
||||
val shouldRunFling = flingX != 0f || flingY != 0f || accumulatedZoom !in fitZoom..5f
|
||||
PdfVerticalPerfLog.i(
|
||||
"gesture-end duration=${gestureDurationMs}ms events=$gestureEventCount consumed=$gestureConsumedEventCount " +
|
||||
"canceled=$gestureCanceledEventCount zoomEvents=$gestureZoomEventCount maxPanDelta=${PdfVerticalPerfLog.f(gestureMaxPanDelta)} " +
|
||||
"totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} mode=$gestureDisambiguationMode panLocked=$panLocked shouldRunFling=$shouldRunFling " +
|
||||
"velocity=${PdfVerticalPerfLog.xy(velocity.x, velocity.y)} fling=${PdfVerticalPerfLog.xy(flingX, flingY)} " +
|
||||
"zoom=${PdfVerticalPerfLog.f(finalZoom)} pan=${PdfVerticalPerfLog.xy(finalX, finalY)}"
|
||||
)
|
||||
|
||||
if (shouldRunFling) {
|
||||
scope.launch {
|
||||
isFlinging = true
|
||||
val flingStartNanos = PdfVerticalPerfLog.nowNanos()
|
||||
PdfVerticalPerfLog.i(
|
||||
"fling-start fling=${PdfVerticalPerfLog.xy(flingX, flingY)} " +
|
||||
"boundsX=${PdfVerticalPerfLog.xy(flingMinX, flingMaxX)} boundsY=${PdfVerticalPerfLog.xy(minPanY, headerHeightPx)} " +
|
||||
"zoomedDocH=${PdfVerticalPerfLog.f(zoomedDocHeight)} highRes=${PdfVerticalPerfLog.f(highResScale)}"
|
||||
)
|
||||
try {
|
||||
if (accumulatedZoom !in fitZoom..5f) {
|
||||
zoomAnimatable.animateTo(
|
||||
finalZoom, animationSpec = tween(300)
|
||||
)
|
||||
}
|
||||
onZoomChange(zoomAnimatable.targetValue)
|
||||
Timber.tag(SCROLL_BOUNDS_TAG).i("Fling Logic:")
|
||||
Timber.tag(SCROLL_BOUNDS_TAG)
|
||||
.d("- totalDocHeight: $totalDocHeight, zoom: $finalZoom -> zoomedDocHeight: $zoomedDocHeight")
|
||||
Timber.tag(SCROLL_BOUNDS_TAG)
|
||||
.d("- Fling bounds set to Y:[$minPanY, $headerHeightPx]")
|
||||
panXAnimatable.updateBounds(flingMinX, flingMaxX)
|
||||
panYAnimatable.updateBounds(minPanY, headerHeightPx)
|
||||
|
||||
coroutineScope {
|
||||
launch {
|
||||
if (flingX != 0f) panXAnimatable.animateDecay(
|
||||
flingX, decay
|
||||
)
|
||||
}
|
||||
launch {
|
||||
if (flingY != 0f) panYAnimatable.animateDecay(
|
||||
flingY, decay
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
PdfVerticalPerfLog.i(
|
||||
"fling-end duration=${PdfVerticalPerfLog.elapsedMs(flingStartNanos)}ms " +
|
||||
"zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)} " +
|
||||
"velocity=${PdfVerticalPerfLog.xy(panXAnimatable.velocity, panYAnimatable.velocity)}"
|
||||
)
|
||||
isFlinging = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
panXAnimatable.updateBounds(flingMinX, flingMaxX)
|
||||
panYAnimatable.updateBounds(minPanY, headerHeightPx)
|
||||
}
|
||||
} else {
|
||||
PdfVerticalPerfLog.i(
|
||||
"gesture-end duration=${gestureDurationMs}ms events=$gestureEventCount consumed=$gestureConsumedEventCount " +
|
||||
"canceled=$gestureCanceledEventCount zoomEvents=$gestureZoomEventCount maxPanDelta=${PdfVerticalPerfLog.f(gestureMaxPanDelta)} " +
|
||||
"totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} mode=$gestureDisambiguationMode panLocked=false no-fling " +
|
||||
"zoom=${PdfVerticalPerfLog.f(accumulatedZoom)} pan=${PdfVerticalPerfLog.xy(accumulatedPanX, accumulatedPanY)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
|
@ -1543,6 +1867,11 @@ internal fun PdfVerticalReader(
|
|||
Timber.tag("PdfDrawPerf").d(
|
||||
"Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})"
|
||||
)
|
||||
PdfVerticalPerfLog.d(
|
||||
"visible-pages pages=${finalPages.map { it.index }} base=${baseVisiblePages.map { it.index }} " +
|
||||
"draggingBox=${draggedBox != null} zoom=${PdfVerticalPerfLog.f(zoom)} panY=${PdfVerticalPerfLog.f(panY)} " +
|
||||
"viewport=${PdfVerticalPerfLog.xy(viewportTop, viewportBottom)} buffered=${PdfVerticalPerfLog.xy(searchTop, searchBottom)}"
|
||||
)
|
||||
finalPages
|
||||
} else {
|
||||
cached
|
||||
|
|
@ -1569,6 +1898,10 @@ internal fun PdfVerticalReader(
|
|||
|
||||
if (mostVisible != null && mostVisible.index != state.currentPage) {
|
||||
Timber.tag("PdfPositionDebug").v("VerticalReader: Page changed to ${mostVisible.index} (PanY: $panY)")
|
||||
PdfVerticalPerfLog.d(
|
||||
"current-page-change from=${state.currentPage} to=${mostVisible.index} " +
|
||||
"viewport=${PdfVerticalPerfLog.xy(realViewportTop, realViewportBottom)} panY=${PdfVerticalPerfLog.f(panY)} zoom=${PdfVerticalPerfLog.f(zoom)}"
|
||||
)
|
||||
state.currentPage = mostVisible.index
|
||||
}
|
||||
}
|
||||
|
|
@ -1678,24 +2011,6 @@ internal fun PdfVerticalReader(
|
|||
{ text: String -> onSearchText(text) }
|
||||
}
|
||||
|
||||
val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom)
|
||||
val onDoubleTapLambda = remember(page, screenWidth, screenHeight) {
|
||||
{ localOffset: Offset ->
|
||||
Timber.tag("PdfZoomDebug").d(
|
||||
"Page ${page.index} Double Tap: Local=$localOffset, PageY=${page.y}"
|
||||
)
|
||||
val contentX = localOffset.x
|
||||
val contentY = localOffset.y + page.y
|
||||
val currentZ = zoomAnimatable.value
|
||||
val panX = panXAnimatable.value
|
||||
val panY = panYAnimatable.value
|
||||
val screenX = contentX * currentZ + panX
|
||||
val screenY = contentY * currentZ + panY
|
||||
Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log
|
||||
currentOnDoubleTapToZoom(Offset(screenX, screenY))
|
||||
}
|
||||
}
|
||||
|
||||
val onTtsHighlightCenter: (Float) -> Unit =
|
||||
remember(page.index, ttsReadingPage) {
|
||||
{ highlightCenterY ->
|
||||
|
|
@ -1792,12 +2107,14 @@ internal fun PdfVerticalReader(
|
|||
onOcrStateChange = onOcrStateChange,
|
||||
onBookmarkClick = { onBookmarkClick(page.index) },
|
||||
isZoomEnabled = false,
|
||||
isScrolling = isDragging || (isFlinging && isFastFlinging),
|
||||
isScrolling = isInteracting ||
|
||||
isDragging ||
|
||||
isFlinging ||
|
||||
isTileRenderIdleCooldownActive,
|
||||
isVerticalScroll = true,
|
||||
showPageNumberOverlay = showPageNumberOverlay,
|
||||
isScrollLocked = isScrollLocked,
|
||||
visualScaleProvider = currentScaleProvider,
|
||||
onDoubleTap = onDoubleTapLambda,
|
||||
clearSelectionTrigger = selectionClearTrigger,
|
||||
onTtsHighlightCenterCalculated = onTtsHighlightCenter,
|
||||
onSearchHighlightCenterCalculated = onSearchHighlightCenter,
|
||||
|
|
@ -1993,6 +2310,11 @@ internal fun PdfVerticalReader(
|
|||
Timber.tag("PdfPerformance").d(
|
||||
"VerticalReader Layout Measure/Place took ${layoutTime}ms for ${measurables.size} items"
|
||||
)
|
||||
PdfVerticalPerfLog.d(
|
||||
"compose-layout-slow duration=${PdfVerticalPerfLog.f(layoutTime)}ms items=${measurables.size} " +
|
||||
"visible=${visiblePages.map { it.index }} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} " +
|
||||
"pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}"
|
||||
)
|
||||
}
|
||||
measureResult
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
141
app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt
Normal file
141
app/src/main/java/com/aryan/reader/pdf/PdfViewerStateLogic.kt
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package com.aryan.reader.pdf
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import com.aryan.reader.shared.pdf.PdfSpreadLayout
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
internal fun resolveEraserStrokeWidth(
|
||||
isEraserOverride: Boolean,
|
||||
activeToolThickness: Float,
|
||||
eraserToolThickness: Float
|
||||
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
|
||||
|
||||
internal fun canUsePdfSidecarsForBook(
|
||||
activeBookId: String?,
|
||||
loadedSidecarBookId: String?,
|
||||
areSidecarsLoaded: Boolean
|
||||
): Boolean = activeBookId != null && areSidecarsLoaded && loadedSidecarBookId == activeBookId
|
||||
|
||||
internal fun canManagePdfVirtualPages(
|
||||
isDocumentReady: Boolean,
|
||||
currentBookId: String?,
|
||||
loadedPageLayoutBookId: String?,
|
||||
virtualPageCount: Int
|
||||
): Boolean {
|
||||
return isDocumentReady &&
|
||||
currentBookId != null &&
|
||||
loadedPageLayoutBookId == currentBookId &&
|
||||
virtualPageCount > 0
|
||||
}
|
||||
|
||||
internal fun currentPageScaleAfterPdfPageChange(
|
||||
displayMode: DisplayMode,
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<Float, Float, Float>?,
|
||||
currentActiveScale: Float
|
||||
): Float {
|
||||
return if (displayMode == DisplayMode.PAGINATION && isScrollLocked) {
|
||||
lockedState?.first ?: currentActiveScale
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
}
|
||||
|
||||
internal fun pdfPageRangeText(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
displayMode: DisplayMode,
|
||||
settings: ReaderSettings
|
||||
): String {
|
||||
val pageRange = if (displayMode == DisplayMode.PAGINATION) {
|
||||
PdfSpreadLayout.pageRangeLabel(pageIndex, pageCount, settings)
|
||||
} else {
|
||||
"${pageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + 1}"
|
||||
}
|
||||
return "$pageRange / $pageCount"
|
||||
}
|
||||
|
||||
internal fun pdfPageRangeLabel(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
displayMode: DisplayMode,
|
||||
settings: ReaderSettings
|
||||
): String {
|
||||
val pageRange = if (displayMode == DisplayMode.PAGINATION) {
|
||||
PdfSpreadLayout.pageRangeLabel(pageIndex, pageCount, settings)
|
||||
} else {
|
||||
"${pageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + 1}"
|
||||
}
|
||||
return if ('-' in pageRange) {
|
||||
"Pages $pageRange of $pageCount"
|
||||
} else {
|
||||
"Page $pageRange of $pageCount"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun clampPdfSpreadCameraOffset(
|
||||
scale: Float,
|
||||
offset: Offset,
|
||||
viewportWidth: Float,
|
||||
viewportHeight: Float
|
||||
): Offset {
|
||||
if (viewportWidth <= 0f || viewportHeight <= 0f || scale <= 1f) return Offset.Zero
|
||||
val maxOffsetX = ((viewportWidth * scale) - viewportWidth).coerceAtLeast(0f) / 2f
|
||||
val maxOffsetY = ((viewportHeight * scale) - viewportHeight).coerceAtLeast(0f) / 2f
|
||||
return Offset(
|
||||
x = offset.x.coerceIn(-maxOffsetX, maxOffsetX),
|
||||
y = offset.y.coerceIn(-maxOffsetY, maxOffsetY)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun activePdfCameraAfterLockPreferenceLoad(
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<Float, Float, Float>?
|
||||
): Pair<Float, Offset> {
|
||||
return if (isScrollLocked && lockedState != null) {
|
||||
lockedState.first to Offset(lockedState.second, lockedState.third)
|
||||
} else {
|
||||
1f to Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
internal fun shouldReportPdfPageCamera(
|
||||
isZoomEnabled: Boolean,
|
||||
isVerticalScroll: Boolean,
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<Float, Float, Float>?,
|
||||
hasAppliedLockedState: Boolean
|
||||
): Boolean {
|
||||
return !isZoomEnabled ||
|
||||
isVerticalScroll ||
|
||||
!isScrollLocked ||
|
||||
lockedState == null ||
|
||||
hasAppliedLockedState
|
||||
}
|
||||
|
||||
internal fun initialPdfPageCamera(
|
||||
isZoomEnabled: Boolean,
|
||||
isVerticalScroll: Boolean,
|
||||
isScrollLocked: Boolean,
|
||||
lockedState: Triple<Float, Float, Float>?
|
||||
): Pair<Float, Offset> {
|
||||
return if (isZoomEnabled && !isVerticalScroll && isScrollLocked && lockedState != null) {
|
||||
lockedState.first to Offset(lockedState.second, lockedState.third)
|
||||
} else {
|
||||
1f to Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
internal fun shouldResetPdfZoomAfterBubbleZoomCleanup(
|
||||
isBubbleZoomModeActive: Boolean,
|
||||
scale: Float,
|
||||
isVerticalScroll: Boolean,
|
||||
isZoomEnabled: Boolean,
|
||||
isScrollLocked: Boolean
|
||||
): Boolean {
|
||||
return !isBubbleZoomModeActive &&
|
||||
scale > 1f &&
|
||||
!isVerticalScroll &&
|
||||
isZoomEnabled &&
|
||||
!isScrollLocked
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.net.Uri
|
||||
|
|
@ -33,12 +34,22 @@ import androidx.compose.ui.unit.isSpecified
|
|||
import com.aryan.reader.pdf.data.PdfAnnotation
|
||||
import com.aryan.reader.pdf.data.PdfTextBox
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.PdfPageBounds
|
||||
import com.aryan.reader.shared.pdf.PdfPagePoint
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationExportMapper
|
||||
import com.aryan.reader.shared.pdf.pdfInkAppearancePoints
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
|
@ -111,7 +122,8 @@ internal object PdfiumAnnotationExporter {
|
|||
textBoxes = emptyList(),
|
||||
highlights = highlights.orEmpty(),
|
||||
richTextPageLayouts = emptyList(),
|
||||
rasterOverlays = rasterOverlays
|
||||
rasterOverlays = rasterOverlays,
|
||||
pageSizes = pageSizes
|
||||
)
|
||||
|
||||
if (!payload.hasAnnotations()) {
|
||||
|
|
@ -119,38 +131,51 @@ internal object PdfiumAnnotationExporter {
|
|||
return@withContext
|
||||
}
|
||||
|
||||
val exported = NativePdfiumBridge.exportAnnotatedPdf(
|
||||
sourcePath = sourceFile.absolutePath,
|
||||
destPath = destFile.absolutePath,
|
||||
inkPageIndices = payload.inkPageIndices,
|
||||
inkTypes = payload.inkTypes,
|
||||
inkColors = payload.inkColors,
|
||||
inkStrokeWidths = payload.inkStrokeWidths,
|
||||
inkPointOffsets = payload.inkPointOffsets,
|
||||
inkPointCounts = payload.inkPointCounts,
|
||||
inkPoints = payload.inkPoints,
|
||||
textPageIndices = payload.textPageIndices,
|
||||
textBounds = payload.textBounds,
|
||||
textColors = payload.textColors,
|
||||
textBackgroundColors = payload.textBackgroundColors,
|
||||
textFontSizes = payload.textFontSizes,
|
||||
textFlags = payload.textFlags,
|
||||
textValues = payload.textValues,
|
||||
textFontPaths = payload.textFontPaths,
|
||||
textFontNames = payload.textFontNames,
|
||||
rasterPageIndices = payload.rasterPageIndices,
|
||||
rasterBounds = payload.rasterBounds,
|
||||
rasterWidths = payload.rasterWidths,
|
||||
rasterHeights = payload.rasterHeights,
|
||||
rasterPixelOffsets = payload.rasterPixelOffsets,
|
||||
rasterPixels = payload.rasterPixels,
|
||||
highlightPageIndices = payload.highlightPageIndices,
|
||||
highlightColors = payload.highlightColors,
|
||||
highlightRectOffsets = payload.highlightRectOffsets,
|
||||
highlightRectCounts = payload.highlightRectCounts,
|
||||
highlightRects = payload.highlightRects,
|
||||
highlightContents = payload.highlightContents
|
||||
)
|
||||
val exported = PdfiumEngineProvider.withPdfium {
|
||||
NativePdfiumBridge.exportAnnotatedPdf(
|
||||
sourcePath = sourceFile.absolutePath,
|
||||
destPath = destFile.absolutePath,
|
||||
inkPageIndices = payload.inkPageIndices,
|
||||
inkTypes = payload.inkTypes,
|
||||
inkColors = payload.inkColors,
|
||||
inkStrokeWidths = payload.inkStrokeWidths,
|
||||
inkPointOffsets = payload.inkPointOffsets,
|
||||
inkPointCounts = payload.inkPointCounts,
|
||||
inkPoints = payload.inkPoints,
|
||||
inkNames = payload.inkNames,
|
||||
inkContents = payload.inkContents,
|
||||
textPageIndices = payload.textPageIndices,
|
||||
textBounds = payload.textBounds,
|
||||
textColors = payload.textColors,
|
||||
textBackgroundColors = payload.textBackgroundColors,
|
||||
textFontSizes = payload.textFontSizes,
|
||||
textFlags = payload.textFlags,
|
||||
textValues = payload.textValues,
|
||||
textFontPaths = payload.textFontPaths,
|
||||
textFontNames = payload.textFontNames,
|
||||
rasterPageIndices = payload.rasterPageIndices,
|
||||
rasterBounds = payload.rasterBounds,
|
||||
rasterWidths = payload.rasterWidths,
|
||||
rasterHeights = payload.rasterHeights,
|
||||
rasterPixelOffsets = payload.rasterPixelOffsets,
|
||||
rasterPixels = payload.rasterPixels,
|
||||
highlightPageIndices = payload.highlightPageIndices,
|
||||
highlightColors = payload.highlightColors,
|
||||
highlightRectOffsets = payload.highlightRectOffsets,
|
||||
highlightRectCounts = payload.highlightRectCounts,
|
||||
highlightRects = payload.highlightRects,
|
||||
highlightNames = payload.highlightNames,
|
||||
highlightContents = payload.highlightContents,
|
||||
highlightCommentOffsets = payload.highlightCommentOffsets,
|
||||
highlightCommentCounts = payload.highlightCommentCounts,
|
||||
highlightCommentParentIndices = payload.highlightCommentParentIndices,
|
||||
highlightCommentNames = payload.highlightCommentNames,
|
||||
highlightCommentAuthors = payload.highlightCommentAuthors,
|
||||
highlightCommentContents = payload.highlightCommentContents,
|
||||
highlightCommentCreatedDates = payload.highlightCommentCreatedDates,
|
||||
highlightCommentModifiedDates = payload.highlightCommentModifiedDates
|
||||
)
|
||||
}
|
||||
|
||||
if (!exported) {
|
||||
throw IOException("PDFium failed to write annotated PDF.")
|
||||
|
|
@ -183,15 +208,21 @@ internal object PdfiumAnnotationExporter {
|
|||
highlights: List<PdfUserHighlight>,
|
||||
richTextPageLayouts: List<PageTextLayout> = emptyList(),
|
||||
fontPathResolver: (String?) -> String? = { it },
|
||||
rasterOverlays: List<PdfiumRasterOverlay> = emptyList()
|
||||
rasterOverlays: List<PdfiumRasterOverlay> = emptyList(),
|
||||
pageSizes: List<PdfiumPageSize> = emptyList()
|
||||
): PdfiumAnnotationExportPayload {
|
||||
val inkItems = inkAnnotations.entries
|
||||
.flatMap { (pageIndex, annotations) -> annotations.map { pageIndex to it } }
|
||||
.filter { (_, annotation) ->
|
||||
annotation.points.size >= 2 &&
|
||||
annotation.inkType != InkType.ERASER &&
|
||||
annotation.inkType != InkType.TEXT
|
||||
}
|
||||
val exportPayload = SharedPdfAnnotationExportMapper.build(
|
||||
sharedExportAnnotations(
|
||||
inkAnnotations = inkAnnotations,
|
||||
highlights = highlights,
|
||||
pageSizes = pageSizes
|
||||
)
|
||||
)
|
||||
val inkItems = exportPayload.inkAnnotations
|
||||
val inkPointsForExport = inkItems.map { annotation ->
|
||||
val pageSize = pageSizeFor(pageSizes, annotation.pageIndex)
|
||||
annotation.pdfInkAppearancePoints(pageSize.width.toFloat(), pageSize.height.toFloat())
|
||||
}
|
||||
|
||||
val inkPageIndices = IntArray(inkItems.size)
|
||||
val inkTypes = IntArray(inkItems.size)
|
||||
|
|
@ -199,17 +230,22 @@ internal object PdfiumAnnotationExporter {
|
|||
val inkStrokeWidths = FloatArray(inkItems.size)
|
||||
val inkPointOffsets = IntArray(inkItems.size)
|
||||
val inkPointCounts = IntArray(inkItems.size)
|
||||
val inkPoints = FloatArray(inkItems.sumOf { it.second.points.size } * 2)
|
||||
val inkPoints = FloatArray(inkPointsForExport.sumOf { it.size } * 2)
|
||||
val inkNames = Array(inkItems.size) { "" }
|
||||
val inkContents = Array(inkItems.size) { "" }
|
||||
|
||||
var inkPointCursor = 0
|
||||
inkItems.forEachIndexed { index, (pageIndex, annotation) ->
|
||||
inkPageIndices[index] = pageIndex
|
||||
inkTypes[index] = annotation.inkType.ordinal
|
||||
inkColors[index] = annotation.color.toArgb()
|
||||
inkItems.forEachIndexed { index, annotation ->
|
||||
val points = inkPointsForExport[index]
|
||||
inkPageIndices[index] = annotation.pageIndex
|
||||
inkTypes[index] = annotation.tool.toAndroidInkTypeOrdinal()
|
||||
inkColors[index] = annotation.colorArgb
|
||||
inkStrokeWidths[index] = annotation.strokeWidth
|
||||
inkPointOffsets[index] = inkPointCursor / 2
|
||||
inkPointCounts[index] = annotation.points.size
|
||||
annotation.points.forEach { point ->
|
||||
inkPointCounts[index] = points.size
|
||||
inkNames[index] = annotation.id
|
||||
inkContents[index] = annotation.contents
|
||||
points.forEach { point ->
|
||||
inkPoints[inkPointCursor++] = point.x
|
||||
inkPoints[inkPointCursor++] = point.y
|
||||
}
|
||||
|
|
@ -246,22 +282,49 @@ internal object PdfiumAnnotationExporter {
|
|||
rasterPixelCursor += overlay.pixels.size
|
||||
}
|
||||
|
||||
val boundedHighlights = highlights.filter { it.bounds.isNotEmpty() }
|
||||
val boundedHighlights = exportPayload.highlightAnnotations
|
||||
val highlightPageIndices = IntArray(boundedHighlights.size)
|
||||
val highlightColors = IntArray(boundedHighlights.size)
|
||||
val highlightRectOffsets = IntArray(boundedHighlights.size)
|
||||
val highlightRectCounts = IntArray(boundedHighlights.size)
|
||||
val highlightRects = FloatArray(boundedHighlights.sumOf { it.bounds.size } * 4)
|
||||
val highlightRects = FloatArray(boundedHighlights.sumOf { it.boundsList.size } * 4)
|
||||
val highlightNames = Array(boundedHighlights.size) { "" }
|
||||
val highlightContents = Array(boundedHighlights.size) { "" }
|
||||
val highlightCommentCount = boundedHighlights.sumOf { it.comments.size }
|
||||
val highlightCommentOffsets = IntArray(boundedHighlights.size)
|
||||
val highlightCommentCounts = IntArray(boundedHighlights.size)
|
||||
val highlightCommentParentIndices = IntArray(highlightCommentCount)
|
||||
val highlightCommentNames = Array(highlightCommentCount) { "" }
|
||||
val highlightCommentAuthors = Array(highlightCommentCount) { "" }
|
||||
val highlightCommentContents = Array(highlightCommentCount) { "" }
|
||||
val highlightCommentCreatedDates = Array(highlightCommentCount) { "" }
|
||||
val highlightCommentModifiedDates = Array(highlightCommentCount) { "" }
|
||||
|
||||
var highlightRectCursor = 0
|
||||
var highlightCommentCursor = 0
|
||||
boundedHighlights.forEachIndexed { index, highlight ->
|
||||
highlightPageIndices[index] = highlight.pageIndex
|
||||
highlightColors[index] = highlight.color.color.toArgb()
|
||||
highlightColors[index] = highlight.colorArgb
|
||||
highlightRectOffsets[index] = highlightRectCursor / 4
|
||||
highlightRectCounts[index] = highlight.bounds.size
|
||||
highlightContents[index] = highlight.note?.takeIf { it.isNotBlank() } ?: highlight.text
|
||||
highlight.bounds.forEach { rect ->
|
||||
highlightRectCounts[index] = highlight.boundsList.size
|
||||
highlightNames[index] = highlight.id
|
||||
highlightContents[index] = highlight.contents
|
||||
highlightCommentOffsets[index] = highlightCommentCursor
|
||||
highlightCommentCounts[index] = highlight.comments.size
|
||||
val localCommentIndices = mutableMapOf<String, Int>()
|
||||
highlight.comments.forEachIndexed { localIndex, comment ->
|
||||
val globalIndex = highlightCommentCursor + localIndex
|
||||
highlightCommentParentIndices[globalIndex] = comment.parentId?.let(localCommentIndices::get) ?: -1
|
||||
localCommentIndices[comment.id] = localIndex
|
||||
highlightCommentNames[globalIndex] = comment.id
|
||||
highlightCommentAuthors[globalIndex] = comment.author
|
||||
highlightCommentContents[globalIndex] = comment.contents
|
||||
highlightCommentCreatedDates[globalIndex] = comment.createdAt.toPdfDateString()
|
||||
highlightCommentModifiedDates[globalIndex] = comment.modifiedAt.toPdfDateString()
|
||||
.ifBlank { comment.createdAt.toPdfDateString() }
|
||||
}
|
||||
highlightCommentCursor += highlight.comments.size
|
||||
highlight.boundsList.forEach { rect ->
|
||||
highlightRects[highlightRectCursor++] = rect.left
|
||||
highlightRects[highlightRectCursor++] = rect.top
|
||||
highlightRects[highlightRectCursor++] = rect.right
|
||||
|
|
@ -277,6 +340,8 @@ internal object PdfiumAnnotationExporter {
|
|||
inkPointOffsets = inkPointOffsets,
|
||||
inkPointCounts = inkPointCounts,
|
||||
inkPoints = inkPoints,
|
||||
inkNames = inkNames,
|
||||
inkContents = inkContents,
|
||||
textPageIndices = textPageIndices,
|
||||
textBounds = textBounds,
|
||||
textColors = textColors,
|
||||
|
|
@ -297,7 +362,103 @@ internal object PdfiumAnnotationExporter {
|
|||
highlightRectOffsets = highlightRectOffsets,
|
||||
highlightRectCounts = highlightRectCounts,
|
||||
highlightRects = highlightRects,
|
||||
highlightContents = highlightContents
|
||||
highlightNames = highlightNames,
|
||||
highlightContents = highlightContents,
|
||||
highlightCommentOffsets = highlightCommentOffsets,
|
||||
highlightCommentCounts = highlightCommentCounts,
|
||||
highlightCommentParentIndices = highlightCommentParentIndices,
|
||||
highlightCommentNames = highlightCommentNames,
|
||||
highlightCommentAuthors = highlightCommentAuthors,
|
||||
highlightCommentContents = highlightCommentContents,
|
||||
highlightCommentCreatedDates = highlightCommentCreatedDates,
|
||||
highlightCommentModifiedDates = highlightCommentModifiedDates
|
||||
)
|
||||
}
|
||||
|
||||
private fun sharedExportAnnotations(
|
||||
inkAnnotations: Map<Int, List<PdfAnnotation>>,
|
||||
highlights: List<PdfUserHighlight>,
|
||||
pageSizes: List<PdfiumPageSize>
|
||||
): List<SharedPdfAnnotation> {
|
||||
val annotations = mutableListOf<SharedPdfAnnotation>()
|
||||
inkAnnotations.entries.forEach { (pageIndex, pageAnnotations) ->
|
||||
pageAnnotations.forEach { annotation ->
|
||||
if (annotation.type != AnnotationType.INK) return@forEach
|
||||
annotations += SharedPdfAnnotation(
|
||||
id = annotation.id,
|
||||
pageIndex = pageIndex,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = annotation.inkType.toSharedPdfInkTool(),
|
||||
points = annotation.points.map { point ->
|
||||
PdfPagePoint(point.x, point.y, point.timestamp)
|
||||
},
|
||||
note = annotation.note,
|
||||
colorArgb = annotation.color.toArgb(),
|
||||
strokeWidth = annotation.strokeWidth
|
||||
)
|
||||
}
|
||||
}
|
||||
highlights.forEach { highlight ->
|
||||
val boundsList = highlight.bounds.mapNotNull { rect ->
|
||||
rect.toNormalizedPdfPageBounds(pageSizeFor(pageSizes, highlight.pageIndex))
|
||||
}
|
||||
annotations += SharedPdfAnnotation(
|
||||
id = highlight.id,
|
||||
pageIndex = highlight.pageIndex,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
bounds = boundsList.firstOrNull(),
|
||||
boundsList = boundsList,
|
||||
text = highlight.text,
|
||||
note = highlight.note,
|
||||
comments = highlight.comments,
|
||||
colorArgb = highlight.color.color.toArgb(),
|
||||
rangeStartIndex = highlight.range.first,
|
||||
rangeEndIndex = (highlight.range.second - 1).coerceAtLeast(highlight.range.first)
|
||||
)
|
||||
}
|
||||
return annotations
|
||||
}
|
||||
|
||||
private fun InkType.toSharedPdfInkTool(): PdfInkTool {
|
||||
return when (this) {
|
||||
InkType.PEN -> PdfInkTool.PEN
|
||||
InkType.HIGHLIGHTER -> PdfInkTool.HIGHLIGHTER
|
||||
InkType.HIGHLIGHTER_ROUND -> PdfInkTool.HIGHLIGHTER_ROUND
|
||||
InkType.ERASER -> PdfInkTool.ERASER
|
||||
InkType.FOUNTAIN_PEN -> PdfInkTool.FOUNTAIN_PEN
|
||||
InkType.PENCIL -> PdfInkTool.PENCIL
|
||||
InkType.TEXT -> PdfInkTool.TEXT
|
||||
}
|
||||
}
|
||||
|
||||
private fun PdfInkTool.toAndroidInkTypeOrdinal(): Int {
|
||||
return when (this) {
|
||||
PdfInkTool.HIGHLIGHTER -> InkType.HIGHLIGHTER.ordinal
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> InkType.HIGHLIGHTER_ROUND.ordinal
|
||||
PdfInkTool.FOUNTAIN_PEN -> InkType.FOUNTAIN_PEN.ordinal
|
||||
PdfInkTool.PENCIL -> InkType.PENCIL.ordinal
|
||||
PdfInkTool.TEXT -> InkType.TEXT.ordinal
|
||||
PdfInkTool.ERASER -> InkType.ERASER.ordinal
|
||||
PdfInkTool.NONE,
|
||||
PdfInkTool.PEN -> InkType.PEN.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
private fun RectF.toNormalizedPdfPageBounds(pageSize: PdfiumPageSize): PdfPageBounds? {
|
||||
val pageWidth = pageSize.width.takeIf { it > 0 }?.toFloat() ?: return null
|
||||
val pageHeight = pageSize.height.takeIf { it > 0 }?.toFloat() ?: return null
|
||||
val pdfLeft = minOf(left, right)
|
||||
val pdfRight = maxOf(left, right)
|
||||
val pdfTop = maxOf(top, bottom)
|
||||
val pdfBottom = minOf(top, bottom)
|
||||
if (pdfRight <= pdfLeft || pdfTop <= pdfBottom) return null
|
||||
|
||||
return PdfPageBounds(
|
||||
left = pdfLeft / pageWidth,
|
||||
top = (pageHeight - pdfTop) / pageHeight,
|
||||
right = pdfRight / pageWidth,
|
||||
bottom = (pageHeight - pdfBottom) / pageHeight
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -681,6 +842,13 @@ internal object PdfiumAnnotationExporter {
|
|||
private fun String.sanitizeRasterTextPreservingLength(): String =
|
||||
replace(PAGE_BREAK_CHAR, '\n')
|
||||
.replace('\r', ' ')
|
||||
|
||||
private fun Long.toPdfDateString(): String {
|
||||
if (this <= 0L) return ""
|
||||
return SimpleDateFormat("'D:'yyyyMMddHHmmss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(this))
|
||||
}
|
||||
}
|
||||
|
||||
internal data class PdfiumRasterOverlay(
|
||||
|
|
@ -694,7 +862,7 @@ internal data class PdfiumRasterOverlay(
|
|||
val pixels: IntArray
|
||||
)
|
||||
|
||||
private data class PdfiumPageSize(
|
||||
internal data class PdfiumPageSize(
|
||||
val width: Int,
|
||||
val height: Int
|
||||
) {
|
||||
|
|
@ -738,6 +906,8 @@ internal data class PdfiumAnnotationExportPayload(
|
|||
val inkPointOffsets: IntArray,
|
||||
val inkPointCounts: IntArray,
|
||||
val inkPoints: FloatArray,
|
||||
val inkNames: Array<String>,
|
||||
val inkContents: Array<String>,
|
||||
val textPageIndices: IntArray,
|
||||
val textBounds: FloatArray,
|
||||
val textColors: IntArray,
|
||||
|
|
@ -758,7 +928,16 @@ internal data class PdfiumAnnotationExportPayload(
|
|||
val highlightRectOffsets: IntArray,
|
||||
val highlightRectCounts: IntArray,
|
||||
val highlightRects: FloatArray,
|
||||
val highlightContents: Array<String>
|
||||
val highlightNames: Array<String>,
|
||||
val highlightContents: Array<String>,
|
||||
val highlightCommentOffsets: IntArray,
|
||||
val highlightCommentCounts: IntArray,
|
||||
val highlightCommentParentIndices: IntArray,
|
||||
val highlightCommentNames: Array<String>,
|
||||
val highlightCommentAuthors: Array<String>,
|
||||
val highlightCommentContents: Array<String>,
|
||||
val highlightCommentCreatedDates: Array<String>,
|
||||
val highlightCommentModifiedDates: Array<String>
|
||||
) {
|
||||
fun hasAnnotations(): Boolean =
|
||||
inkPageIndices.isNotEmpty() ||
|
||||
|
|
|
|||
|
|
@ -44,9 +44,11 @@ import androidx.compose.ui.text.style.TextDecoration
|
|||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import androidx.compose.ui.text.font.Font
|
||||
|
|
@ -460,6 +462,65 @@ private fun AnnotatedString.withRestoredTrailingAndroidPageBreak(shouldRestore:
|
|||
return this + AnnotatedString(PAGE_BREAK_CHAR.toString())
|
||||
}
|
||||
|
||||
internal fun androidRichTextInsertionIndexForPage(
|
||||
insertPageIndex: Int,
|
||||
pageLayouts: List<PageTextLayout>,
|
||||
textLength: Int
|
||||
): Int {
|
||||
val rawIndex = if (insertPageIndex <= 0) {
|
||||
0
|
||||
} else {
|
||||
pageLayouts.find { it.pageIndex == insertPageIndex - 1 }?.globalEndIndex ?: textLength
|
||||
}
|
||||
return rawIndex.coerceIn(0, textLength)
|
||||
}
|
||||
|
||||
internal fun androidRichTextBlankInsertBreakCount(text: String, insertionCharIndex: Int): Int {
|
||||
val safeIndex = insertionCharIndex.coerceIn(0, text.length)
|
||||
if (safeIndex == 0 || safeIndex == text.length) return 1
|
||||
|
||||
val hasBoundaryBreakBefore = text.getOrNull(safeIndex - 1) == PAGE_BREAK_CHAR
|
||||
val hasBoundaryBreakAfter = text.getOrNull(safeIndex) == PAGE_BREAK_CHAR
|
||||
return if (hasBoundaryBreakBefore || hasBoundaryBreakAfter) 1 else 2
|
||||
}
|
||||
|
||||
internal fun remapAndroidRichTextForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>,
|
||||
pageLayouts: List<PageTextLayout>
|
||||
): AnnotatedString {
|
||||
if (pageLayouts.isEmpty()) return AnnotatedString("")
|
||||
|
||||
val mapping = buildPdfPageIndexMapping(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
sourcePageIndices = pageLayouts.map { it.pageIndex }
|
||||
)
|
||||
if (mapping.isEmpty()) return AnnotatedString("")
|
||||
|
||||
val contentByTargetPage = linkedMapOf<Int, AnnotatedString>()
|
||||
pageLayouts.sortedBy { it.pageIndex }.forEach { layout ->
|
||||
val targetPageIndex = mapping[layout.pageIndex] ?: return@forEach
|
||||
val pageContent = layout.visibleText.withoutTrailingAndroidPageBreak()
|
||||
contentByTargetPage[targetPageIndex] = pageContent
|
||||
}
|
||||
|
||||
val lastPageWithContent = contentByTargetPage
|
||||
.filterValues { it.text.isNotEmpty() }
|
||||
.keys
|
||||
.maxOrNull()
|
||||
?: return AnnotatedString("")
|
||||
|
||||
val builder = AnnotatedString.Builder()
|
||||
for (pageIndex in 0..lastPageWithContent) {
|
||||
contentByTargetPage[pageIndex]?.let { builder.append(it) }
|
||||
if (pageIndex < lastPageWithContent) {
|
||||
builder.append(PAGE_BREAK_CHAR.toString())
|
||||
}
|
||||
}
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
class PdfRichTextRepository(private val context: Context) {
|
||||
private val _document = MutableStateFlow<GlobalRichDocument?>(null)
|
||||
val document = _document.asStateFlow()
|
||||
|
|
@ -1166,30 +1227,139 @@ class RichTextController(
|
|||
val original = globalTextFieldValue.annotatedString
|
||||
Timber.tag("RichTextMigration").d("insertPageBreakAt: Target Page Index: $insertPageIndex, Count: $count")
|
||||
|
||||
val insertionCharIndex = if (insertPageIndex == 0) 0 else {
|
||||
val prevLayout = pageLayouts.find { it.pageIndex == insertPageIndex - 1 }
|
||||
val idx = prevLayout?.globalEndIndex ?: original.length
|
||||
Timber.tag("RichTextMigration").v("insertPageBreakAt: Prev Page (${insertPageIndex - 1}) ends at global index $idx")
|
||||
idx
|
||||
}
|
||||
val safeIndex = insertionCharIndex.coerceIn(0, original.length)
|
||||
val safeIndex = androidRichTextInsertionIndexForPage(
|
||||
insertPageIndex = insertPageIndex,
|
||||
pageLayouts = pageLayouts,
|
||||
textLength = original.length
|
||||
)
|
||||
Timber.tag("RichTextMigration").v("insertPageBreakAt: insertion index $safeIndex")
|
||||
|
||||
Timber.tag("RichTextMigration").i("insertPageBreakAt: Inserting $count PAGE_BREAK_CHARs at global index $safeIndex")
|
||||
insertPageBreaksIntoGlobalText(
|
||||
original = original,
|
||||
safeIndex = safeIndex,
|
||||
count = count,
|
||||
caller = "InsertPageBreakAt"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.append(original.subSequence(0, safeIndex))
|
||||
fun insertBlankPageAt(insertPageIndex: Int) {
|
||||
scope.launch {
|
||||
forceSyncAndClear()
|
||||
|
||||
repeat(count) {
|
||||
builder.append(PAGE_BREAK_CHAR.toString())
|
||||
}
|
||||
val original = globalTextFieldValue.annotatedString
|
||||
val safeIndex = androidRichTextInsertionIndexForPage(
|
||||
insertPageIndex = insertPageIndex,
|
||||
pageLayouts = pageLayouts,
|
||||
textLength = original.length
|
||||
)
|
||||
val requiredBreaks = androidRichTextBlankInsertBreakCount(
|
||||
text = original.text,
|
||||
insertionCharIndex = safeIndex
|
||||
)
|
||||
|
||||
builder.append(original.subSequence(safeIndex, original.length))
|
||||
Timber.tag("RichTextMigration").i(
|
||||
"insertBlankPageAt: page=$insertPageIndex index=$safeIndex breaks=$requiredBreaks"
|
||||
)
|
||||
|
||||
val newCursorPos = safeIndex + count
|
||||
insertPageBreaksIntoGlobalText(
|
||||
original = original,
|
||||
safeIndex = safeIndex,
|
||||
count = requiredBreaks,
|
||||
caller = "InsertBlankPageAt"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(newCursorPos))
|
||||
debouncedSave(globalTextFieldValue)
|
||||
repaginate(dirtyStartIndex = safeIndex, caller = "InsertPageBreakAt")
|
||||
suspend fun remapPagesForLayoutChange(
|
||||
currentLayout: List<VirtualPage>,
|
||||
updatedLayout: List<VirtualPage>
|
||||
) = withContext(NonCancellable) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"rich.remap.start current=${currentLayout.pdfLayoutDebugSummary()} " +
|
||||
"updated=${updatedLayout.pdfLayoutDebugSummary()} pageLayouts=${pageLayouts.size} " +
|
||||
"textLen=${globalTextFieldValue.annotatedString.length}"
|
||||
)
|
||||
forceSyncAndClear()
|
||||
|
||||
val original = globalTextFieldValue.annotatedString
|
||||
if (pageLayouts.isEmpty() && original.text.isNotEmpty()) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
|
||||
"rich.remap.skipNoLayouts current=${currentLayout.pdfLayoutDebugSummary()} " +
|
||||
"updated=${updatedLayout.pdfLayoutDebugSummary()} textLen=${original.length}"
|
||||
)
|
||||
Timber.tag("RichTextMigration").w(
|
||||
"remapPagesForLayoutChange skipped: no rich text page layouts for non-empty text"
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
val remapped = remapAndroidRichTextForLayoutChange(
|
||||
currentLayout = currentLayout,
|
||||
updatedLayout = updatedLayout,
|
||||
pageLayouts = pageLayouts
|
||||
)
|
||||
|
||||
if (remapped == original) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"rich.remap.noChange current=${currentLayout.pdfLayoutDebugSummary()} " +
|
||||
"updated=${updatedLayout.pdfLayoutDebugSummary()} textLen=${original.length}"
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
Timber.tag("RichTextMigration").i(
|
||||
"remapPagesForLayoutChange: textLen ${original.length} -> ${remapped.length}, " +
|
||||
"pages ${currentLayout.size} -> ${updatedLayout.size}"
|
||||
)
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"rich.remap.apply textLen=${original.length}->${remapped.length} " +
|
||||
"current=${currentLayout.pdfLayoutDebugSummary()} updated=${updatedLayout.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
|
||||
globalTextFieldValue = TextFieldValue(
|
||||
remapped,
|
||||
selection = TextRange(remapped.length)
|
||||
)
|
||||
repaginateSync(0)
|
||||
saveCurrentGlobalTextImmediately()
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"rich.remap.done textLen=${globalTextFieldValue.annotatedString.length} pageLayouts=${pageLayouts.size}"
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertPageBreaksIntoGlobalText(
|
||||
original: AnnotatedString,
|
||||
safeIndex: Int,
|
||||
count: Int,
|
||||
caller: String
|
||||
) {
|
||||
val safeCount = count.coerceAtLeast(0)
|
||||
if (safeCount == 0) return
|
||||
|
||||
Timber.tag("RichTextMigration").i("$caller: Inserting $safeCount PAGE_BREAK_CHARs at global index $safeIndex")
|
||||
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.append(original.subSequence(0, safeIndex))
|
||||
|
||||
repeat(safeCount) {
|
||||
builder.append(PAGE_BREAK_CHAR.toString())
|
||||
}
|
||||
|
||||
builder.append(original.subSequence(safeIndex, original.length))
|
||||
|
||||
val newCursorPos = safeIndex + safeCount
|
||||
|
||||
globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(newCursorPos))
|
||||
debouncedSave(globalTextFieldValue)
|
||||
repaginate(dirtyStartIndex = safeIndex, caller = caller)
|
||||
}
|
||||
|
||||
private suspend fun saveCurrentGlobalTextImmediately() {
|
||||
saveJob?.cancel()
|
||||
val finalAnnotated = globalTextFieldValue.annotatedString
|
||||
withContext(Dispatchers.Default) {
|
||||
val doc = RichTextMapper.fromAnnotatedString(finalAnnotated, lastPageHeight)
|
||||
repository.save(bookId, doc)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,19 @@ interface ReaderTextPage : AutoCloseable {
|
|||
data class ReaderLink(val uri: String?, val destPageIdx: Int?, val bounds: RectF)
|
||||
data class ReaderTextRect(val rect: RectF)
|
||||
|
||||
internal data class PdfNativePageOverlayExtraction(
|
||||
val embeddedAnnotations: List<EmbeddedAnnotation> = emptyList(),
|
||||
val annotationScreenRects: List<Pair<EmbeddedAnnotation, Rect>> = emptyList(),
|
||||
val imageScreenRects: List<Rect> = emptyList(),
|
||||
val resolvedNativePointer: Boolean = true
|
||||
)
|
||||
|
||||
internal data class PdfNativeTapResult(
|
||||
val linkInfo: String? = null,
|
||||
val clickHandled: Boolean = false,
|
||||
val resolvedNativePointer: Boolean = true
|
||||
)
|
||||
|
||||
interface ReaderWebLinks : AutoCloseable {
|
||||
suspend fun countWebLinks(): Int
|
||||
suspend fun getURL(linkIndex: Int, maxLength: Int): String?
|
||||
|
|
@ -103,7 +116,16 @@ object DocumentFactory {
|
|||
ArchiveDocumentWrapper(cacheFile)
|
||||
} else {
|
||||
val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF")
|
||||
PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) })
|
||||
try {
|
||||
PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) })
|
||||
} catch (e: Throwable) {
|
||||
try {
|
||||
pfd.close()
|
||||
} catch (closeError: Exception) {
|
||||
e.addSuppressed(closeError)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -134,13 +156,25 @@ class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
|
|||
val page = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else pdfDocument.openPage(pageIndex)
|
||||
} ?: return null
|
||||
return PdfPageWrapper(page)
|
||||
return PdfPageWrapper(page, isClosed)
|
||||
}
|
||||
|
||||
override suspend fun getTableOfContents() = PdfiumEngineProvider.withPdfium {
|
||||
pdfDocument.getFixedTableOfContents()
|
||||
}
|
||||
|
||||
internal fun getNativeDocumentPointerForLockedAccess(): Long {
|
||||
if (isClosed.get()) return 0L
|
||||
return try {
|
||||
val documentField = pdfDocument.javaClass.getDeclaredField("document").apply { isAccessible = true }
|
||||
val docUInstance = documentField.get(pdfDocument) ?: return 0L
|
||||
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
|
||||
ptrField.get(docUInstance) as? Long ?: 0L
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed.compareAndSet(false, true)) return
|
||||
PdfiumEngineProvider.withPdfiumBlocking {
|
||||
|
|
@ -149,24 +183,28 @@ class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
|
|||
}
|
||||
}
|
||||
|
||||
class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
||||
class PdfPageWrapper(
|
||||
val pdfPage: PdfPageKt,
|
||||
private val ownerClosed: AtomicBoolean = AtomicBoolean(false)
|
||||
) : ReaderPage {
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get()
|
||||
|
||||
override suspend fun getPageWidthPoint() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else pdfPage.getPageWidthPoint()
|
||||
if (isUnavailable()) 0 else pdfPage.getPageWidthPoint()
|
||||
}
|
||||
|
||||
override suspend fun getPageHeightPoint() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else pdfPage.getPageHeightPoint()
|
||||
if (isUnavailable()) 0 else pdfPage.getPageHeightPoint()
|
||||
}
|
||||
|
||||
override suspend fun getPageRotation() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else pdfPage.getPageRotation()
|
||||
if (isUnavailable()) 0 else pdfPage.getPageRotation()
|
||||
}
|
||||
|
||||
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
if (!isClosed.get()) {
|
||||
if (!isUnavailable()) {
|
||||
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
|
||||
}
|
||||
}
|
||||
|
|
@ -174,21 +212,21 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
|||
|
||||
override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) =
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) Rect() else pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
|
||||
if (isUnavailable()) Rect() else pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
|
||||
}
|
||||
|
||||
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int) =
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
|
||||
if (isUnavailable()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
|
||||
}
|
||||
|
||||
override suspend fun openTextPage(): ReaderTextPage = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage())
|
||||
if (isUnavailable()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage(), ownerClosed, isClosed)
|
||||
}
|
||||
|
||||
override suspend fun getLinks(): List<ReaderLink> {
|
||||
return PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) {
|
||||
if (isUnavailable()) {
|
||||
emptyList()
|
||||
} else {
|
||||
pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
|
||||
|
|
@ -197,9 +235,191 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
|||
}
|
||||
|
||||
override fun getNativePointer(): Long {
|
||||
if (isUnavailable()) return 0L
|
||||
return extractNativePointer(pdfPage)
|
||||
}
|
||||
|
||||
internal suspend fun extractNativePageOverlays(
|
||||
bitmapWidthPx: Int,
|
||||
bitmapHeightPx: Int,
|
||||
pageRotation: Int,
|
||||
pageIndex: Int,
|
||||
linkAnnotationSubtype: Int
|
||||
): PdfNativePageOverlayExtraction = PdfiumEngineProvider.withPdfium {
|
||||
if (isUnavailable() || bitmapWidthPx <= 0 || bitmapHeightPx <= 0) {
|
||||
return@withPdfium PdfNativePageOverlayExtraction()
|
||||
}
|
||||
|
||||
val pagePtr = extractNativePointer(pdfPage)
|
||||
if (pagePtr == 0L) {
|
||||
return@withPdfium PdfNativePageOverlayExtraction(resolvedNativePointer = false)
|
||||
}
|
||||
|
||||
val imageRects = extractImageScreenRectsLocked(pagePtr, bitmapWidthPx, bitmapHeightPx, pageRotation)
|
||||
val embeddedAnnotations = extractEmbeddedAnnotationsLocked(pagePtr, pageIndex, linkAnnotationSubtype)
|
||||
val mappedAnnots = embeddedAnnotations.mapNotNull { annotation ->
|
||||
val screenRect = pdfPage.mapRectToDevice(
|
||||
0,
|
||||
0,
|
||||
bitmapWidthPx,
|
||||
bitmapHeightPx,
|
||||
pageRotation,
|
||||
annotation.rect
|
||||
)
|
||||
if (screenRect.width() > 0 && screenRect.height() > 0) {
|
||||
annotation to screenRect
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
PdfNativePageOverlayExtraction(
|
||||
embeddedAnnotations = embeddedAnnotations,
|
||||
annotationScreenRects = mappedAnnots,
|
||||
imageScreenRects = imageRects
|
||||
)
|
||||
}
|
||||
|
||||
internal suspend fun resolveNativeTap(
|
||||
documentWrapper: PdfDocumentWrapper?,
|
||||
bitmapWidthPx: Int,
|
||||
bitmapHeightPx: Int,
|
||||
pageRotation: Int,
|
||||
deviceX: Int,
|
||||
deviceY: Int
|
||||
): PdfNativeTapResult = PdfiumEngineProvider.withPdfium {
|
||||
if (isUnavailable() || bitmapWidthPx <= 0 || bitmapHeightPx <= 0) {
|
||||
return@withPdfium PdfNativeTapResult()
|
||||
}
|
||||
|
||||
val pagePtr = extractNativePointer(pdfPage)
|
||||
if (pagePtr == 0L) {
|
||||
return@withPdfium PdfNativeTapResult(resolvedNativePointer = false)
|
||||
}
|
||||
|
||||
val pdfCoords = pdfPage.mapDeviceCoordsToPage(
|
||||
0,
|
||||
0,
|
||||
bitmapWidthPx,
|
||||
bitmapHeightPx,
|
||||
pageRotation,
|
||||
deviceX,
|
||||
deviceY
|
||||
)
|
||||
val docPtr = documentWrapper?.getNativeDocumentPointerForLockedAccess() ?: 0L
|
||||
|
||||
Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr")
|
||||
|
||||
val linkInfo = NativePdfiumBridge.getLinkInfoAtPoint(
|
||||
docPtr,
|
||||
pagePtr,
|
||||
pdfCoords.x.toDouble(),
|
||||
pdfCoords.y.toDouble()
|
||||
)
|
||||
if (linkInfo != null) {
|
||||
return@withPdfium PdfNativeTapResult(linkInfo = linkInfo)
|
||||
}
|
||||
|
||||
PdfNativeTapResult(
|
||||
clickHandled = NativePdfiumBridge.performClick(
|
||||
pagePtr,
|
||||
pdfCoords.x.toDouble(),
|
||||
pdfCoords.y.toDouble()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun extractImageScreenRectsLocked(
|
||||
pagePtr: Long,
|
||||
bitmapWidthPx: Int,
|
||||
bitmapHeightPx: Int,
|
||||
pageRotation: Int
|
||||
): List<Rect> {
|
||||
return try {
|
||||
val objectCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
|
||||
if (objectCount <= 0) return emptyList()
|
||||
|
||||
val rects = mutableListOf<Rect>()
|
||||
val outRect = FloatArray(4)
|
||||
for (index in 0 until objectCount) {
|
||||
if (NativePdfiumBridge.getPageObjectType(pagePtr, index) != 3) continue
|
||||
if (!NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, index, outRect)) continue
|
||||
|
||||
val pdfRect = RectF(
|
||||
minOf(outRect[0], outRect[2]),
|
||||
maxOf(outRect[1], outRect[3]),
|
||||
maxOf(outRect[0], outRect[2]),
|
||||
minOf(outRect[1], outRect[3])
|
||||
)
|
||||
val deviceRect = pdfPage.mapRectToDevice(
|
||||
0,
|
||||
0,
|
||||
bitmapWidthPx,
|
||||
bitmapHeightPx,
|
||||
pageRotation,
|
||||
pdfRect
|
||||
)
|
||||
if (deviceRect.width() > 0 && deviceRect.height() > 0) {
|
||||
rects += Rect(deviceRect.left, deviceRect.top, deviceRect.right, deviceRect.bottom)
|
||||
}
|
||||
}
|
||||
rects
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PdfImageDebug").e(e, "Error extracting image rects")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractEmbeddedAnnotationsLocked(
|
||||
pagePtr: Long,
|
||||
pageIndex: Int,
|
||||
linkAnnotationSubtype: Int
|
||||
): List<EmbeddedAnnotation> {
|
||||
return try {
|
||||
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
|
||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
||||
if (count <= 0) return emptyList()
|
||||
|
||||
val annotations = mutableListOf<EmbeddedAnnotation>()
|
||||
for (index in 0 until count) {
|
||||
val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, index)
|
||||
if (subtype == linkAnnotationSubtype) continue
|
||||
|
||||
var contents = NativePdfiumBridge.getAnnotString(pagePtr, index, "Contents")
|
||||
if (contents.isNullOrBlank()) {
|
||||
contents = NativePdfiumBridge.getAnnotString(pagePtr, index, "RC")
|
||||
}
|
||||
|
||||
val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, index)
|
||||
val pdfRect = if (pdfRectArray != null) {
|
||||
RectF(
|
||||
minOf(pdfRectArray[0], pdfRectArray[2]),
|
||||
maxOf(pdfRectArray[1], pdfRectArray[3]),
|
||||
maxOf(pdfRectArray[0], pdfRectArray[2]),
|
||||
minOf(pdfRectArray[1], pdfRectArray[3])
|
||||
)
|
||||
} else {
|
||||
RectF()
|
||||
}
|
||||
|
||||
annotations += EmbeddedAnnotation(
|
||||
index = index,
|
||||
subtype = subtype,
|
||||
rect = pdfRect,
|
||||
contents = contents,
|
||||
author = NativePdfiumBridge.getAnnotString(pagePtr, index, "T"),
|
||||
name = NativePdfiumBridge.getAnnotString(pagePtr, index, "NM"),
|
||||
inReplyTo = NativePdfiumBridge.getAnnotString(pagePtr, index, "IRT")
|
||||
)
|
||||
}
|
||||
|
||||
groupEmbeddedAnnotationsForDisplay(annotations)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("PdfCommentDebug").e(e, "Error extracting annotations")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractNativePointer(obj: Any): Long {
|
||||
val priorityFields = listOf("page", "mNativePagePtr", "pagePtr", "mNativePage")
|
||||
|
||||
|
|
@ -231,66 +451,74 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
|||
|
||||
override fun close() {
|
||||
if (!isClosed.compareAndSet(false, true)) return
|
||||
if (ownerClosed.get()) return
|
||||
PdfiumEngineProvider.withPdfiumBlocking {
|
||||
closePdfiumResource("PdfPageWrapper") { pdfPage.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
|
||||
class PdfTextPageWrapper(
|
||||
private val textPage: PdfTextPageKt,
|
||||
private val ownerClosed: AtomicBoolean = AtomicBoolean(false),
|
||||
private val pageClosed: AtomicBoolean = AtomicBoolean(false)
|
||||
) : ReaderTextPage {
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get() || pageClosed.get()
|
||||
|
||||
override suspend fun textPageCountChars() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else textPage.textPageCountChars()
|
||||
if (isUnavailable()) 0 else textPage.textPageCountChars()
|
||||
}
|
||||
|
||||
override suspend fun textPageGetText(startIndex: Int, count: Int) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else textPage.textPageGetText(startIndex, count)
|
||||
if (isUnavailable()) null else textPage.textPageGetText(startIndex, count)
|
||||
}
|
||||
|
||||
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
|
||||
if (isUnavailable()) null else textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
|
||||
}
|
||||
|
||||
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) -1 else textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
|
||||
if (isUnavailable()) -1 else textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
|
||||
}
|
||||
|
||||
override suspend fun textPageGetCharBox(index: Int) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else textPage.textPageGetCharBox(index)
|
||||
if (isUnavailable()) null else textPage.textPageGetCharBox(index)
|
||||
}
|
||||
|
||||
override suspend fun textPageGetUnicode(index: Int): Int {
|
||||
return PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else textPage.textPageGetUnicode(index).code
|
||||
if (isUnavailable()) 0 else textPage.textPageGetUnicode(index).code
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadWebLink(): ReaderWebLinks? {
|
||||
val links = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else textPage.loadWebLink()
|
||||
if (isUnavailable()) null else textPage.loadWebLink()
|
||||
} ?: return null
|
||||
return object : ReaderWebLinks {
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get() || pageClosed.get()
|
||||
|
||||
override suspend fun countWebLinks() = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else links.countWebLinks()
|
||||
if (isUnavailable()) 0 else links.countWebLinks()
|
||||
}
|
||||
|
||||
override suspend fun getURL(linkIndex: Int, maxLength: Int) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) null else links.getURL(linkIndex, maxLength)
|
||||
if (isUnavailable()) null else links.getURL(linkIndex, maxLength)
|
||||
}
|
||||
|
||||
override suspend fun countRects(linkIndex: Int) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) 0 else links.countRects(linkIndex)
|
||||
if (isUnavailable()) 0 else links.countRects(linkIndex)
|
||||
}
|
||||
|
||||
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = PdfiumEngineProvider.withPdfium {
|
||||
if (isClosed.get()) RectF() else links.getRect(linkIndex, rectIndex)
|
||||
if (isUnavailable()) RectF() else links.getRect(linkIndex, rectIndex)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed.compareAndSet(false, true)) return
|
||||
if (ownerClosed.get() || pageClosed.get()) return
|
||||
PdfiumEngineProvider.withPdfiumBlocking {
|
||||
closePdfiumResource("PdfWebLinksWrapper") { links.close() }
|
||||
}
|
||||
|
|
@ -299,6 +527,7 @@ class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
|
|||
}
|
||||
override fun close() {
|
||||
if (!isClosed.compareAndSet(false, true)) return
|
||||
if (ownerClosed.get() || pageClosed.get()) return
|
||||
PdfiumEngineProvider.withPdfiumBlocking {
|
||||
closePdfiumResource("PdfTextPageWrapper") { textPage.close() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
package com.aryan.reader.pdf.data
|
||||
|
||||
import android.content.Context
|
||||
import com.aryan.reader.pdf.PDF_BLANK_PAGE_PERSISTENCE_TAG
|
||||
import com.aryan.reader.pdf.pdfLayoutDebugSummary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
|
|
@ -41,6 +43,12 @@ class PageLayoutRepository(private val context: Context) {
|
|||
}
|
||||
|
||||
suspend fun saveLayout(bookId: String, pages: List<VirtualPage>) = withContext(Dispatchers.IO) {
|
||||
val file = getFile(bookId)
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.saveLayout.start bookId=$bookId file=${file.absolutePath} " +
|
||||
"beforeExists=${file.exists()} beforeBytes=${if (file.exists()) file.length() else 0L} " +
|
||||
"beforeMtime=${if (file.exists()) file.lastModified() else 0L} layout=${pages.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
val jsonArray = JSONArray()
|
||||
pages.forEach { page ->
|
||||
val obj = JSONObject()
|
||||
|
|
@ -59,13 +67,27 @@ class PageLayoutRepository(private val context: Context) {
|
|||
}
|
||||
jsonArray.put(obj)
|
||||
}
|
||||
getFile(bookId).writeText(jsonArray.toString())
|
||||
file.writeText(jsonArray.toString())
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.saveLayout.done bookId=$bookId file=${file.absolutePath} " +
|
||||
"afterExists=${file.exists()} afterBytes=${file.length()} afterMtime=${file.lastModified()} " +
|
||||
"layout=${pages.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun loadLayout(bookId: String, totalPdfPages: Int): List<VirtualPage> = withContext(Dispatchers.IO) {
|
||||
val file = getFile(bookId)
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.loadLayout.start bookId=$bookId totalPdfPages=$totalPdfPages file=${file.absolutePath} " +
|
||||
"exists=${file.exists()} bytes=${if (file.exists()) file.length() else 0L} " +
|
||||
"mtime=${if (file.exists()) file.lastModified() else 0L}"
|
||||
)
|
||||
if (!file.exists()) {
|
||||
return@withContext (0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
|
||||
val fallback = (0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
|
||||
"repo.loadLayout.missing bookId=$bookId returningDefault=${fallback.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
return@withContext fallback
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -84,18 +106,32 @@ class PageLayoutRepository(private val context: Context) {
|
|||
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h, isManual))
|
||||
}
|
||||
}
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.loadLayout.parsed bookId=$bookId layout=${list.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
list
|
||||
} catch (_: Exception) {
|
||||
(0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
|
||||
} catch (e: Exception) {
|
||||
val fallback = (0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e(
|
||||
e,
|
||||
"repo.loadLayout.failed bookId=$bookId returningDefault=${fallback.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLayoutOrNull(bookId: String): List<VirtualPage>? = withContext(Dispatchers.IO) {
|
||||
val file = getFile(bookId)
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.getLayoutOrNull.start bookId=$bookId file=${file.absolutePath} " +
|
||||
"exists=${file.exists()} bytes=${if (file.exists()) file.length() else 0L} " +
|
||||
"mtime=${if (file.exists()) file.lastModified() else 0L}"
|
||||
)
|
||||
Timber.tag("PdfExportDebug").d("PageLayoutRepo: Looking for layout at ${file.absolutePath}")
|
||||
Timber.tag("PdfExportDebug").d("PageLayoutRepo: File exists: ${file.exists()}")
|
||||
|
||||
if (!file.exists()) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w("repo.getLayoutOrNull.missing bookId=$bookId")
|
||||
Timber.tag("PdfExportDebug").w("PageLayoutRepo: No layout file for book $bookId")
|
||||
return@withContext null
|
||||
}
|
||||
|
|
@ -114,14 +150,19 @@ class PageLayoutRepository(private val context: Context) {
|
|||
} else {
|
||||
val w = obj.optInt("w", 595)
|
||||
val h = obj.optInt("h", 842)
|
||||
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h))
|
||||
val isManual = obj.optBoolean("manual", false)
|
||||
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h, isManual))
|
||||
}
|
||||
}
|
||||
Timber.tag("PdfExportDebug").i("PageLayoutRepo: Parsed ${list.size} virtual pages (${
|
||||
list.count { it is VirtualPage.PdfPage }
|
||||
} PDF, ${list.count { it is VirtualPage.BlankPage }} blank)")
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
|
||||
"repo.getLayoutOrNull.parsed bookId=$bookId layout=${list.pdfLayoutDebugSummary()}"
|
||||
)
|
||||
list
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e(e, "repo.getLayoutOrNull.failed bookId=$bookId")
|
||||
Timber.tag("PdfExportDebug").e(e, "PageLayoutRepo: Failed to parse layout")
|
||||
null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import com.aryan.reader.pdf.InkType
|
|||
import com.aryan.reader.pdf.PdfHighlightColor
|
||||
import com.aryan.reader.pdf.PdfPoint
|
||||
import com.aryan.reader.pdf.PdfUserHighlight
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
|
|
@ -237,6 +238,10 @@ object HighlightSerializer {
|
|||
if (!h.note.isNullOrBlank()) {
|
||||
obj.put("note", h.note)
|
||||
}
|
||||
val commentsArray = h.comments.toJsonArray()
|
||||
if (commentsArray.length() > 0) {
|
||||
obj.put("comments", commentsArray)
|
||||
}
|
||||
|
||||
val boundsArray = JSONArray()
|
||||
h.bounds.forEach { r ->
|
||||
|
|
@ -279,7 +284,8 @@ object HighlightSerializer {
|
|||
color = try { PdfHighlightColor.valueOf(obj.getString("color")) } catch(_: Exception) { PdfHighlightColor.YELLOW },
|
||||
text = obj.optString("text", ""),
|
||||
range = Pair(obj.optInt("rangeStart", 0), obj.optInt("rangeEnd", 0)),
|
||||
note = obj.optString("note").takeIf { !it.isNullOrBlank() }
|
||||
note = obj.optString("note").takeIf { !it.isNullOrBlank() },
|
||||
comments = obj.optJSONArray("comments").toSharedPdfAnnotationComments()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -288,4 +294,50 @@ object HighlightSerializer {
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotationComment>.toJsonArray(): JSONArray {
|
||||
val array = JSONArray()
|
||||
forEach { comment ->
|
||||
val contents = comment.contents.trim()
|
||||
if (contents.isBlank()) return@forEach
|
||||
val obj = JSONObject()
|
||||
obj.put("id", comment.id)
|
||||
comment.parentId?.takeIf { it.isNotBlank() }?.let { obj.put("parentId", it) }
|
||||
comment.author.takeIf { it.isNotBlank() }?.let { obj.put("author", it) }
|
||||
obj.put("contents", contents)
|
||||
if (comment.createdAt > 0L) obj.put("createdAt", comment.createdAt)
|
||||
val modifiedAt = comment.modifiedAt.takeIf { it > 0L } ?: comment.createdAt
|
||||
if (modifiedAt > 0L) obj.put("modifiedAt", modifiedAt)
|
||||
array.put(obj)
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
private fun JSONArray?.toSharedPdfAnnotationComments(): List<SharedPdfAnnotationComment> {
|
||||
if (this == null) return emptyList()
|
||||
val comments = mutableListOf<SharedPdfAnnotationComment>()
|
||||
for (index in 0 until length()) {
|
||||
val obj = optJSONObject(index) ?: continue
|
||||
val contents = obj.optString("contents")
|
||||
.ifBlank { obj.optString("text") }
|
||||
.ifBlank { obj.optString("comment") }
|
||||
.trim()
|
||||
if (contents.isBlank()) continue
|
||||
val createdAt = obj.optLong("createdAt", obj.optLong("created", 0L))
|
||||
comments += SharedPdfAnnotationComment(
|
||||
id = obj.optString("id").takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString(),
|
||||
parentId = obj.optString("parentId")
|
||||
.ifBlank { obj.optString("inReplyTo") }
|
||||
.takeIf { it.isNotBlank() },
|
||||
author = obj.optString("author").trim(),
|
||||
contents = contents,
|
||||
createdAt = createdAt,
|
||||
modifiedAt = obj.optLong(
|
||||
"modifiedAt",
|
||||
obj.optLong("modified", createdAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
return comments
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -372,43 +372,50 @@ class PdfTextRepository(context: Context) {
|
|||
): List<RectF> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val rects = mutableListOf<RectF>()
|
||||
var bitmap: android.graphics.Bitmap? = null
|
||||
var targetWidth = 0
|
||||
var targetHeight = 0
|
||||
try {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
val targetWidth = 1080
|
||||
val ptrWidth = page.getPageWidthPoint()
|
||||
val ptrHeight = page.getPageHeightPoint()
|
||||
PdfiumEngineProvider.withPdfium {
|
||||
document.openPage(pageIndex)?.use { page ->
|
||||
targetWidth = 1080
|
||||
val ptrWidth = page.getPageWidthPoint()
|
||||
val ptrHeight = page.getPageHeightPoint()
|
||||
|
||||
if (ptrWidth <= 0 || ptrHeight <= 0) return@use
|
||||
if (ptrWidth <= 0 || ptrHeight <= 0) return@use
|
||||
|
||||
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
|
||||
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
|
||||
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
|
||||
targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
|
||||
|
||||
val bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false)
|
||||
bitmap = createBitmap(targetWidth, targetHeight)
|
||||
page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false)
|
||||
}
|
||||
}
|
||||
|
||||
val visionText = OcrHelper.extractTextFromBitmap(bitmap, onModelDownloading)
|
||||
val renderedBitmap = bitmap ?: return@withContext rects
|
||||
val visionText = OcrHelper.extractTextFromBitmap(renderedBitmap, onModelDownloading)
|
||||
|
||||
visionText?.textBlocks?.forEach { block ->
|
||||
block.lines.forEach { line ->
|
||||
line.elements.forEach { element ->
|
||||
if (element.text.contains(query, ignoreCase = true)) {
|
||||
element.boundingBox?.let { box ->
|
||||
val normalized = RectF(
|
||||
box.left.toFloat() / targetWidth,
|
||||
box.top.toFloat() / targetHeight,
|
||||
box.right.toFloat() / targetWidth,
|
||||
box.bottom.toFloat() / targetHeight
|
||||
)
|
||||
rects.add(normalized)
|
||||
}
|
||||
visionText?.textBlocks?.forEach { block ->
|
||||
block.lines.forEach { line ->
|
||||
line.elements.forEach { element ->
|
||||
if (element.text.contains(query, ignoreCase = true)) {
|
||||
element.boundingBox?.let { box ->
|
||||
val normalized = RectF(
|
||||
box.left.toFloat() / targetWidth,
|
||||
box.top.toFloat() / targetHeight,
|
||||
box.right.toFloat() / targetWidth,
|
||||
box.bottom.toFloat() / targetHeight
|
||||
)
|
||||
rects.add(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bitmap.recycle()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to get OCR rects for page $pageIndex")
|
||||
} finally {
|
||||
bitmap?.recycle()
|
||||
}
|
||||
rects
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
198
app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt
Normal file
198
app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package com.aryan.reader.tts
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
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.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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
||||
|
||||
private const val TTS_MINI_BAR_EDGE_PADDING_DP = 16
|
||||
private const val TTS_MINI_BAR_MAIN_BOTTOM_PADDING_DP = 96
|
||||
|
||||
internal fun shouldShowReaderTtsMiniBar(
|
||||
ttsState: TtsState,
|
||||
isOnReaderRoute: Boolean
|
||||
): Boolean {
|
||||
if (isOnReaderRoute) return false
|
||||
if (ttsState.playbackSource != "READER") return false
|
||||
if (ttsState.sessionEndedByStop || ttsState.sessionFinished) return false
|
||||
return ttsState.isLoading || !ttsState.currentText.isNullOrBlank()
|
||||
}
|
||||
|
||||
internal fun readerTtsMiniBarBottomPaddingDp(isOnMainRoute: Boolean): Int {
|
||||
return if (isOnMainRoute) {
|
||||
TTS_MINI_BAR_MAIN_BOTTOM_PADDING_DP
|
||||
} else {
|
||||
TTS_MINI_BAR_EDGE_PADDING_DP
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun ReaderTtsMiniBar(
|
||||
ttsController: TtsController,
|
||||
ttsState: TtsState,
|
||||
onOpenReader: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val canOpenReader = !ttsState.bookId.isNullOrBlank()
|
||||
val canSkipPreviousChunk = !ttsState.isLoading &&
|
||||
ttsState.currentChunkIndex > 0 &&
|
||||
ttsState.totalChunks > 0
|
||||
val canSkipNextChunk = !ttsState.isLoading &&
|
||||
ttsState.currentChunkIndex >= 0 &&
|
||||
ttsState.currentChunkIndex < ttsState.totalChunks - 1
|
||||
val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
|
||||
if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) {
|
||||
"Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val title = ttsState.bookTitle
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.action_read_aloud)
|
||||
val subtitle = remember(title, ttsState.chapterTitle, chunkLabel) {
|
||||
listOfNotNull(
|
||||
ttsState.chapterTitle
|
||||
?.takeIf { it.isNotBlank() && it != title },
|
||||
chunkLabel
|
||||
).joinToString(" - ")
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 8.dp,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 64.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(enabled = canOpenReader, onClick = onOpenReader)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (subtitle.isNotBlank()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(4.dp))
|
||||
|
||||
IconButton(
|
||||
enabled = canSkipPreviousChunk,
|
||||
onClick = ttsController::skipToPreviousChunk,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SkipPrevious,
|
||||
contentDescription = stringResource(R.string.content_desc_tts_previous_chunk),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
if (ttsState.isPlaying) {
|
||||
ttsController.pause()
|
||||
} else {
|
||||
ttsController.resume()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(44.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
||||
contentDescription = stringResource(
|
||||
if (ttsState.isPlaying) {
|
||||
R.string.content_desc_pause_tts
|
||||
} else {
|
||||
R.string.content_desc_resume_tts
|
||||
}
|
||||
),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
if (ttsState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
enabled = canSkipNextChunk,
|
||||
onClick = ttsController::skipToNextChunk,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SkipNext,
|
||||
contentDescription = stringResource(R.string.content_desc_tts_next_chunk),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -153,8 +153,11 @@ class TtsController(context: Context) : Player.Listener {
|
|||
bookTitle: String,
|
||||
chapterTitle: String?,
|
||||
coverImageUri: String?,
|
||||
bookId: String? = null,
|
||||
chapterIndex: Int? = null,
|
||||
totalChapters: Int? = null,
|
||||
pageIndex: Int? = null,
|
||||
startChunkIndex: Int = 0,
|
||||
continueSession: Boolean = false,
|
||||
ttsMode: TtsPlaybackManager.TtsMode,
|
||||
playbackSource: String = "READER",
|
||||
|
|
@ -184,8 +187,11 @@ class TtsController(context: Context) : Player.Listener {
|
|||
putString(KEY_BOOK_TITLE, bookTitle)
|
||||
putString(KEY_CHAPTER_TITLE, chapterTitle)
|
||||
putString(KEY_COVER_IMAGE_URI, coverImageUri)
|
||||
bookId?.let { putString(KEY_BOOK_ID, it) }
|
||||
chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
|
||||
totalChapters?.let { putInt(KEY_TOTAL_CHAPTERS, it) }
|
||||
pageIndex?.let { putInt(KEY_PAGE_INDEX, it) }
|
||||
putInt(KEY_START_CHUNK_INDEX, startChunkIndex)
|
||||
putBoolean(KEY_CONTINUE_SESSION, continueSession)
|
||||
putString(KEY_TTS_MODE, ttsMode.name)
|
||||
putString(KEY_PLAYBACK_SOURCE, playbackSource)
|
||||
|
|
@ -254,6 +260,16 @@ class TtsController(context: Context) : Player.Listener {
|
|||
mediaController?.sendCustomCommand(SLICE_CURRENT_AND_RELOAD_COMMAND, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
fun skipToPreviousChunk() {
|
||||
Timber.d("UI sending SKIP_TO_PREVIOUS_TTS_CHUNK command.")
|
||||
mediaController?.sendCustomCommand(SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
fun skipToNextChunk() {
|
||||
Timber.d("UI sending SKIP_TO_NEXT_TTS_CHUNK command.")
|
||||
mediaController?.sendCustomCommand(SKIP_TO_NEXT_TTS_CHUNK_COMMAND, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"Controller onEvents. playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}, events=$events"
|
||||
|
|
@ -266,6 +282,7 @@ class TtsController(context: Context) : Player.Listener {
|
|||
val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY
|
||||
val currentMediaItem = controller.currentMediaItem
|
||||
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
|
||||
val currentMediaBookTitle = currentMediaItem?.mediaMetadata?.title?.toString()
|
||||
val currentTextFromMediaItem = mediaItemExtras?.getString("ttsText")
|
||||
?: currentMediaItem?.mediaMetadata?.subtitle?.toString()
|
||||
val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING
|
||||
|
|
@ -278,12 +295,17 @@ class TtsController(context: Context) : Player.Listener {
|
|||
val serviceChapterTitle = customState.getString("chapterTitle")
|
||||
val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 }
|
||||
val serviceTotalChapters = customState.getInt("totalChapters", -1).takeIf { it > 0 }
|
||||
val serviceBookId = customState.getString("bookId") ?: mediaItemExtras?.getString("bookId")
|
||||
val servicePageIndex = customState.getInt("pageIndex", -1)
|
||||
.takeIf { it >= 0 }
|
||||
?: mediaItemExtras?.getInt("pageIndex", -1)?.takeIf { it >= 0 }
|
||||
val serviceCurrentChunkIndex = customState.getInt("currentChunkIndex", -1)
|
||||
val serviceTotalChunks = customState.getInt("totalChunks", 0)
|
||||
val serviceBookProgressPercent = customState.getInt("bookProgressPercent", -1).takeIf { it >= 0 }
|
||||
|
||||
val sourceCfi = mediaItemExtras?.getString("sourceCfi")
|
||||
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
|
||||
val sourceCfi = mediaItemExtras?.getString("sourceCfi") ?: customState.getString("sourceCfi")
|
||||
val startOffset = mediaItemExtras?.getInt("startOffset", -1)
|
||||
?: customState.getInt("startOffset", -1)
|
||||
val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
|
||||
val currentWordStartOffset = customState.getInt("currentWordStartOffset", -1)
|
||||
val serviceMode = customState.getString("ttsMode", _ttsState.value.ttsMode)
|
||||
|
|
@ -298,8 +320,13 @@ class TtsController(context: Context) : Player.Listener {
|
|||
if (isLoading) currentState.currentText else null
|
||||
},
|
||||
errorMessage = customState.getString("errorMessage"),
|
||||
bookId = if (isPlaybackActive || isLoading) {
|
||||
serviceBookId ?: currentState.bookId
|
||||
} else {
|
||||
serviceBookId
|
||||
},
|
||||
bookTitle = if (isPlaybackActive) {
|
||||
currentMediaItem?.mediaMetadata?.artist?.toString() ?: serviceBookTitle
|
||||
currentMediaBookTitle ?: serviceBookTitle
|
||||
} else {
|
||||
if (isLoading) currentState.bookTitle else serviceBookTitle
|
||||
},
|
||||
|
|
@ -318,6 +345,11 @@ class TtsController(context: Context) : Player.Listener {
|
|||
} else {
|
||||
serviceTotalChapters
|
||||
},
|
||||
pageIndex = if (isPlaybackActive || isLoading) {
|
||||
servicePageIndex ?: currentState.pageIndex
|
||||
} else {
|
||||
servicePageIndex
|
||||
},
|
||||
currentChunkIndex = serviceCurrentChunkIndex,
|
||||
totalChunks = serviceTotalChunks,
|
||||
bookProgressPercent = serviceBookProgressPercent,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package com.aryan.reader.tts
|
||||
|
||||
const val ACTION_OPEN_TTS_SESSION = "com.aryan.reader.tts.OPEN_SESSION"
|
||||
const val EXTRA_TTS_BOOK_ID = "com.aryan.reader.tts.extra.BOOK_ID"
|
||||
const val EXTRA_TTS_CHAPTER_INDEX = "com.aryan.reader.tts.extra.CHAPTER_INDEX"
|
||||
const val EXTRA_TTS_SOURCE_CFI = "com.aryan.reader.tts.extra.SOURCE_CFI"
|
||||
const val EXTRA_TTS_START_OFFSET = "com.aryan.reader.tts.extra.START_OFFSET"
|
||||
const val EXTRA_TTS_PAGE_INDEX = "com.aryan.reader.tts.extra.PAGE_INDEX"
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -30,10 +30,16 @@ import android.content.pm.ServiceInfo
|
|||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaNotification
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.aryan.reader.R
|
||||
|
|
@ -61,6 +67,7 @@ import kotlinx.coroutines.channels.Channel
|
|||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import com.google.common.collect.ImmutableList
|
||||
|
||||
data class WordTimingInfo(val word: String, val startTime: Double)
|
||||
|
||||
|
|
@ -242,6 +249,227 @@ private const val TTS_FOREGROUND_CHANNEL_ID = "tts_playback"
|
|||
// Keep this aligned with Media3's default notification ID so playback updates replace the fallback.
|
||||
private const val TTS_FOREGROUND_NOTIFICATION_ID = 1001
|
||||
private const val TTS_FOREGROUND_IDLE_GRACE_MS = 15_000L
|
||||
private const val ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK = "com.aryan.reader.tts.NOTIFICATION_PREVIOUS_CHUNK"
|
||||
private const val ACTION_TTS_NOTIFICATION_NEXT_CHUNK = "com.aryan.reader.tts.NOTIFICATION_NEXT_CHUNK"
|
||||
private const val TTS_NOTIFICATION_PREVIOUS_REQUEST_CODE = 4208
|
||||
private const val TTS_NOTIFICATION_NEXT_REQUEST_CODE = 4209
|
||||
|
||||
@UnstableApi
|
||||
private class TtsMediaNotificationProvider(
|
||||
context: android.content.Context
|
||||
) : DefaultMediaNotificationProvider(
|
||||
context,
|
||||
{ _ -> TTS_FOREGROUND_NOTIFICATION_ID },
|
||||
TTS_FOREGROUND_CHANNEL_ID,
|
||||
R.string.tts_notification_channel_name
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
override fun addNotificationActions(
|
||||
mediaSession: MediaSession,
|
||||
mediaButtons: ImmutableList<CommandButton>,
|
||||
builder: NotificationCompat.Builder,
|
||||
actionFactory: MediaNotification.ActionFactory
|
||||
): IntArray {
|
||||
return super.addNotificationActions(
|
||||
mediaSession,
|
||||
mediaButtons,
|
||||
builder,
|
||||
TtsNotificationActionFactory(appContext, actionFactory)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
private class TtsNotificationActionFactory(
|
||||
private val context: android.content.Context,
|
||||
private val delegate: MediaNotification.ActionFactory
|
||||
) : MediaNotification.ActionFactory {
|
||||
override fun createMediaAction(
|
||||
mediaSession: MediaSession,
|
||||
icon: IconCompat,
|
||||
title: CharSequence,
|
||||
command: Int
|
||||
): NotificationCompat.Action {
|
||||
return when (command) {
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> createChunkSkipAction(
|
||||
iconResId = R.drawable.skip_previous,
|
||||
title = title,
|
||||
action = ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK,
|
||||
requestCode = TTS_NOTIFICATION_PREVIOUS_REQUEST_CODE
|
||||
)
|
||||
Player.COMMAND_SEEK_TO_NEXT,
|
||||
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> createChunkSkipAction(
|
||||
iconResId = R.drawable.skip_next,
|
||||
title = title,
|
||||
action = ACTION_TTS_NOTIFICATION_NEXT_CHUNK,
|
||||
requestCode = TTS_NOTIFICATION_NEXT_REQUEST_CODE
|
||||
)
|
||||
else -> delegate.createMediaAction(mediaSession, icon, title, command)
|
||||
}
|
||||
}
|
||||
|
||||
override fun createCustomAction(
|
||||
mediaSession: MediaSession,
|
||||
icon: IconCompat,
|
||||
title: CharSequence,
|
||||
customAction: String,
|
||||
extras: android.os.Bundle
|
||||
): NotificationCompat.Action {
|
||||
return delegate.createCustomAction(mediaSession, icon, title, customAction, extras)
|
||||
}
|
||||
|
||||
override fun createCustomActionFromCustomCommandButton(
|
||||
mediaSession: MediaSession,
|
||||
customCommandButton: CommandButton
|
||||
): NotificationCompat.Action {
|
||||
return delegate.createCustomActionFromCustomCommandButton(mediaSession, customCommandButton)
|
||||
}
|
||||
|
||||
override fun createMediaActionPendingIntent(mediaSession: MediaSession, command: Long): PendingIntent {
|
||||
return delegate.createMediaActionPendingIntent(mediaSession, command)
|
||||
}
|
||||
|
||||
override fun createNotificationDismissalIntent(mediaSession: MediaSession): PendingIntent {
|
||||
return delegate.createNotificationDismissalIntent(mediaSession)
|
||||
}
|
||||
|
||||
private fun createChunkSkipAction(
|
||||
iconResId: Int,
|
||||
title: CharSequence,
|
||||
action: String,
|
||||
requestCode: Int
|
||||
): NotificationCompat.Action {
|
||||
val intent = Intent(context, TtsService::class.java).apply {
|
||||
this.action = action
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getService(
|
||||
context,
|
||||
requestCode,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
IconCompat.createWithResource(context, iconResId),
|
||||
title,
|
||||
pendingIntent
|
||||
)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
private class TtsSessionPlayer(
|
||||
player: Player,
|
||||
private val canSkipToPreviousChunk: () -> Boolean,
|
||||
private val canSkipToNextChunk: () -> Boolean,
|
||||
private val skipToPreviousChunk: () -> Unit,
|
||||
private val skipToNextChunk: () -> Unit,
|
||||
private val isCurrentChunkStreaming: () -> Boolean,
|
||||
private val currentChunkDurationForNotification: (Long) -> Long
|
||||
) : ForwardingPlayer(player) {
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
val builder = super.getAvailableCommands().buildUpon()
|
||||
if (canSkipToPreviousChunk()) {
|
||||
builder
|
||||
.add(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
} else {
|
||||
builder
|
||||
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
}
|
||||
if (canSkipToNextChunk()) {
|
||||
builder
|
||||
.add(Player.COMMAND_SEEK_TO_NEXT)
|
||||
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
} else {
|
||||
builder
|
||||
.remove(Player.COMMAND_SEEK_TO_NEXT)
|
||||
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun isCommandAvailable(command: Int): Boolean {
|
||||
return getAvailableCommands().contains(command)
|
||||
}
|
||||
|
||||
override fun hasPreviousMediaItem(): Boolean {
|
||||
return canSkipToPreviousChunk() || super.hasPreviousMediaItem()
|
||||
}
|
||||
|
||||
override fun hasNextMediaItem(): Boolean {
|
||||
return canSkipToNextChunk() || super.hasNextMediaItem()
|
||||
}
|
||||
|
||||
override fun seekToPrevious() {
|
||||
skipToPreviousChunk()
|
||||
}
|
||||
|
||||
override fun seekToPreviousMediaItem() {
|
||||
skipToPreviousChunk()
|
||||
}
|
||||
|
||||
override fun seekToNext() {
|
||||
skipToNextChunk()
|
||||
}
|
||||
|
||||
override fun seekToNextMediaItem() {
|
||||
skipToNextChunk()
|
||||
}
|
||||
|
||||
override fun getDuration(): Long {
|
||||
return notificationDurationMs().takeIf { it != C.TIME_UNSET } ?: super.getDuration()
|
||||
}
|
||||
|
||||
override fun getContentDuration(): Long {
|
||||
return getDuration()
|
||||
}
|
||||
|
||||
override fun getBufferedPosition(): Long {
|
||||
return adjustedStreamingBufferedPosition(super.getBufferedPosition())
|
||||
}
|
||||
|
||||
override fun getContentBufferedPosition(): Long {
|
||||
return getBufferedPosition()
|
||||
}
|
||||
|
||||
override fun getBufferedPercentage(): Int {
|
||||
val duration = notificationDurationMs()
|
||||
if (!isCurrentChunkStreaming() || duration == C.TIME_UNSET || duration <= 0L) {
|
||||
return super.getBufferedPercentage()
|
||||
}
|
||||
val bufferedPosition = getBufferedPosition().coerceIn(0L, duration)
|
||||
return ((bufferedPosition * 100L) / duration).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
override fun isCurrentMediaItemDynamic(): Boolean {
|
||||
return if (isCurrentChunkStreaming()) false else super.isCurrentMediaItemDynamic()
|
||||
}
|
||||
|
||||
private fun notificationDurationMs(): Long {
|
||||
val currentPositionMs = super.getCurrentPosition().coerceAtLeast(0L)
|
||||
return currentChunkDurationForNotification(currentPositionMs)
|
||||
}
|
||||
|
||||
private fun adjustedStreamingBufferedPosition(delegatePositionMs: Long): Long {
|
||||
val duration = notificationDurationMs()
|
||||
if (!isCurrentChunkStreaming() || duration == C.TIME_UNSET || duration <= 0L) {
|
||||
return delegatePositionMs
|
||||
}
|
||||
val currentPositionMs = super.getCurrentPosition().coerceAtLeast(0L)
|
||||
val bufferedPositionMs = if (delegatePositionMs == C.TIME_UNSET || delegatePositionMs < currentPositionMs) {
|
||||
currentPositionMs
|
||||
} else {
|
||||
delegatePositionMs
|
||||
}
|
||||
return bufferedPositionMs.coerceIn(0L, duration)
|
||||
}
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
class TtsService : MediaSessionService() {
|
||||
|
|
@ -258,6 +486,29 @@ class TtsService : MediaSessionService() {
|
|||
private var foregroundChapterTitle: String? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK -> {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Notification previous chunk action received.")
|
||||
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i(
|
||||
"serviceAction=notification-previous hasPlaybackManager=${::playbackManager.isInitialized} playerInitialized=${::player.isInitialized}"
|
||||
)
|
||||
if (::playbackManager.isInitialized) {
|
||||
playbackManager.skipToPreviousChunkFromTransport()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
ACTION_TTS_NOTIFICATION_NEXT_CHUNK -> {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Notification next chunk action received.")
|
||||
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i(
|
||||
"serviceAction=notification-next hasPlaybackManager=${::playbackManager.isInitialized} playerInitialized=${::player.isInitialized}"
|
||||
)
|
||||
if (::playbackManager.isInitialized) {
|
||||
playbackManager.skipToNextChunkFromTransport()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
val result = super.onStartCommand(intent, flags, startId)
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"onStartCommand. action=${intent?.action}, startId=$startId, result=$result"
|
||||
|
|
@ -747,6 +998,7 @@ class TtsService : MediaSessionService() {
|
|||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Timber.d("TtsService created.")
|
||||
setMediaNotificationProvider(TtsMediaNotificationProvider(this))
|
||||
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
|
|
@ -818,7 +1070,17 @@ class TtsService : MediaSessionService() {
|
|||
onPlaybackSessionStopped = ::onPlaybackSessionStopped
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
val sessionPlayer = TtsSessionPlayer(
|
||||
player = player,
|
||||
canSkipToPreviousChunk = playbackManager::canSkipToPreviousChunk,
|
||||
canSkipToNextChunk = playbackManager::canSkipToNextChunk,
|
||||
skipToPreviousChunk = playbackManager::skipToPreviousChunkFromTransport,
|
||||
skipToNextChunk = playbackManager::skipToNextChunkFromTransport,
|
||||
isCurrentChunkStreaming = playbackManager::isCurrentChunkStreaming,
|
||||
currentChunkDurationForNotification = playbackManager::currentChunkDurationForNotification
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, sessionPlayer)
|
||||
.setCallback(playbackManager)
|
||||
.build()
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,10 @@ import androidx.compose.material3.dynamicLightColorScheme
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import com.materialkolor.PaletteStyle
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.aryan.reader.shared.ui.withAppFontFamily
|
||||
import com.materialkolor.dynamicColorScheme
|
||||
|
||||
private val lightScheme = lightColorScheme(
|
||||
|
|
@ -116,6 +118,7 @@ fun AppTheme(
|
|||
seedColor: Color? = null,
|
||||
contrastLevel: Double = 0.0,
|
||||
textDimFactor: Float = 1.0f,
|
||||
appFontFamily: FontFamily? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
|
@ -174,7 +177,7 @@ fun AppTheme(
|
|||
|
||||
MaterialTheme(
|
||||
colorScheme = finalColorScheme,
|
||||
typography = AppTypography,
|
||||
typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
10
app/src/main/res/drawable-nodpi/contrast.xml
Normal file
10
app/src/main/res/drawable-nodpi/contrast.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="M324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5ZM520,797Q639,782 719.5,692.5Q800,603 800,480Q800,357 719.5,267.5Q639,178 520,163L520,797Z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable-nodpi/skip_next.xml
Normal file
10
app/src/main/res/drawable-nodpi/skip_next.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="M660,720L660,240L740,240L740,720L660,720ZM220,720L220,240L580,480L220,720ZM300,480L300,480L300,480ZM300,570L436,480L300,390L300,570Z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable-nodpi/skip_previous.xml
Normal file
10
app/src/main/res/drawable-nodpi/skip_previous.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="M220,720L220,240L300,240L300,720L220,720ZM740,720L380,480L740,240L740,720ZM660,480L660,480L660,480ZM660,570L660,390L524,480L660,570Z"/>
|
||||
</vector>
|
||||
|
|
@ -807,7 +807,7 @@
|
|||
<string name="ai_generate_story_recap">Сгенерировать пересказ сюжета</string>
|
||||
<string name="ai_story_recap">Пересказ сюжета</string>
|
||||
<string name="ai_cache_hit_free">Загружено из кэша (Бесплатно)</string>
|
||||
<string name="ai_generated_free_remaining">Сгенерировано • Бесплатно (1%$d/10)</string>
|
||||
<string name="ai_generated_free_remaining">Сгенерировано • Бесплатно (%1$d/10)</string>
|
||||
<string name="ai_generated_cost">Сгенерировано • Стоимость: %1$s кредитов</string>
|
||||
<string name="ai_generating_cost_calculating">Расчет стоимости...</string>
|
||||
<string name="ai_output_title">Результат ИИ</string>
|
||||
|
|
@ -816,7 +816,7 @@
|
|||
<string name="credits_tab">Кредиты</string>
|
||||
<string name="credits_title">Баланс кредитов</string>
|
||||
<string name="credits_available">Доступно кредитов</string>
|
||||
<string name="credits_count">%1$ кредитов</string>
|
||||
<string name="credits_count">%1$d кредитов</string>
|
||||
<string name="credits_estimated_cost_breakdown">Примерный расчет стоимости</string>
|
||||
<string name="credits_cloud_tts_title">Облачный синтез речи (TTS)</string>
|
||||
<string name="credits_cloud_tts_desc">Стоимость: ~3-4 кредита за минуту сгенерированного аудио.\n\nЧтобы включить : Читатель экрана> Дополнительно > Настройки голоса TTS.</string>
|
||||
|
|
|
|||
|
|
@ -59,18 +59,144 @@
|
|||
<item quantity="other">%1$d books removed from library.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Android bulk import progress banner. %1$d = number of selected books being imported. -->
|
||||
<plurals name="banner_importing_books_count">
|
||||
<item quantity="one">Importing %1$d book… It will appear in your Library shortly.</item>
|
||||
<item quantity="other">Importing %1$d books… They will appear in your Library shortly.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Android bulk import result banner. %1$d = number of books imported into the Library tab. -->
|
||||
<plurals name="banner_books_imported_library_tab">
|
||||
<item quantity="one">Imported %1$d book. You can find it in the Library tab.</item>
|
||||
<item quantity="other">Imported %1$d books. You can find them in the Library tab.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Shared library mutation banner. %1$d = number of selected books added to a shelf. -->
|
||||
<plurals name="banner_books_added_to_shelf">
|
||||
<item quantity="one">%1$d book added to shelf.</item>
|
||||
<item quantity="other">%1$d books added to shelf.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Shared library mutation banner. %1$d = number of selected books; %2$s = tag name. -->
|
||||
<plurals name="banner_books_tagged_with_tag">
|
||||
<item quantity="one">%1$d book tagged with "%2$s".</item>
|
||||
<item quantity="other">%1$d books tagged with "%2$s".</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Shared library mutation banner. %1$s = folder name; %2$d = number of books removed with the folder. -->
|
||||
<plurals name="banner_folder_removed_with_book_count">
|
||||
<item quantity="one">Removed folder "%1$s" and %2$d book from the app.</item>
|
||||
<item quantity="other">Removed folder "%1$s" and %2$d books from the app.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Word count for folders shown in shelf subtitles. %1$d = number of folders. -->
|
||||
<plurals name="folder_count">
|
||||
<item quantity="one">%1$d folder</item>
|
||||
<item quantity="other">%1$d folders</item>
|
||||
</plurals>
|
||||
|
||||
<!-- File count label. %1$d = number of files. Use a full count phrase, not a reusable standalone noun. -->
|
||||
<plurals name="file_count">
|
||||
<item quantity="one">%1$d file</item>
|
||||
<item quantity="other">%1$d files</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop drag-and-drop overlay title. %1$d = number of importable files currently dragged over the app. -->
|
||||
<plurals name="desktop_drop_import_file_count">
|
||||
<item quantity="one">Drop to import %1$d file</item>
|
||||
<item quantity="other">Drop to import %1$d files</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop drag-and-drop overlay detail. %1$d = number of unsupported dragged files that will not be imported. -->
|
||||
<plurals name="desktop_unsupported_import_file_count">
|
||||
<item quantity="one">%1$d unsupported file will be skipped.</item>
|
||||
<item quantity="other">%1$d unsupported files will be skipped.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop import progress banner. %1$d = number of files being imported. -->
|
||||
<plurals name="desktop_importing_file_count">
|
||||
<item quantity="one">Importing %1$d file…</item>
|
||||
<item quantity="other">Importing %1$d files…</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop import result banner sentence. %1$d = number of files successfully imported. -->
|
||||
<plurals name="desktop_imported_file_count">
|
||||
<item quantity="one">Imported %1$d file.</item>
|
||||
<item quantity="other">Imported %1$d files.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Legacy desktop import model message. %1$d = number of files imported before reader support is available. -->
|
||||
<plurals name="desktop_imported_file_count_reader_support_later">
|
||||
<item quantity="one">Imported %1$d file. Reader support comes later.</item>
|
||||
<item quantity="other">Imported %1$d files. Reader support comes later.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop import result banner sentence. %1$d = number of files that could not be imported. -->
|
||||
<plurals name="desktop_import_failed_file_count">
|
||||
<item quantity="one">Could not import %1$d file.</item>
|
||||
<item quantity="other">Could not import %1$d files.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop import result banner sentence. %1$d = number of files skipped during import. -->
|
||||
<plurals name="desktop_skipped_file_count">
|
||||
<item quantity="one">Skipped %1$d file.</item>
|
||||
<item quantity="other">Skipped %1$d files.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop remove-folder confirmation body. %1$s = folder name; %2$d = number of books from that folder. -->
|
||||
<plurals name="desktop_remove_folder_desc_with_book_count">
|
||||
<item quantity="one">Remove "%1$s" and its %2$d book from the app? Files on disk will not be deleted.</item>
|
||||
<item quantity="other">Remove "%1$s" and its %2$d books from the app? Files on disk will not be deleted.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop folder-sync result banner. %1$d = number of linked folders whose sync failed. -->
|
||||
<plurals name="desktop_folder_sync_failed_folder_count">
|
||||
<item quantity="one">Folder sync failed for %1$d folder.</item>
|
||||
<item quantity="other">Folder sync failed for %1$d folders.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop folder-sync result banner. %1$d = number of linked folders skipped after a partial sync. -->
|
||||
<plurals name="desktop_folder_sync_skipped_folder_count">
|
||||
<item quantity="one">Folder sync finished with %1$d folder skipped.</item>
|
||||
<item quantity="other">Folder sync finished with %1$d folders skipped.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop OPDS catalog removal banner. %1$d = number of streamed OPDS books removed with the catalog. -->
|
||||
<plurals name="desktop_opds_removed_stream_book_count">
|
||||
<item quantity="one">Removed %1$d streamed OPDS book from that catalog.</item>
|
||||
<item quantity="other">Removed %1$d streamed OPDS books from that catalog.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Word count for tags shown in filter chips. %1$d = number of tags. -->
|
||||
<plurals name="tag_count">
|
||||
<item quantity="one">%1$d tag</item>
|
||||
<item quantity="other">%1$d tags</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop library tab label with count. %1$d = number of visible books in the tab. -->
|
||||
<plurals name="desktop_library_tab_books_count">
|
||||
<item quantity="one">All Books %1$d</item>
|
||||
<item quantity="other">All Books %1$d</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop library tab label with count. %1$d = number of manual/series shelf groups. -->
|
||||
<plurals name="desktop_library_tab_shelves_count">
|
||||
<item quantity="one">Shelves %1$d</item>
|
||||
<item quantity="other">Shelves %1$d</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop library tab label with count. %1$d = number of tag groups. -->
|
||||
<plurals name="desktop_library_tab_tags_count">
|
||||
<item quantity="one">Tags %1$d</item>
|
||||
<item quantity="other">Tags %1$d</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Desktop library tab label with count. %1$d = number of source folder groups. -->
|
||||
<plurals name="desktop_library_tab_folders_count">
|
||||
<item quantity="one">Folders %1$d</item>
|
||||
<item quantity="other">Folders %1$d</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Parenthetical cache chunk count in TTS cache entries. %1$d = number of cached audio chunks. -->
|
||||
<plurals name="tts_cache_chunk_count_parenthetical">
|
||||
<item quantity="one">(%1$d chunk)</item>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
<!-- Tab label in the multi-tab PDF reader — means the slot is available (unused), NOT "free of cost". -->
|
||||
<string name="tab_free">Free</string>
|
||||
<string name="active_tabs">Active Tabs</string>
|
||||
<string name="pdf_tabs_show_top_app_bar_tabs">Show tabs in top app bar</string>
|
||||
<string name="close_tab">Close Tab</string>
|
||||
<string name="close_all_tabs">Close All Tabs</string>
|
||||
<string name="dialog_close_all_tabs">Close All Tabs?</string>
|
||||
|
|
@ -733,6 +734,10 @@
|
|||
<string name="content_desc_pause_tts">Pause TTS</string>
|
||||
<!-- TTS = Text-to-Speech. Accessibility label. -->
|
||||
<string name="content_desc_resume_tts">Resume TTS</string>
|
||||
<!-- TTS = Text-to-Speech. Accessibility label. -->
|
||||
<string name="content_desc_tts_previous_chunk">Previous TTS chunk</string>
|
||||
<!-- TTS = Text-to-Speech. Accessibility label. -->
|
||||
<string name="content_desc_tts_next_chunk">Next TTS chunk</string>
|
||||
<string name="content_desc_exit_slider">Exit slider navigation</string>
|
||||
<string name="content_desc_start_page_thumbnail">Start page thumbnail</string>
|
||||
<string name="content_desc_expand">Expand</string>
|
||||
|
|
@ -769,10 +774,13 @@
|
|||
<string name="tab_bookmarks">Bookmarks</string>
|
||||
<string name="tab_highlights">Highlights</string>
|
||||
<string name="tab_pages">Pages</string>
|
||||
<string name="tab_images">Images</string>
|
||||
<string name="action_expand_all">Expand All</string>
|
||||
<string name="action_collapse_all">Collapse All</string>
|
||||
<string name="action_locate">Locate</string>
|
||||
<string name="no_bookmarks_yet">You haven\'t added any bookmarks yet.</string>
|
||||
<string name="no_images_found">No images found.</string>
|
||||
<string name="content_desc_download_image">Download image</string>
|
||||
<string name="content_desc_more_options_bookmark">More options for bookmark</string>
|
||||
<string name="dialog_rename_bookmark">Rename Bookmark</string>
|
||||
<string name="label_new_name">New Name</string>
|
||||
|
|
@ -786,6 +794,8 @@
|
|||
<string name="dialog_delete_highlight_desc">Are you sure you want to permanently delete this highlight?</string>
|
||||
|
||||
<!-- EpubReaderScreen.kt -->
|
||||
<string name="saved_image_message">Saved %1$s</string>
|
||||
<string name="error_save_image">Could not save image.</string>
|
||||
<!-- PDF = file format name — do not translate. -->
|
||||
<string name="banner_original_pdf_not_found">Original PDF not found.</string>
|
||||
<!-- Error state shown in the reader. %1$s = the file path that could not be found. -->
|
||||
|
|
@ -833,8 +843,13 @@
|
|||
<string name="no_imported_fonts_yet">No imported fonts yet.</string>
|
||||
<string name="visual_options_title">Visual Options</string>
|
||||
<string name="visual_options_page_layout">Page layout</string>
|
||||
<string name="visual_options_pdf_page_spread">PDF page spread</string>
|
||||
<string name="visual_options_pdf_spread_single">Single page</string>
|
||||
<string name="visual_options_pdf_spread_two">Two pages</string>
|
||||
<string name="visual_options_pdf_first_page_alone">First page alone</string>
|
||||
<string name="visual_options_pdf_first_page_alone_desc">Starts facing-page spreads after the cover page.</string>
|
||||
<string name="visual_options_remove_page_gap">Remove gap between pages</string>
|
||||
<string name="visual_options_remove_page_gap_desc">Applies to vertical reading mode.</string>
|
||||
<string name="visual_options_remove_page_gap_desc">Applies to vertical reading and two-page spreads.</string>
|
||||
<string name="visual_options_hide_page_number_overlay">Hide page number overlay</string>
|
||||
<string name="visual_options_hide_page_number_overlay_desc">Removes the small page count label from each page.</string>
|
||||
<!-- "Status Bar" and "Navigation Bar" are standard Android UI terms — keep consistent with your OS locale if applicable. -->
|
||||
|
|
@ -850,6 +865,12 @@
|
|||
<string name="visual_options_seamless_chapter_desc">Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation.</string>
|
||||
<string name="visual_options_edge_padding">Remove Edge Padding</string>
|
||||
<string name="visual_options_edge_padding_desc">Removes the horizontal gap on the left and right edges.</string>
|
||||
<string name="reader_brightness_title">Brightness</string>
|
||||
<string name="reader_brightness_system">Use system brightness</string>
|
||||
<string name="reader_brightness_system_desc">Follows the device brightness setting.</string>
|
||||
<string name="reader_brightness_custom">Custom brightness</string>
|
||||
<string name="reader_brightness_custom_desc">Applies while a reader screen is open.</string>
|
||||
<string name="reader_brightness_percent">%1$d%%</string>
|
||||
|
||||
<!-- ExternalDictionaryHelper.kt -->
|
||||
<!-- Label for the "Search" app category in the dictionary settings. -->
|
||||
|
|
@ -877,6 +898,20 @@
|
|||
<string name="about_licenses_desc">Open source libraries used.</string>
|
||||
<!-- %1$d = number of books being imported. -->
|
||||
<string name="banner_importing_multiple">Importing %1$d books… They will appear in your Library shortly.</string>
|
||||
<!-- Shared library mutation banner. %1$s = shelf name. -->
|
||||
<string name="banner_shelf_created">Created shelf "%1$s".</string>
|
||||
<!-- Shared library mutation banner. %1$s = smart shelf name. -->
|
||||
<string name="banner_smart_shelf_created">Created smart shelf "%1$s".</string>
|
||||
<!-- Shared library mutation banner. %1$s = new shelf name. -->
|
||||
<string name="banner_shelf_renamed">Renamed shelf to "%1$s".</string>
|
||||
<!-- Shared library mutation banner. %1$s = shelf name. -->
|
||||
<string name="banner_shelf_deleted">Deleted shelf "%1$s".</string>
|
||||
<!-- Shared library mutation banner. %1$s = book title. -->
|
||||
<string name="banner_book_updated">Updated "%1$s".</string>
|
||||
<!-- Shared import result banner shown when selected files already exist in the library. -->
|
||||
<string name="banner_duplicate_files_already_in_library">Those files are already in the library.</string>
|
||||
<!-- Folder shelf subtitle that joins already-localized count phrases. %1$s = folder count phrase; %2$s = book count phrase. -->
|
||||
<string name="folder_subtitle_folder_book_counts">%1$s - %2$s</string>
|
||||
|
||||
<!-- General / Dialogs -->
|
||||
<string name="dialog_external_link_title">External Link</string>
|
||||
|
|
@ -887,13 +922,21 @@
|
|||
|
||||
<!-- Annotations & Notes -->
|
||||
<string name="action_save_note">Save Note</string>
|
||||
<string name="action_save_annotation">Save</string>
|
||||
<string name="action_save_comment">Save Comment</string>
|
||||
<string name="action_add_comment">Add Comment</string>
|
||||
<string name="action_reply">Reply</string>
|
||||
<string name="placeholder_add_note">Add a note…</string>
|
||||
<string name="placeholder_add_comment">Add a comment…</string>
|
||||
<!-- Short label for the dictionary action in a text-selection popup. -->
|
||||
<string name="label_dict">Dict</string>
|
||||
<!-- Short label for the speak/TTS action in a text-selection popup. -->
|
||||
<string name="label_speak">Speak</string>
|
||||
<!-- Short label for the note action in a text-selection popup. -->
|
||||
<string name="label_note">Note</string>
|
||||
<string name="label_comments">Comments</string>
|
||||
<string name="label_editing_comment">Editing comment</string>
|
||||
<string name="label_replying_to">Replying to %1$s</string>
|
||||
<!-- Short label for the edit action in a text-selection popup. -->
|
||||
<string name="label_edit">Edit</string>
|
||||
|
||||
|
|
@ -1097,6 +1140,8 @@
|
|||
<string name="options_enable_multi_tab_reading">Enable Multi-Tab Reading</string>
|
||||
<!-- Home overflow menu item: uses stricter MIME filters when choosing local files. -->
|
||||
<string name="options_use_strict_file_filter">Use Strict File Filter</string>
|
||||
<!-- Home overflow menu item: prefers the PDF filename over embedded document metadata titles. -->
|
||||
<string name="options_use_pdf_filename_display_name">Use PDF Filenames</string>
|
||||
<string name="options_language">Language</string>
|
||||
<!-- Debug-only menu item that runs local panel detection diagnostics. -->
|
||||
<string name="options_test_panel_ml_detection">Test Panel ML Detection</string>
|
||||
|
|
@ -1175,8 +1220,8 @@
|
|||
<string name="sort_recent">Recent</string>
|
||||
<string name="sort_title_az">Title A-Z</string>
|
||||
<string name="sort_author_az">Author A-Z</string>
|
||||
<string name="sort_percent_asc">Percent complete 0-100</string>
|
||||
<string name="sort_percent_desc">Percent complete 100-0</string>
|
||||
<string name="sort_percent_asc">Percent complete 0–100</string>
|
||||
<string name="sort_percent_desc">Percent complete 100–0</string>
|
||||
<string name="sort_size_smallest">Size (Smallest)</string>
|
||||
<string name="sort_size_biggest">Size (Biggest)</string>
|
||||
<string name="read_status_all">All</string>
|
||||
|
|
@ -1243,9 +1288,9 @@
|
|||
<string name="credits_count">%1$d Credits</string>
|
||||
<string name="credits_estimated_cost_breakdown">Estimated Cost Breakdown</string>
|
||||
<string name="credits_cloud_tts_title">Cloud TTS</string>
|
||||
<string name="credits_cloud_tts_desc">Cost: ~3-4 credits per minute of audio generated.\nTo enable: Reader Screen > More > TTS Voice Settings.</string>
|
||||
<string name="credits_cloud_tts_desc">Cost: ~3–4 credits per minute of audio generated.\nTo enable: Reader Screen > More > TTS Voice Settings.</string>
|
||||
<string name="credits_ai_summaries_title">AI Summaries & Recap</string>
|
||||
<string name="credits_ai_summaries_desc">Cost: ~1-4 credits per request based on chapter length.\nPro Users get 10 free summaries daily.</string>
|
||||
<string name="credits_ai_summaries_desc">Cost: ~1–4 credits per request based on chapter length.\nPro Users get 10 free summaries daily.</string>
|
||||
<string name="legal_by_purchasing">By purchasing,</string>
|
||||
<string name="dialog_out_of_credits_title">Out of Credits</string>
|
||||
<string name="dialog_out_of_credits_desc">You don\'t have enough credits. Get Episteme Pro for 10 free Summaries per day, or add more credits to use Summaries, Cloud TTS and Story Recap.</string>
|
||||
|
|
@ -1511,6 +1556,7 @@
|
|||
<string name="content_desc_drag_to_reorder">Drag to reorder</string>
|
||||
<string name="tool_external_apps">External Apps</string>
|
||||
<string name="tool_navigation_slider">Navigation Slider</string>
|
||||
<string name="tool_brightness">Brightness</string>
|
||||
<string name="tool_sidebar">Sidebar</string>
|
||||
<string name="tool_highlight_selectable_text">Highlight selectable text</string>
|
||||
<string name="tool_edit_mode">Edit Mode</string>
|
||||
|
|
@ -1561,4 +1607,403 @@
|
|||
<string name="language_dutch">Nederlands (Dutch)</string>
|
||||
<string name="language_ukrainian">Українська (Ukrainian)</string>
|
||||
<string name="language_indonesian">Bahasa Indonesia (Indonesian)</string>
|
||||
<!-- Desktop localization. Desktop reuses this Android string file as its source of truth. -->
|
||||
<string name="desktop_about">About</string>
|
||||
<string name="desktop_about_subtitle">Desktop reader</string>
|
||||
<string name="desktop_access">Desktop access</string>
|
||||
<string name="desktop_account">Account</string>
|
||||
<string name="desktop_ai_hub">AI hub</string>
|
||||
<string name="desktop_ai_settings_summaries_desc">Used for EPUB summaries and PDF page summaries.</string>
|
||||
<string name="desktop_author_text">Author text</string>
|
||||
<!-- %1$s = voice/cache label. -->
|
||||
<string name="desktop_cache_format">Cache: %1$s</string>
|
||||
<string name="desktop_cached">Cached</string>
|
||||
<string name="desktop_cached_summary">Cached summary</string>
|
||||
<string name="desktop_choose_cloud_tts_voice">Choose the Gemini voice used for cloud read aloud.</string>
|
||||
<string name="desktop_clear_book_cache_desc">Delete generated desktop book and EPUB pagination cache files? They will be recreated the next time books are opened.</string>
|
||||
<string name="desktop_clear_voice_cache">Clear voice cache</string>
|
||||
<string name="desktop_close_tools">Close tools</string>
|
||||
<string name="desktop_cloud_tts_needs_gemini">Cloud TTS needs Gemini</string>
|
||||
<string name="desktop_cloud_tts_needs_signed_in_credits">Cloud TTS needs signed-in credits</string>
|
||||
<string name="desktop_cloud_tts_ready">Cloud TTS ready</string>
|
||||
<string name="desktop_cloud_tts_settings">Cloud TTS settings</string>
|
||||
<string name="desktop_cloud_tts_unavailable">Cloud TTS unavailable</string>
|
||||
<string name="desktop_cloud_tts_voice">Cloud TTS voice</string>
|
||||
<string name="desktop_contains">Contains</string>
|
||||
<string name="desktop_cost_calculating">Cost calculating</string>
|
||||
<string name="desktop_create_recap_current_position">Create a recap up to your current position.</string>
|
||||
<string name="desktop_create_smart_shelf">Create smart shelf</string>
|
||||
<!-- %1$d = integer credit count. -->
|
||||
<string name="desktop_credits_available_format">%1$d credits available</string>
|
||||
<!-- %1$s = decimal credit cost. -->
|
||||
<string name="desktop_credits_decimal_format">%1$s credits</string>
|
||||
<string name="desktop_custom_fonts_desc">Imported fonts for the reader</string>
|
||||
<string name="desktop_delete_font">Delete font</string>
|
||||
<!-- %1$s = font display name. -->
|
||||
<string name="desktop_delete_font_desc">Delete %1$s? Books using it will fall back to the default font.</string>
|
||||
<!-- %1$s = shelf name. -->
|
||||
<string name="desktop_delete_shelf_desc">Delete \"%1$s\"? Books stay in your library.</string>
|
||||
<string name="desktop_delete_summary">Delete summary</string>
|
||||
<string name="desktop_disabled">Disabled</string>
|
||||
<string name="desktop_drop_files_to_import">Drop files to import</string>
|
||||
<string name="desktop_drop_supported_files_to_import">Drop supported files to import</string>
|
||||
<string name="desktop_email_support_desc">Contact us directly by email for anything else.</string>
|
||||
<string name="desktop_equals">Equals</string>
|
||||
<string name="desktop_extras">Extras</string>
|
||||
<string name="desktop_feedback">Feedback</string>
|
||||
<string name="desktop_field">Field</string>
|
||||
<string name="desktop_folder_path">Folder path</string>
|
||||
<string name="desktop_from_here">From here</string>
|
||||
<string name="desktop_full_scan">Full scan</string>
|
||||
<!-- %1$d = number of free uses remaining. -->
|
||||
<string name="desktop_free_remaining_format">Free, %1$d left</string>
|
||||
<string name="desktop_generate_recap">Generate recap</string>
|
||||
<string name="desktop_generate_summary">Generate summary</string>
|
||||
<string name="desktop_get_in_touch_desc">Report bugs, request features, or contact support directly.</string>
|
||||
<string name="desktop_github_sponsors">GitHub Sponsors</string>
|
||||
<string name="desktop_github_sponsors_desc">Support development through GitHub Sponsors.</string>
|
||||
<string name="desktop_google_sign_in_not_configured">Google sign-in is not configured for this desktop build.</string>
|
||||
<string name="desktop_greater_than">Greater than</string>
|
||||
<string name="desktop_help_feedback_desc">Bug reports, feature requests, and support</string>
|
||||
<string name="desktop_hide">Hide</string>
|
||||
<string name="desktop_import_files">Import files</string>
|
||||
<string name="desktop_issues">Issues</string>
|
||||
<string name="desktop_issues_desc">Open the issue tracker for bugs and feature requests.</string>
|
||||
<string name="desktop_less_than">Less than</string>
|
||||
<string name="desktop_library_and_reader">Library and reader</string>
|
||||
<string name="desktop_match_any">Any</string>
|
||||
<string name="desktop_no_cached_summaries_book">No cached summaries for this book yet.</string>
|
||||
<string name="desktop_no_custom_fonts_desc">Import TTF, OTF, or WOFF2 files to use them in books.</string>
|
||||
<!-- %1$s = search query. -->
|
||||
<string name="desktop_no_fonts_matching">No fonts found matching \"%1$s\"</string>
|
||||
<string name="desktop_no_google_account_connected">No Google account is connected.</string>
|
||||
<string name="desktop_no_summary_cached_section">No summary cached for this section.</string>
|
||||
<string name="desktop_open_readers">Open readers</string>
|
||||
<!-- %1$s = book or section title. -->
|
||||
<string name="desktop_opening_title">Opening %1$s</string>
|
||||
<string name="desktop_opening_your_library">Opening your library</string>
|
||||
<string name="desktop_operator">Operator</string>
|
||||
<string name="desktop_page">Page</string>
|
||||
<string name="desktop_password_protected_pdf">Password protected PDF</string>
|
||||
<string name="desktop_patreon">Patreon</string>
|
||||
<string name="desktop_patreon_desc">Support the project on Patreon.</string>
|
||||
<string name="desktop_paused">Paused</string>
|
||||
<!-- %1$s = PDF title. -->
|
||||
<string name="desktop_pdf_password_required_desc">%1$s requires a password before it can be opened.</string>
|
||||
<string name="desktop_pdf_password_required_or_incorrect">Password is required or incorrect.</string>
|
||||
<!-- %1$s = PDF title. -->
|
||||
<string name="desktop_pdf_password_retry_desc">That password did not open %1$s. Enter the PDF password and try again.</string>
|
||||
<string name="desktop_percent">Percent</string>
|
||||
<string name="desktop_preparing_audio">Preparing audio</string>
|
||||
<string name="desktop_pro">Pro</string>
|
||||
<string name="desktop_pro_and_credits">Pro and credits</string>
|
||||
<string name="desktop_pro_not_unlocked_account">Pro is not unlocked for this account.</string>
|
||||
<string name="desktop_pro_purchase_android_desc">Pro and credits can only be purchased from the Android app. Desktop checks the same signed-in account and uses those credits for cloud TTS, summaries, recaps, and other paid AI features.</string>
|
||||
<string name="desktop_pro_sign_in_desc">Sign in to check your account status on desktop.</string>
|
||||
<string name="desktop_pro_unlocked_account">Pro is unlocked for this account.</string>
|
||||
<string name="desktop_progress">Progress</string>
|
||||
<string name="desktop_project">Project</string>
|
||||
<string name="desktop_reader">Reader</string>
|
||||
<string name="desktop_refresh">Refresh</string>
|
||||
<string name="desktop_release_add_library">Release to add to your library.</string>
|
||||
<string name="desktop_secure_key_storage_unavailable">Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted.</string>
|
||||
<string name="desktop_settings_hub">Settings hub</string>
|
||||
<string name="desktop_show_ai_in_reader_desc">Matches the Android hide toggle for smart dictionary, summaries, and recaps.</string>
|
||||
<string name="desktop_signed_in">Signed in</string>
|
||||
<string name="desktop_source_code">Source code</string>
|
||||
<string name="desktop_source_code_desc">Browse the project source on GitHub.</string>
|
||||
<string name="desktop_stop_reading_change_voices">Stop reading to change voices.</string>
|
||||
<string name="desktop_support">Support</string>
|
||||
<string name="desktop_support_episteme">Support Episteme</string>
|
||||
<string name="desktop_support_episteme_desc">Contributions help keep the reader improving across Android and desktop.</string>
|
||||
<string name="desktop_support_project_desc">Ways to support Episteme development</string>
|
||||
<string name="desktop_sync_folders">Sync folders</string>
|
||||
<string name="desktop_sync_metadata">Sync metadata</string>
|
||||
<string name="desktop_tag_name">Tag name</string>
|
||||
<string name="desktop_tag_selected_books">Tag selected books</string>
|
||||
<string name="desktop_title_text">Title text</string>
|
||||
<string name="desktop_tools">Tools</string>
|
||||
<string name="desktop_tools_desc">Import, sync, and app settings</string>
|
||||
<string name="desktop_type_example_pdf">Type, e.g. PDF</string>
|
||||
<string name="desktop_view">View</string>
|
||||
<string name="desktop_voice_cache">Voice cache</string>
|
||||
<string name="desktop_webview_preparing">Preparing embedded webview…</string>
|
||||
<!-- %1$d = progress percentage. %% is a literal percent sign. -->
|
||||
<string name="desktop_webview_preparing_progress">Preparing bundled embedded webview %1$d%%</string>
|
||||
<string name="desktop_webview_restart_required">Embedded webview installed. Restart Episteme to finish setup.</string>
|
||||
<!-- %1$s = startup error detail. -->
|
||||
<string name="desktop_webview_start_error">Embedded webview could not start: %1$s</string>
|
||||
<string name="desktop_working">Working…</string>
|
||||
<string name="desktop_workspace">Workspace</string>
|
||||
<!-- Desktop action/dialog title for adding selected books to a manual shelf. -->
|
||||
<string name="desktop_add_to_shelf">Add to shelf</string>
|
||||
<string name="desktop_create_shelf_first">Create a shelf first, then add selected books to it.</string>
|
||||
<string name="desktop_create_theme">Create theme</string>
|
||||
<!-- %1$s = comma-separated existing tag names. -->
|
||||
<string name="desktop_existing_tags_format">Existing: %1$s</string>
|
||||
<string name="desktop_external_link_desc">You clicked an external link.</string>
|
||||
<string name="desktop_edit_epub_metadata">Edit EPUB metadata</string>
|
||||
<string name="desktop_less">Less</string>
|
||||
<string name="desktop_more">…more</string>
|
||||
<string name="desktop_no_custom_themes_yet">No custom themes yet</string>
|
||||
<string name="desktop_rename_in_app">Rename in app</string>
|
||||
<string name="desktop_tags_comma_separated">Tags, comma separated</string>
|
||||
<string name="unknown">Unknown</string>
|
||||
<string name="action_define">Define</string>
|
||||
<string name="desktop_annotation">Annotation</string>
|
||||
<string name="desktop_annotation_options">Annotation options</string>
|
||||
<string name="desktop_annotation_tools">Annotation tools</string>
|
||||
<string name="desktop_assist">Assist</string>
|
||||
<string name="desktop_choose_pdf_to_save">Choose which PDF to save.</string>
|
||||
<string name="desktop_clear_jump_history">Clear jump history</string>
|
||||
<string name="desktop_cloud_tts_failed">Cloud TTS failed.</string>
|
||||
<string name="desktop_cloud_tts_needs_gemini_key_desc">Add a Gemini key and select Gemini cloud TTS in AI keys and models.</string>
|
||||
<string name="desktop_cloud_tts_not_configured_desc">Cloud TTS is not configured for this desktop build.</string>
|
||||
<string name="desktop_cloud_tts_sign_in_required_desc">Sign in with Google to use cloud TTS.</string>
|
||||
<string name="desktop_cloud_tts_signed_in_credits_required_desc">Cloud TTS needs a signed-in account with credits. Pro and credits can only be purchased from the Android app.</string>
|
||||
<string name="desktop_color">Color</string>
|
||||
<string name="desktop_comment_options">Comment options</string>
|
||||
<string name="desktop_custom_theme_default">Custom</string>
|
||||
<string name="desktop_delete_annotation_desc">This removes the annotation from this PDF.</string>
|
||||
<string name="desktop_delete_annotation_title">Delete annotation?</string>
|
||||
<string name="desktop_document_text">Document text</string>
|
||||
<string name="desktop_embedded_pdf_comment">Embedded PDF comment</string>
|
||||
<string name="desktop_failed_render_page">Failed to render page.</string>
|
||||
<string name="desktop_feature_unavailable">Feature unavailable</string>
|
||||
<string name="desktop_finished">Finished</string>
|
||||
<string name="desktop_fountain_pen">Fountain pen</string>
|
||||
<string name="desktop_hide_search_results">Hide search results</string>
|
||||
<!-- %1$d = highlighter palette slot number. -->
|
||||
<string name="desktop_highlight_color_format">Highlight color %1$d</string>
|
||||
<string name="desktop_highlighter_palette">Highlighter palette</string>
|
||||
<string name="desktop_interaction">Interaction</string>
|
||||
<!-- %1$d = indexed page count; %2$d = total page count. -->
|
||||
<string name="desktop_indexing_pages_format">Indexing %1$d/%2$d pages</string>
|
||||
<string name="desktop_markup">Markup</string>
|
||||
<!-- %1$d = number of search matches. -->
|
||||
<string name="desktop_matches_format">%1$d matches</string>
|
||||
<!-- %1$d = number of search matches found while indexing is still running. -->
|
||||
<string name="desktop_matches_so_far_format">%1$d matches so far</string>
|
||||
<string name="desktop_next_page">Next page</string>
|
||||
<string name="desktop_next_search_result">Next search result</string>
|
||||
<string name="desktop_no_annotations_yet">No annotations yet</string>
|
||||
<string name="desktop_no_bookmarks_yet">No bookmarks yet</string>
|
||||
<string name="desktop_no_comment">No comment</string>
|
||||
<string name="desktop_no_matches">No matches</string>
|
||||
<string name="desktop_no_matches_indexed_pages_yet">No matches in indexed pages yet</string>
|
||||
<string name="desktop_no_table_of_contents">No table of contents</string>
|
||||
<string name="desktop_no_text_here_to_read">There is no text here to read.</string>
|
||||
<string name="desktop_no_text_on_page_to_read">There is no text on this page to read.</string>
|
||||
<string name="desktop_no_text_to_summarize">There is no text to summarize.</string>
|
||||
<string name="desktop_open_comment">Open comment</string>
|
||||
<string name="desktop_out_of_credits_android_purchase_desc">Out of credits. Pro and credits can only be purchased from the Android app.</string>
|
||||
<string name="desktop_out_of_credits_cloud_tts_desc">Using cloud TTS needs credits on desktop. Pro and credits can only be purchased from the Android app.</string>
|
||||
<string name="desktop_out_of_credits_generic_feature_desc">Using this feature needs credits on desktop. Pro and credits can only be purchased from the Android app.</string>
|
||||
<string name="desktop_out_of_credits_recaps_desc">Using recaps needs credits on desktop. Pro and credits can only be purchased from the Android app.</string>
|
||||
<string name="desktop_out_of_credits_summaries_desc">Using summaries needs credits on desktop. Pro and credits can only be purchased from the Android app.</string>
|
||||
<string name="desktop_pan">Pan</string>
|
||||
<string name="desktop_pdf_action_failed">PDF action failed</string>
|
||||
<string name="desktop_pdf_action_failed_desc">The PDF action could not be completed.</string>
|
||||
<string name="desktop_pdf_comment">PDF comment</string>
|
||||
<!-- %1$d = 1-indexed page number. -->
|
||||
<string name="desktop_pdf_compact_page_number">p. %1$d</string>
|
||||
<!-- %1$d = 1-indexed page number. -->
|
||||
<string name="desktop_pdf_page_content_desc">PDF page %1$d</string>
|
||||
<!-- %1$d = 1-indexed page number; %2$s = comment author. -->
|
||||
<string name="desktop_pdf_page_author_format">Page %1$d - %2$s</string>
|
||||
<!-- %1$s = page number or page range; %2$d = total page count. -->
|
||||
<string name="desktop_pdf_page_of_count">Page %1$s of %2$d</string>
|
||||
<!-- %1$s = page range; %2$d = total page count. -->
|
||||
<string name="desktop_pdf_pages_of_count">Pages %1$s of %2$d</string>
|
||||
<string name="desktop_pdf_saved">PDF saved</string>
|
||||
<string name="desktop_pdf_tools">PDF tools</string>
|
||||
<string name="desktop_pencil">Pencil</string>
|
||||
<string name="desktop_preparing_selection">Preparing selection</string>
|
||||
<!-- %1$s = read scope, such as page or chapter. -->
|
||||
<string name="desktop_preparing_scope_format">Preparing %1$s</string>
|
||||
<string name="desktop_previous_page">Previous page</string>
|
||||
<string name="desktop_previous_search_result">Previous search result</string>
|
||||
<string name="desktop_print_dialog_finished">The print dialog has finished.</string>
|
||||
<string name="desktop_pro_required">Pro required</string>
|
||||
<string name="desktop_pro_required_generic_feature_desc">This feature requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in.</string>
|
||||
<string name="desktop_pro_required_multi_word_dictionary_desc">Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in.</string>
|
||||
<string name="desktop_reader_ai_hidden_desc">Reader AI features are hidden.</string>
|
||||
<string name="desktop_ai_not_configured_desc">Desktop AI is not configured for this build.</string>
|
||||
<string name="desktop_remove_gap_between_pages_desc">Applies to vertical reading mode.</string>
|
||||
<string name="desktop_round_highlighter">Round highlighter</string>
|
||||
<!-- %1$s = saved file path. -->
|
||||
<string name="desktop_saved_to_path_format">Saved to %1$s</string>
|
||||
<string name="desktop_scroll">Scroll</string>
|
||||
<string name="desktop_search_in_pdf">Search in PDF</string>
|
||||
<string name="desktop_select_text">Select text</string>
|
||||
<!-- %1$s = localized annotation type. -->
|
||||
<string name="desktop_selected_annotation_format">Selected %1$s</string>
|
||||
<string name="desktop_show_search_results">Show search results</string>
|
||||
<string name="desktop_sign_in_required_generic_feature_desc">Sign in with Google to use this feature on desktop.</string>
|
||||
<string name="desktop_sign_in_required_multi_word_dictionary_desc">Sign in with Google to use multi-word smart dictionary on desktop.</string>
|
||||
<string name="desktop_sign_in_required_recaps_desc">Sign in with Google to use recaps on desktop.</string>
|
||||
<string name="desktop_sign_in_required_summaries_desc">Sign in with Google to use summaries on desktop.</string>
|
||||
<string name="desktop_stopped">Stopped</string>
|
||||
<string name="desktop_text_note">Text note</string>
|
||||
<string name="desktop_text_note_lowercase">text note</string>
|
||||
<string name="desktop_text_style">Text style</string>
|
||||
<!-- %1$s = stroke thickness percentage. -->
|
||||
<string name="desktop_thickness_format">Thickness %1$s</string>
|
||||
<string name="desktop_toc">TOC</string>
|
||||
<string name="desktop_type_to_search_pdf">Type to search this PDF</string>
|
||||
<string name="desktop_untitled">Untitled</string>
|
||||
<string name="desktop_view_pro_and_credits">View Pro and credits</string>
|
||||
<string name="desktop_voice_cache_cleared">Voice cache cleared</string>
|
||||
<string name="desktop_zoom">Zoom</string>
|
||||
<string name="desktop_zoom_in">Zoom in</string>
|
||||
<string name="desktop_zoom_out">Zoom out</string>
|
||||
<string name="action_choose">Choose</string>
|
||||
<string name="action_continue_reading">Continue reading</string>
|
||||
<string name="action_dismiss">Dismiss</string>
|
||||
<string name="action_down">Down</string>
|
||||
<string name="action_up">Up</string>
|
||||
<string name="desktop_ai">AI</string>
|
||||
<string name="desktop_align_center">Center</string>
|
||||
<string name="desktop_authors">Authors</string>
|
||||
<string name="desktop_back_to_library">Back to library</string>
|
||||
<string name="desktop_book_actions">Book actions</string>
|
||||
<!-- Small badge shown on a book card when the book comes from a synced/local folder. -->
|
||||
<string name="desktop_book_badge_folder">Folder</string>
|
||||
<string name="desktop_browse">Browse</string>
|
||||
<string name="desktop_categories">Categories</string>
|
||||
<!-- %1$d = 1-indexed chapter number. -->
|
||||
<string name="desktop_chapter_short_format">Ch. %1$d</string>
|
||||
<string name="desktop_chapter_turns">Chapter Turns</string>
|
||||
<string name="desktop_choose_font">Choose font</string>
|
||||
<string name="desktop_choose_reader_texture">Choose reader texture</string>
|
||||
<string name="desktop_clear_file_types">Clear file types</string>
|
||||
<string name="desktop_clear_page_annotations">Clear page annotations</string>
|
||||
<string name="desktop_clear_sources">Clear sources</string>
|
||||
<string name="desktop_clear_status">Clear status</string>
|
||||
<string name="desktop_clear_tags">Clear tags</string>
|
||||
<string name="desktop_close_reader">Close reader</string>
|
||||
<string name="desktop_continuous">Continuous</string>
|
||||
<string name="desktop_cover_view">Covers</string>
|
||||
<string name="desktop_custom_colors">Custom colors</string>
|
||||
<string name="desktop_custom_theme_preview">Custom theme preview</string>
|
||||
<!-- %1$s = setting label. -->
|
||||
<string name="desktop_decrease_format">Decrease %1$s</string>
|
||||
<string name="desktop_define_page">Define page</string>
|
||||
<string name="desktop_delete_highlight_desc">This removes the highlight and its note.</string>
|
||||
<string name="desktop_enter_full_screen">Enter full screen</string>
|
||||
<string name="desktop_exit_full_screen">Exit full screen</string>
|
||||
<string name="desktop_external_lookup">External lookup</string>
|
||||
<!-- Standalone file-type filter group label for book formats such as EPUB and MOBI. Do not reuse for counts or sentences. -->
|
||||
<string name="desktop_file_type_group_books">Books</string>
|
||||
<string name="desktop_file_type_group_comics">Comics</string>
|
||||
<string name="desktop_file_type_group_documents">Documents</string>
|
||||
<string name="desktop_file_type_group_other">Other</string>
|
||||
<string name="desktop_file_type_group_text_web">Text and web</string>
|
||||
<string name="desktop_fill">Fill</string>
|
||||
<string name="desktop_fixed_layout_appearance">Fixed-layout appearance</string>
|
||||
<string name="desktop_folder_empty">Folder is empty</string>
|
||||
<string name="desktop_folder_empty_desc">No supported files or subfolders are available here.</string>
|
||||
<!-- Desktop folder shelf subtitle that joins already-localized count phrases. %1$s = folder count phrase; %2$s = file count phrase. -->
|
||||
<string name="desktop_folder_subtitle_folder_file_counts">%1$s, %2$s</string>
|
||||
<!-- %1$s = first label; %2$s = second label. -->
|
||||
<string name="desktop_label_pair_format">%1$s - %2$s</string>
|
||||
<string name="desktop_hide_filters">Hide filters</string>
|
||||
<string name="desktop_hide_reader_tools">Hide reader tools</string>
|
||||
<string name="desktop_highlight_palette_hint">Tap a slot, then pick a color.</string>
|
||||
<string name="desktop_home_subtitle">Continue reading and recent books</string>
|
||||
<string name="desktop_import_books">Import books</string>
|
||||
<string name="desktop_import_folder">Import folder</string>
|
||||
<string name="desktop_imported_fonts">Imported fonts</string>
|
||||
<!-- Desktop import banner that joins two complete status sentences. %1$s = imported-count sentence; %2$s = skipped-count sentence. -->
|
||||
<string name="desktop_import_result_pair">%1$s %2$s</string>
|
||||
<!-- %1$s = setting label. -->
|
||||
<string name="desktop_increase_format">Increase %1$s</string>
|
||||
<string name="desktop_jump_history">Jump history</string>
|
||||
<string name="desktop_layout_spacing">Layout and Spacing</string>
|
||||
<string name="desktop_library_empty_desc">Import files into app storage or add a folder to read files in place.</string>
|
||||
<string name="desktop_library_subtitle">Browse your collection</string>
|
||||
<!-- Desktop library tab label with count. %1$d = number of smart shelves. Keep as a full tab phrase, not a reusable noun. -->
|
||||
<string name="desktop_library_tab_smart_shelves_count">Smart %1$d</string>
|
||||
<!-- Desktop library tab label with count. %1$d = number of unread books. Keep as a full tab phrase. -->
|
||||
<string name="desktop_library_tab_unread_count">Unread %1$d</string>
|
||||
<!-- Desktop library tab label with count. %1$d = number of in-progress books. Keep as a full tab phrase. -->
|
||||
<string name="desktop_library_tab_in_progress_count">In progress %1$d</string>
|
||||
<!-- Desktop library tab label with count. %1$d = number of completed books. Keep as a full tab phrase. -->
|
||||
<string name="desktop_library_tab_completed_count">Complete %1$d</string>
|
||||
<string name="desktop_list_view">List</string>
|
||||
<string name="desktop_navigation">Navigation</string>
|
||||
<string name="desktop_no_book_open">No book open</string>
|
||||
<string name="desktop_no_folders_desc">Add a folder to read files from that folder in place.</string>
|
||||
<string name="desktop_no_folders_yet">No folders yet</string>
|
||||
<string name="desktop_no_navigation_items">No navigation items</string>
|
||||
<string name="desktop_no_page_content">No page content</string>
|
||||
<string name="desktop_no_settings_found">No settings found</string>
|
||||
<string name="desktop_no_shelves_desc">Manual shelves and series collections will appear here.</string>
|
||||
<string name="desktop_no_shelves_yet">No shelves yet</string>
|
||||
<string name="desktop_no_smart_shelves_desc">Create smart shelves to collect books by rules.</string>
|
||||
<string name="desktop_no_smart_shelves_yet">No smart shelves yet</string>
|
||||
<string name="desktop_no_tags_desc">Tags added to books will appear here.</string>
|
||||
<string name="desktop_no_tags_yet">No tags yet</string>
|
||||
<!-- Desktop import result banner shown when every selected file is unsupported. -->
|
||||
<string name="desktop_no_supported_files_imported">No supported files were imported.</string>
|
||||
<string name="desktop_opds_catalog">Catalog</string>
|
||||
<!-- %1$s = catalog title. -->
|
||||
<string name="desktop_opds_delete_catalog_desc">Delete "%1$s"? Streamed books from this catalog may stop opening if credentials change later.</string>
|
||||
<string name="desktop_opds_no_catalogs">No catalogs</string>
|
||||
<string name="desktop_opds_no_catalogs_desc">Add an OPDS catalog to browse remote books.</string>
|
||||
<string name="desktop_opds_subtitle">Browse catalogs, streams, and downloads</string>
|
||||
<string name="desktop_open_book">Open Book</string>
|
||||
<string name="desktop_open_folder">Open folder</string>
|
||||
<string name="desktop_open_pdf">Open PDF</string>
|
||||
<string name="desktop_page_and_text_colors">Page and text colors</string>
|
||||
<string name="desktop_page_info">Page Info</string>
|
||||
<string name="desktop_page_width">Page width</string>
|
||||
<string name="desktop_pdf_appearance_defaults_desc">These defaults apply where the platform supports shared PDF appearance. Per-book PDF overrides stay in the PDF reader.</string>
|
||||
<string name="desktop_pdf_file_actions">PDF file actions</string>
|
||||
<string name="desktop_pdf_highlighter">PDF highlighter</string>
|
||||
<string name="desktop_pdf_highlighter_alpha_desc">Saved with reader highlight transparency.</string>
|
||||
<string name="desktop_pin">Pin</string>
|
||||
<string name="desktop_reader_managed_pdf_tools">Reader-managed PDF tools</string>
|
||||
<string name="desktop_reader_managed_pdf_tools_desc">Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader.</string>
|
||||
<!-- %1$s = page mode; %2$s = page or spread label; %3$d = total pages; %4$d = progress percentage. -->
|
||||
<string name="desktop_reader_page_info_format">%1$s %2$s of %3$d (%4$d%%)</string>
|
||||
<string name="desktop_reader_toolbar_managed_in_reader">Reader toolbar defaults are managed from the reader on this platform.</string>
|
||||
<string name="desktop_reader_tools">Reader tools</string>
|
||||
<string name="desktop_save_image">Save image</string>
|
||||
<!-- %1$s = current search query. -->
|
||||
<string name="desktop_search_filter_format">Search: %1$s</string>
|
||||
<string name="desktop_search_in_reader">Search in reader</string>
|
||||
<string name="desktop_search_settings">Search settings</string>
|
||||
<string name="desktop_selection">Selection</string>
|
||||
<string name="desktop_selection_end_handle">Selection end handle</string>
|
||||
<string name="desktop_selection_start_handle">Selection start handle</string>
|
||||
<!-- Empty-state body for the top-level shelves screen that includes shelves, tags, and folder metadata. -->
|
||||
<string name="desktop_shelves_overview_empty_desc">Add shelves, tags, or folder metadata to organize your library.</string>
|
||||
<string name="desktop_shelves_subtitle">Collections, series, tags, and folders</string>
|
||||
<string name="desktop_show_reader_tools">Show reader tools</string>
|
||||
<!-- Desktop smart-shelf rule field label for matching a source folder path. -->
|
||||
<string name="desktop_smart_field_folder">Folder</string>
|
||||
<string name="desktop_smart_shelves">Smart</string>
|
||||
<string name="desktop_solid">Solid</string>
|
||||
<string name="desktop_speed">Speed</string>
|
||||
<string name="desktop_start_auto_scroll">Start auto scroll</string>
|
||||
<string name="desktop_stop_auto_scroll">Stop auto scroll</string>
|
||||
<string name="desktop_stop_read_aloud">Stop read aloud</string>
|
||||
<string name="desktop_texture_strength">Texture strength</string>
|
||||
<string name="desktop_type_to_search_book">Type to search this book</string>
|
||||
<string name="desktop_typography">Typography</string>
|
||||
<string name="desktop_undo_annotation">Undo annotation</string>
|
||||
<string name="desktop_unpin">Unpin</string>
|
||||
<string name="desktop_use_dark_theme">Use dark theme</string>
|
||||
<string name="desktop_use_light_theme">Use light theme</string>
|
||||
<string name="font_mono">Mono</string>
|
||||
<string name="font_sans">Sans</string>
|
||||
<string name="font_serif">Serif</string>
|
||||
<string name="library_search_placeholder">Search books, authors, or tags</string>
|
||||
<string name="toolbar_no_tools">No tools</string>
|
||||
<string name="toolbar_visible">Visible</string>
|
||||
<string name="tts_replacements_replace_only_spoken">Replace only what is spoken</string>
|
||||
<string name="tts_replacements_replace_only_spoken_desc">Reader text, highlights, and locations stay unchanged.</string>
|
||||
<!-- %1$s = replacement source; %2$s = spoken replacement. -->
|
||||
<string name="tts_replacements_summary_format">%1$s -> %2$s</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ class AndroidSettingsHubModelsTest {
|
|||
uiState = ReaderScreenState(
|
||||
isTabsEnabled = true,
|
||||
useStrictFileFilter = true,
|
||||
usePdfFileNameAsDisplayName = true,
|
||||
isScreenCaptureProtectionEnabled = true
|
||||
),
|
||||
isOssBuild = false,
|
||||
|
|
@ -134,6 +135,7 @@ class AndroidSettingsHubModelsTest {
|
|||
|
||||
assertTrue(toggles.getValue(SharedSettingsAction.TABS_TOGGLE).checked == true)
|
||||
assertTrue(toggles.getValue(SharedSettingsAction.STRICT_FILE_FILTER).checked == true)
|
||||
assertTrue(toggles.getValue(SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME).checked == true)
|
||||
assertTrue(toggles.getValue(SharedSettingsAction.SCREEN_CAPTURE_PROTECTION).checked == true)
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +155,7 @@ class AndroidSettingsHubModelsTest {
|
|||
assertTrue(SharedSettingsAction.LANGUAGE in extraActions)
|
||||
assertTrue(SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR in extraActions)
|
||||
assertTrue(SharedSettingsAction.STRICT_FILE_FILTER in extraActions)
|
||||
assertTrue(SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME in extraActions)
|
||||
assertTrue(SharedSettingsAction.CLEAR_BOOK_CACHE in extraActions)
|
||||
assertTrue(SharedSettingsAction.CLEAR_REFLOW_CACHE in extraActions)
|
||||
assertTrue(SharedSettingsAction.TEST_PANEL_DETECTION in extraActions)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.aryan.reader.data.BookTagCrossRef
|
|||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.TagEntity
|
||||
import com.aryan.reader.shared.AppAction as SharedAppAction
|
||||
import com.aryan.reader.shared.AppFontPreference as SharedAppFontPreference
|
||||
import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode
|
||||
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
|
||||
import org.junit.Assert.assertEquals
|
||||
|
|
@ -78,6 +79,19 @@ class AndroidSharedStateBridgeTest {
|
|||
assertEquals(AppThemeMode.DARK, result.appThemeMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reduceAppAction applies shared app font preference back to Android fields`() {
|
||||
val preference = SharedAppFontPreference.custom("font")
|
||||
|
||||
val result = AndroidSharedStateBridge.reduceAppAction(
|
||||
current = ReaderScreenState(),
|
||||
projectedState = ReaderScreenState(),
|
||||
action = SharedAppAction.AppFontPreferenceChanged(preference)
|
||||
)
|
||||
|
||||
assertEquals(preference, result.appFontPreference)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setTabsEnabled disables shared tabs but preserves Android active reader session`() {
|
||||
val result = AndroidSharedStateBridge.setTabsEnabled(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.IllegalFormatException
|
||||
import java.util.Locale
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AndroidStringFormatResourcesTest {
|
||||
|
||||
@Test
|
||||
fun `localized formatted strings use valid formatter syntax`() {
|
||||
val resDirectory = findResDirectory()
|
||||
val baseStrings = readStringResources(File(resDirectory, "values/strings.xml"))
|
||||
val formattedBaseStrings = baseStrings
|
||||
.mapValues { (_, value) -> value.formatArguments() }
|
||||
.filterValues { it.isNotEmpty() }
|
||||
|
||||
val failures = resDirectory
|
||||
.listFiles()
|
||||
.orEmpty()
|
||||
.filter { it.isDirectory && it.name.startsWith("values") }
|
||||
.map { File(it, "strings.xml") }
|
||||
.filter { it.isFile }
|
||||
.flatMap { stringsFile ->
|
||||
val strings = readStringResources(stringsFile)
|
||||
formattedBaseStrings.mapNotNull { (name, arguments) ->
|
||||
val value = strings[name] ?: return@mapNotNull null
|
||||
val sampleArguments = arguments.toSampleArguments()
|
||||
try {
|
||||
String.format(Locale.ROOT, value, *sampleArguments)
|
||||
null
|
||||
} catch (exception: IllegalFormatException) {
|
||||
"${stringsFile.invariantSeparatorsPath}:$name -> ${exception.javaClass.simpleName}: ${exception.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(failures.joinToString(separator = "\n"), failures.isEmpty())
|
||||
}
|
||||
|
||||
private fun findResDirectory(): File {
|
||||
return listOf(
|
||||
File("src/main/res"),
|
||||
File("app/src/main/res")
|
||||
).first { it.isDirectory }
|
||||
}
|
||||
|
||||
private fun readStringResources(stringsFile: File): Map<String, String> {
|
||||
val document = DocumentBuilderFactory.newInstance()
|
||||
.newDocumentBuilder()
|
||||
.parse(stringsFile)
|
||||
val nodes = document.getElementsByTagName("string")
|
||||
|
||||
return buildMap {
|
||||
for (index in 0 until nodes.length) {
|
||||
val node = nodes.item(index)
|
||||
val name = node.attributes
|
||||
?.getNamedItem("name")
|
||||
?.nodeValue
|
||||
?: continue
|
||||
put(name, node.textContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.formatArguments(): List<FormatArgument> {
|
||||
val arguments = mutableListOf<FormatArgument>()
|
||||
var nextImplicitIndex = 0
|
||||
var previousIndex = -1
|
||||
|
||||
for (match in formatterPattern.findAll(this)) {
|
||||
val conversion = (match.groups[4] ?: match.groups[5])?.value?.singleOrNull() ?: continue
|
||||
val dateTimePrefix = match.groups[3]?.value
|
||||
if (conversion == '%' || conversion == 'n') continue
|
||||
|
||||
val index = when {
|
||||
match.groups[2] != null -> previousIndex
|
||||
match.groups[1] != null -> match.groups[1]!!.value.toInt() - 1
|
||||
else -> nextImplicitIndex++
|
||||
}
|
||||
if (index < 0) continue
|
||||
|
||||
previousIndex = index
|
||||
val argument = FormatArgument(index, conversion.sampleKind(dateTimePrefix != null))
|
||||
val existingIndex = arguments.indexOfFirst { it.index == index }
|
||||
if (existingIndex >= 0) {
|
||||
arguments[existingIndex] = arguments[existingIndex].merge(argument)
|
||||
} else {
|
||||
arguments += argument
|
||||
}
|
||||
}
|
||||
|
||||
return arguments
|
||||
}
|
||||
|
||||
private fun Char.sampleKind(isDateTime: Boolean): SampleKind {
|
||||
if (isDateTime) return SampleKind.DateTime
|
||||
return when (lowercaseChar()) {
|
||||
'd', 'o', 'x' -> SampleKind.Integer
|
||||
'e', 'f', 'g', 'a' -> SampleKind.Decimal
|
||||
'c' -> SampleKind.Character
|
||||
'b' -> SampleKind.Boolean
|
||||
'h', 's' -> SampleKind.Text
|
||||
else -> SampleKind.Text
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<FormatArgument>.toSampleArguments(): Array<Any> {
|
||||
val maxIndex = maxOf { it.index }
|
||||
val samples = Array<Any>(maxIndex + 1) { "sample" }
|
||||
forEach { argument ->
|
||||
samples[argument.index] = argument.kind.sample
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
private data class FormatArgument(
|
||||
val index: Int,
|
||||
val kind: SampleKind
|
||||
) {
|
||||
fun merge(other: FormatArgument): FormatArgument {
|
||||
return if (kind == other.kind) this else copy(kind = SampleKind.Text)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SampleKind(val sample: Any) {
|
||||
Text("sample"),
|
||||
Integer(7),
|
||||
Decimal(1.5),
|
||||
Character('x'),
|
||||
Boolean(true),
|
||||
DateTime(Date(0L))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val formatterPattern = Regex(
|
||||
"%(?:([1-9]\\d*)\\$|(<))?[-#+ 0,(]*\\d*(?:\\.\\d+)?(?:(?:([tT])([a-zA-Z]))|([bBhHsScCdoxXeEfgGaA%n]))"
|
||||
)
|
||||
}
|
||||
}
|
||||
34
app/src/test/java/com/aryan/reader/AppFontResolverTest.kt
Normal file
34
app/src/test/java/com/aryan/reader/AppFontResolverTest.kt
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import com.aryan.reader.data.CustomFontEntity
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AppFontResolverTest {
|
||||
@Test
|
||||
fun `custom app font falls back to system when imported font is missing`() {
|
||||
val resolved = AppFontPreference.custom("missing")
|
||||
.toAndroidAppFontFamily(customFonts = emptyList())
|
||||
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom app font falls back to system when imported font file is gone`() {
|
||||
val resolved = AppFontPreference.custom("font")
|
||||
.toAndroidAppFontFamily(
|
||||
customFonts = listOf(
|
||||
CustomFontEntity(
|
||||
id = "font",
|
||||
displayName = "Missing",
|
||||
fileName = "missing.ttf",
|
||||
fileExtension = "ttf",
|
||||
path = "build/test-tmp/AppFontResolverTest/missing-${System.nanoTime()}.ttf",
|
||||
timestamp = 1L
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertNull(resolved)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import org.junit.Assert.assertEquals
|
|||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.w3c.dom.Element
|
||||
|
||||
class AppLanguageOptionsTest {
|
||||
|
||||
|
|
@ -79,6 +80,25 @@ class AppLanguageOptionsTest {
|
|||
assertEquals(readLocaleConfigTags(), supportedAppLanguageOptions.map { it.tag })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `android manifest enables AppCompat language persistence`() {
|
||||
val manifest = readAndroidManifest()
|
||||
val service = manifest.getElementsByTagName("service").asElements()
|
||||
.singleOrNull {
|
||||
it.androidAttribute("name") == "androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
}
|
||||
|
||||
assertTrue(service != null)
|
||||
assertEquals("false", service!!.androidAttribute("enabled"))
|
||||
assertEquals("false", service.androidAttribute("exported"))
|
||||
|
||||
val autoStoreLocales = service.getElementsByTagName("meta-data").asElements()
|
||||
.singleOrNull { it.androidAttribute("name") == "autoStoreLocales" }
|
||||
|
||||
assertTrue(autoStoreLocales != null)
|
||||
assertEquals("true", autoStoreLocales!!.androidAttribute("value"))
|
||||
}
|
||||
|
||||
private fun readLocaleConfigTags(): List<String> {
|
||||
val localeConfig = listOf(
|
||||
File("src/main/res/xml/locales_config.xml"),
|
||||
|
|
@ -101,4 +121,28 @@ class AppLanguageOptionsTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readAndroidManifest(): org.w3c.dom.Document {
|
||||
val manifest = listOf(
|
||||
File("src/main/AndroidManifest.xml"),
|
||||
File("app/src/main/AndroidManifest.xml")
|
||||
).first { it.isFile }
|
||||
return DocumentBuilderFactory.newInstance()
|
||||
.apply { isNamespaceAware = true }
|
||||
.newDocumentBuilder()
|
||||
.parse(manifest)
|
||||
}
|
||||
|
||||
private fun org.w3c.dom.NodeList.asElements(): List<Element> =
|
||||
buildList {
|
||||
for (index in 0 until length) {
|
||||
val element = item(index) as? Element
|
||||
if (element != null) add(element)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Element.androidAttribute(name: String): String? =
|
||||
attributes
|
||||
?.getNamedItemNS("http://schemas.android.com/apk/res/android", name)
|
||||
?.nodeValue
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue