From d7a9cae9e1a8d3826eac1572004a639e0d098ac4 Mon Sep 17 00:00:00 2001 From: Aryan Date: Mon, 4 May 2026 21:55:38 +0530 Subject: [PATCH] v1.0.47 (#279) * Add performance and stylus debugging logs * Refactor and decouple UI models from `MainViewModel` * Refactor library state management and projection logic * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Introduce ReaderEngine and enhance EPUB reader features in windows app * Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support. * Implement PDF rendering and text extraction for desktop using Pdfium * Add `NonReaderScreens.kt` and UI dependencies * Refactor and centralize library state management and models to improve cross-platform consistency * Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing * Implement PDF annotation system and enhanced zoom controls for the desktop viewer * Implement WebView-based EPUB rendering for desktop using CEF and embedded resources * Optimize UI state projection, navigation state handling, and main screen pager performance * Implement Bring Your Own Key (BYOK) support for AI features in OSS version * Support Gemini-based Cloud TTS with BYOK support for OSS builds * Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling. * crash fixes * Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6 * Implement dynamic bottom padding for the page info bar to account for device rounded corners * Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar` * Optimize PDF tiling performance and refine pan-and-fling gesture handling * Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers * Updated UI for customize toolbar * Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls * restructure PDF viewer UI component hierarchy to fix verifier crash * Implement separate text dimming factors for light and dark themes * Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers * Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning * Centralize file type resolution logic and implement HTML sanitization during import * Introduce vertical margin customization and configurable progress bar positioning * texture support in epub reader * Enhance TTS session management, progress tracking, and diagnostic logging * Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic. * Refine TTS page mapping for PDF and overhaul TTS control UI * Implement natural session completion logic in `TtsPlaybackManager` for cloud tts * Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen` * Refine TTS playback continuity across PDF pages and improve state management for session transitions * Implement global texture transparency and enhance textured theme support across PDF and EPUB readers. * Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode * Add Support Project screen * Optimize library performance via projection caching, batch database updates, and scoped folder synchronization. * Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic * Bump version to 1.0.47 (51) --- .gitignore | 10 +- .idea/gradle.xml | 20 + app/build.gradle.kts | 14 +- app/src/main/assets/epub_reader.js | 94 +- .../main/assets/textures/classy_fabric.webp | Bin 0 -> 1944 bytes .../main/assets/textures/ep_naturalblack.webp | Bin 0 -> 19270 bytes .../main/assets/textures/ep_naturalwhite.webp | Bin 0 -> 15394 bytes .../main/assets/textures/grey_wash_wall.webp | Bin 0 -> 7450 bytes .../main/assets/textures/light-veneer.webp | Bin 0 -> 2606 bytes app/src/main/assets/textures/retina_wood.webp | Bin 0 -> 9196 bytes app/src/main/assets/textures/retro_intro.webp | Bin 0 -> 1620 bytes app/src/main/cpp/pdfium_bridge.cpp | 56 +- .../java/com/aryan/reader/AiSettingsScreen.kt | 314 +++ .../aryan/reader/AndroidFolderPathResolver.kt | 53 + .../java/com/aryan/reader/AppNavigation.kt | 133 +- .../main/java/com/aryan/reader/AppUiModels.kt | 127 + app/src/main/java/com/aryan/reader/Common.kt | 1267 +++++++-- .../java/com/aryan/reader/FileTypeResolver.kt | 94 + .../java/com/aryan/reader/FolderSyncWorker.kt | 363 ++- .../main/java/com/aryan/reader/HomeScreen.kt | 165 +- .../java/com/aryan/reader/LibraryModels.kt | 86 + .../java/com/aryan/reader/LibraryScreen.kt | 105 +- .../com/aryan/reader/LibraryStateProjector.kt | 435 +++ .../java/com/aryan/reader/MainActivity.kt | 6 +- .../com/aryan/reader/MainPreferenceKeys.kt | 14 + .../main/java/com/aryan/reader/MainScreen.kt | 49 +- .../java/com/aryan/reader/MainViewModel.kt | 712 +---- .../aryan/reader/MetadataExtractionWorker.kt | 363 ++- .../com/aryan/reader/NonReaderScreenModels.kt | 54 + .../java/com/aryan/reader/ReaderPerfLog.kt | 59 + .../com/aryan/reader/SharedModelMappers.kt | 220 ++ .../java/com/aryan/reader/UiLabelResources.kt | 42 + .../java/com/aryan/reader/data/AppDatabase.kt | 12 +- .../com/aryan/reader/data/LocalSyncUtils.kt | 222 +- .../com/aryan/reader/data/RecentFileDao.kt | 55 +- .../com/aryan/reader/data/RecentFileEntity.kt | 5 +- .../com/aryan/reader/data/RecentFileItem.kt | 15 +- .../reader/data/RecentFileItemAndroid.kt | 6 + .../reader/data/RecentFilesRepository.kt | 77 +- .../java/com/aryan/reader/epub/EpubBook.kt | 14 +- .../aryan/reader/epub/ImportedFileCache.kt | 11 +- .../java/com/aryan/reader/epub/MobiParser.kt | 17 +- .../aryan/reader/epub/SingleFileImporter.kt | 65 +- .../aryan/reader/epubreader/ChapterWebView.kt | 33 +- .../epubreader/DictionarySettingsDialog.kt | 6 +- .../aryan/reader/epubreader/EpubReaderAi.kt | 28 +- .../reader/epubreader/EpubReaderControls.kt | 1056 +++++-- .../reader/epubreader/EpubReaderDrawer.kt | 4 +- .../reader/epubreader/EpubReaderScreen.kt | 285 +- .../reader/epubreader/EpubReaderSettings.kt | 65 + .../reader/epubreader/EpubReaderSystem.kt | 9 +- .../aryan/reader/epubreader/EpubReaderTts.kt | 6 + .../reader/feedback/SupportProjectScreen.kt | 172 ++ .../java/com/aryan/reader/opds/OpdsModels.kt | 5 +- .../com/aryan/reader/opds/OpdsViewModel.kt | 54 +- .../AndroidHtmlParserPlatform.kt | 81 + .../reader/paginatedreader/BookPaginator.kt | 20 +- .../reader/paginatedreader/ContentStyler.kt | 16 +- .../reader/paginatedreader/FontLoader.kt | 43 +- .../aryan/reader/paginatedreader/Locator.kt | 4 +- .../reader/paginatedreader/PaginatedReader.kt | 325 ++- .../PaginatedReaderViewModel.kt | 3 +- .../paginatedreader/data/BookCacheDatabase.kt | 4 +- .../paginatedreader/data/BookCacheEntities.kt | 4 +- .../data/BookProcessingWorker.kt | 6 +- .../aryan/reader/pdf/MagnifierComposable.kt | 18 +- .../aryan/reader/pdf/NativePdfiumBridge.kt | 14 +- .../com/aryan/reader/pdf/PdfNavigationUI.kt | 4 +- .../com/aryan/reader/pdf/PdfPageComposable.kt | 308 ++- .../com/aryan/reader/pdf/PdfPreferences.kt | 39 +- .../com/aryan/reader/pdf/PdfSettingsSheets.kt | 511 +++- .../aryan/reader/pdf/PdfToHtmlGenerator.kt | 28 +- .../java/com/aryan/reader/pdf/PdfToolbars.kt | 533 +++- .../com/aryan/reader/pdf/PdfVerticalReader.kt | 4 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 2057 +++++++------- .../aryan/reader/pdf/PdfiumEngineProvider.kt | 120 + .../com/aryan/reader/pdf/UniversalDocument.kt | 167 +- .../reader/pdf/data/PdfTextRepository.kt | 64 +- .../aryan/reader/tts/BaseTtsSynthesizer.kt | 13 +- .../com/aryan/reader/tts/TtsController.kt | 59 +- .../aryan/reader/tts/TtsPlaybackManager.kt | 237 +- .../java/com/aryan/reader/tts/TtsService.kt | 69 +- .../java/com/aryan/reader/tts/TtsUtils.kt | 2 +- app/src/main/res/values/strings.xml | 27 +- .../aryan/reader/data/CloudflareRepository.kt | 2 +- .../com/aryan/reader/FileTypeResolverTest.kt | 28 + build.gradle.kts | 7 +- desktopApp/build.gradle.kts | 55 + .../aryan/reader/desktop/DesktopEpubLoader.kt | 282 ++ .../reader/desktop/DesktopLibraryDatabase.kt | 205 ++ .../com/aryan/reader/desktop/DesktopPdfium.kt | 239 ++ .../kotlin/com/aryan/reader/desktop/Main.kt | 2429 +++++++++++++++++ gradle/libs.versions.toml | 4 +- settings.gradle.kts | 6 +- shared/build.gradle.kts | 49 + .../aryan/reader/shared/Platform.android.kt | 3 + .../aryan/reader/paginatedreader/CssParser.kt | 119 +- .../paginatedreader/FontFamilyMapper.kt | 39 + .../paginatedreader/PaginatedReaderData.kt | 0 .../reader/paginatedreader/SemanticModel.kt | 0 .../reader/paginatedreader/StyleUtils.kt | 0 .../paginatedreader/UserAgentStylesheet.kt | 0 .../serialization/ComposeTypeSerializers.kt | 0 .../com/aryan/reader/shared/AppActions.kt | 38 + .../com/aryan/reader/shared/AppModels.kt | 122 + .../com/aryan/reader/shared/LibraryModels.kt | 139 + .../aryan/reader/shared/LibraryProjector.kt | 173 ++ .../reader/shared/LibraryStateProjector.kt | 324 +++ .../aryan/reader/shared/PdfReaderModels.kt | 81 + .../reader/shared/ReaderAnnotationModels.kt | 51 + .../reader/shared/ReaderAppearanceModels.kt | 67 + .../aryan/reader/shared/ReaderSearchModels.kt | 29 + .../reader/shared/RepositoryContracts.kt | 58 + .../com/aryan/reader/shared/SampleLibrary.kt | 66 + .../aryan/reader/shared/ScreenProjectors.kt | 53 + .../aryan/reader/shared/SharedFormatters.kt | 48 + .../com/aryan/reader/shared/SharedReducers.kt | 62 + .../reader/shared/pdf/PdfInteractionModels.kt | 140 + .../aryan/reader/shared/pdf/PdfiumBridge.kt | 63 + .../reader/shared/reader/ReaderEngine.kt | 163 ++ .../reader/ReaderHtmlDocumentBuilder.kt | 219 ++ .../reader/shared/reader/ReaderModels.kt | 102 + .../reader/shared/reader/SimplePaginator.kt | 109 + .../reader/shared/ui/NonReaderScreens.kt | 887 ++++++ .../aryan/reader/shared/Platform.desktop.kt | 3 + .../reader/paginatedreader/HtmlParser.kt | 120 +- 126 files changed, 15287 insertions(+), 3154 deletions(-) create mode 100644 .idea/gradle.xml create mode 100644 app/src/main/assets/textures/classy_fabric.webp create mode 100644 app/src/main/assets/textures/ep_naturalblack.webp create mode 100644 app/src/main/assets/textures/ep_naturalwhite.webp create mode 100644 app/src/main/assets/textures/grey_wash_wall.webp create mode 100644 app/src/main/assets/textures/light-veneer.webp create mode 100644 app/src/main/assets/textures/retina_wood.webp create mode 100644 app/src/main/assets/textures/retro_intro.webp create mode 100644 app/src/main/java/com/aryan/reader/AiSettingsScreen.kt create mode 100644 app/src/main/java/com/aryan/reader/AndroidFolderPathResolver.kt create mode 100644 app/src/main/java/com/aryan/reader/AppUiModels.kt create mode 100644 app/src/main/java/com/aryan/reader/FileTypeResolver.kt create mode 100644 app/src/main/java/com/aryan/reader/LibraryModels.kt create mode 100644 app/src/main/java/com/aryan/reader/LibraryStateProjector.kt create mode 100644 app/src/main/java/com/aryan/reader/MainPreferenceKeys.kt create mode 100644 app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt create mode 100644 app/src/main/java/com/aryan/reader/ReaderPerfLog.kt create mode 100644 app/src/main/java/com/aryan/reader/SharedModelMappers.kt create mode 100644 app/src/main/java/com/aryan/reader/UiLabelResources.kt create mode 100644 app/src/main/java/com/aryan/reader/data/RecentFileItemAndroid.kt create mode 100644 app/src/main/java/com/aryan/reader/feedback/SupportProjectScreen.kt create mode 100644 app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt create mode 100644 app/src/main/java/com/aryan/reader/pdf/PdfiumEngineProvider.kt create mode 100644 app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt create mode 100644 desktopApp/build.gradle.kts create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubLoader.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt create mode 100644 shared/build.gradle.kts create mode 100644 shared/src/androidMain/kotlin/com/aryan/reader/shared/Platform.android.kt rename {app/src/main/java => shared/src/commonMain/kotlin}/com/aryan/reader/paginatedreader/CssParser.kt (92%) create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/FontFamilyMapper.kt rename {app/src/main/java => shared/src/commonMain/kotlin}/com/aryan/reader/paginatedreader/PaginatedReaderData.kt (100%) rename {app/src/main/java => shared/src/commonMain/kotlin}/com/aryan/reader/paginatedreader/SemanticModel.kt (100%) rename {app/src/main/java => shared/src/commonMain/kotlin}/com/aryan/reader/paginatedreader/StyleUtils.kt (100%) rename {app/src/main/java => shared/src/commonMain/kotlin}/com/aryan/reader/paginatedreader/UserAgentStylesheet.kt (100%) rename {app/src/main/java => shared/src/commonMain/kotlin}/com/aryan/reader/paginatedreader/serialization/ComposeTypeSerializers.kt (100%) create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/PdfReaderModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderSearchModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/SampleLibrary.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ScreenProjectors.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFormatters.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfiumBridge.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SimplePaginator.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt create mode 100644 shared/src/desktopMain/kotlin/com/aryan/reader/shared/Platform.desktop.kt rename {app/src/main/java => shared/src/readerJvmMain/kotlin}/com/aryan/reader/paginatedreader/HtmlParser.kt (88%) diff --git a/.gitignore b/.gitignore index e3b1d94..633a1fb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store -/build +build/ /captures .externalNativeBuild .cxx @@ -17,4 +17,10 @@ local.properties *.jks google-services.json .kotlin/ -.idea/ \ No newline at end of file +.idea/ +.gradle/ +third_party/pdfium/ +*.tgz +kcef-bundle/ +cache/ +worker/ \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..c0bde3e --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 136618b..9e2d5a2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,14 +24,13 @@ kotlin { android { namespace = "com.aryan.reader" compileSdk = 36 - ndkVersion = "29.0.14206865" defaultConfig { applicationId = "com.aryan.reader" minSdk = 26 targetSdk = 35 - versionCode = 46 - versionName = "1.0.46" + versionCode = 51 + versionName = "1.0.47" resourceConfigurations += setOf("en", "ar", "de", "tr", "fr", "ru") @@ -154,6 +153,8 @@ android { //noinspection UseTomlInstead dependencies { + implementation(project(":shared")) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -190,7 +191,7 @@ dependencies { implementation("androidx.appcompat:appcompat:1.7.1") //noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break) - implementation("androidx.navigation:navigation-compose:2.9.2") + implementation("androidx.navigation:navigation-compose:2.9.6") //noinspection GradleDependency implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2") //noinspection GradleDependency @@ -241,6 +242,11 @@ dependencies { implementation("com.materialkolor:material-kolor:5.0.0-alpha07") + debugImplementation("org.tensorflow:tensorflow-lite:2.17.0") + debugImplementation("org.tensorflow:tensorflow-lite-support:0.5.0") + debugImplementation("org.tensorflow:tensorflow-lite-gpu:2.17.0") + debugImplementation("org.tensorflow:tensorflow-lite-gpu-api:2.17.0") + implementation("androidx.core:core-splashscreen:1.2.0") testImplementation("junit:junit:4.13.2") diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index 994a086..b5d78e9 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -189,7 +189,7 @@ window.VIEWPORT_PADDING_BOTTOM = bottom || 0; }; - window.applyReaderTheme = function (isDark, bgHex, textHex, textureBase64) { + window.applyReaderTheme = function (isDark, bgHex, textHex, textureBase64, textureAlpha) { var styleId = "readerThemeStyle"; var themeStyleElement = document.getElementById(styleId); @@ -207,8 +207,14 @@ var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF'); var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000'); + var effectiveTextureAlpha = Math.max(0, Math.min(1, textureAlpha == null ? 0.55 : textureAlpha)); + var bgMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(effectiveBg); + var bgRgb = bgMatch + ? `${parseInt(bgMatch[1], 16)}, ${parseInt(bgMatch[2], 16)}, ${parseInt(bgMatch[3], 16)}` + : (isDark ? '18, 18, 18' : '255, 255, 255'); + var textureCss = textureBase64 - ? `background-image: url('${textureBase64}'); background-repeat: repeat; background-blend-mode: multiply;` + ? `background-image: linear-gradient(rgba(${bgRgb},${1 - effectiveTextureAlpha}), rgba(${bgRgb},${1 - effectiveTextureAlpha})), url('${textureBase64}'); background-repeat: repeat, repeat; background-blend-mode: normal, normal;` : 'background-image: none;'; var css = ` @@ -517,7 +523,65 @@ } }, true); - window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin) { + function getReaderImageElements() { + return Array.prototype.slice.call(document.querySelectorAll("img, svg, video, canvas, image")); + } + + function rememberReaderImageAnchors() { + getReaderImageElements().forEach(function (image) { + if (image.getAttribute("data-reader-image-anchor")) return; + + var parent = image.parentElement || document.body; + var imageRect = image.getBoundingClientRect(); + var parentRect = parent.getBoundingClientRect(); + var parentWidth = parentRect.width || document.documentElement.clientWidth || window.innerWidth || 0; + + if (!parentWidth || imageRect.width <= 0) { + image.setAttribute("data-reader-image-anchor", "center"); + return; + } + + var imageCenter = imageRect.left + imageRect.width / 2; + var parentCenter = parentRect.left + parentWidth / 2; + var tolerance = Math.max(4, parentWidth * 0.08); + var anchor = "center"; + + if (Math.abs(imageCenter - parentCenter) <= tolerance || imageRect.width >= parentWidth - 2) { + anchor = "center"; + } else if (imageCenter > parentCenter) { + anchor = "right"; + } else { + anchor = "left"; + } + + image.setAttribute("data-reader-image-anchor", anchor); + }); + } + + function applyReaderImageAnchors() { + getReaderImageElements().forEach(function (image) { + var anchor = image.getAttribute("data-reader-image-anchor") || "center"; + + image.style.setProperty("display", "block", "important"); + image.style.setProperty("height", "auto", "important"); + image.style.setProperty("object-fit", "contain", "important"); + + if (anchor === "right") { + image.style.setProperty("float", "none", "important"); + image.style.setProperty("margin-left", "auto", "important"); + image.style.setProperty("margin-right", "0", "important"); + } else if (anchor === "left") { + image.style.setProperty("margin-left", "0", "important"); + image.style.setProperty("margin-right", "auto", "important"); + } else { + image.style.setProperty("float", "none", "important"); + image.style.setProperty("margin-left", "auto", "important"); + image.style.setProperty("margin-right", "auto", "important"); + } + }); + } + + window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin, verticalMargin) { var logTag = "ReaderFontDiagnosis"; console.log( logTag + @@ -534,7 +598,9 @@ ", ImageSize: " + imageSize + ", HorizontalMargin: " + - horizontalMargin + horizontalMargin + + ", VerticalMargin: " + + verticalMargin ); var dynamicStyleId = "dynamicReaderStyles"; @@ -551,12 +617,16 @@ var newGap = parseFloat(paragraphGap); var newImageSize = parseFloat(imageSize); var newHorizontalMargin = parseFloat(horizontalMargin); + var newVerticalMargin = parseFloat(verticalMargin); if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0; if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0; if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0; if (isNaN(newImageSize) || newImageSize < 0.5 || newImageSize > 2.0) newImageSize = 1.0; if (isNaN(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0; + if (isNaN(newVerticalMargin) || newVerticalMargin < 0.0 || newVerticalMargin > 3.0) newVerticalMargin = 1.0; + + rememberReaderImageAnchors(); var fontCss = ""; if (fontFamily && fontFamily !== "Original" && fontFamily !== "") { @@ -609,11 +679,14 @@ } var horizontalPaddingPx = Math.max(0, 16 * newHorizontalMargin); + var verticalPaddingPx = Math.max(0, 16 * newVerticalMargin); var horizontalMarginCss = ` body { box-sizing: border-box !important; padding-left: ${horizontalPaddingPx}px !important; padding-right: ${horizontalPaddingPx}px !important; + padding-top: ${verticalPaddingPx}px !important; + padding-bottom: ${verticalPaddingPx}px !important; } `; @@ -629,10 +702,22 @@ width: min(100%, calc(100% * var(--reader-image-size))) !important; max-width: 100% !important; height: auto !important; + display: block !important; + float: none !important; + margin-left: auto !important; + margin-right: auto !important; + object-fit: contain !important; + } + body p:has(> img:only-child), + body div:has(> img:only-child), + body figure { + text-align: center !important; } `; dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n"); + applyReaderImageAnchors(); + setTimeout(applyReaderImageAnchors, 80); setTimeout( function () { @@ -2170,6 +2255,7 @@ if (window.checkImagesForDiagnosis) { setTimeout(window.checkImagesForDiagnosis, 100); } + setTimeout(applyReaderImageAnchors, 80); }, }; diff --git a/app/src/main/assets/textures/classy_fabric.webp b/app/src/main/assets/textures/classy_fabric.webp new file mode 100644 index 0000000000000000000000000000000000000000..40c01e99c4a9d0c26c9384e3e126231865486142 GIT binary patch literal 1944 zcmV;J2WR+FNk&GH2LJ$9MM6+kP&goj2LJ$28UUREDrNv?06s}1jz%J)At4~DP*4d8 zX-pfTbW#tHP5peo{SYz%4F8kJ5bnRlc^LY~`yPrNuK&aH5z;408DU3LyKnG~G4^d} zS2OHa{BAgRJ{bXy-We zG}tdI#9++*5Mh;Q6uXHvSz|_RL;xUI_0I`hQ7*OlvuaCYu$#%xrN||&mkCnhzDd;0 zC?b96@+Bg-lqsx(2THIC24x1h42aF1qt?^@`drIJwUz-)lYjvJ`BJ?c15%`bVK%JF zBK)j>(e*mSj7<91)`}C-Rc2Z4&4Vwrp?x`>IFz>xG?*~JInqK%bwxlxIGy+uog4*A zF?Ce5Mtv)C$(BiM9ShAd0uo=&Or8G~2=K;Sx^UtrVWo?mH#c|DzQLIxWg343ddLk4 z4xC6gi4vq2#>t|*dRx9zjP+snKJ&uPHwvs&jU^sp0e#;bSR=jyH1(t09Afx@&lAQ< z#bG78d#=}~kHg=~5T+P92@5mg60J9kVR9z3PLbd8~*X^ zMcvlQt>amMm5iVz3Y*$lyC{-i?>9`Y!=K;s zOBVW2$zs+Ou)~a-em0mL;p!ZWMp;W;juKXWLC<3Yns#mP(-*>Fyf#P_TE+EYK@*i- z>vqP|*R0LP)FaWqU2eOLNhtstoom-6W^_S@e_tY*dC6j zAzNTjB6SoJMgby@m-y!FdVQ%@Izf!{Mpm0kTwaj82gqCgQcgS6=Mk67NAzM*l7B}8 zHNpDca>-8y#wMTgg7V2NB$2#Dyl^Fhrasr*D-7RImxJ|O)h48X zRaPhe1K1h!bQ6&frwEz)&WR zoG#oYDk!=LE*iP{F#ZT!$N?ETt1&x|bKE=ixf}*$tAzlSwX zqe|;?xT=_^4GFxRdP(zuat!-EH!kl`y88fU2u=!LMC4<>l@P(%fx&6|Ye@V{3GPqT z1XRf$Gl#1_L;uqK#q$LZeGuCcU{LPCqO(To^75)0qJFBJ5H-OymjcVJ-Nc#&vo3vQ z!0+3cv*COIP1iw`G41beb3FerrQET;ZsSD`3n+CbW-*>n&4#?xU4KBDqpz6&# zqZ#HOe2_?MGpNj>gLb5O+PU$Vs+7uQw^QAhJ7Eg*$pQ@V=FF*Cp)B>eVhEY=FjOl3 ztuf4NEFN+~=Oa}$age`_GGT^30A(zT(7xcz2z=PLLC$8<)L(6r+h0?oiU*rg&)cw7 zz3~vP<);itJeuVKV{jFG8r*YG*Cb(cc-kVIK5xgm%85~rjE}TsU1h4N>kqA@Gd@MB z4wd!`Y6)h%C1$4oWaKFJH7hzz67i}Cf;UGab=8~uA%ETV+6|3qp}^f@_=r;l6}osL z|3t*mo`4?X_hY^2)G@SYosDV2fsVj*fOo?sTHX8Cf|0-hI+vQ3&w=Ot=t!uvG=;xc e7g&mCD{SwJ3GmKjj?82GrttQja%{)~27my4kGk{# literal 0 HcmV?d00001 diff --git a/app/src/main/assets/textures/ep_naturalblack.webp b/app/src/main/assets/textures/ep_naturalblack.webp new file mode 100644 index 0000000000000000000000000000000000000000..eeda652ce9327033e73771a1b74017666b73f112 GIT binary patch literal 19270 zcmV(vK7yea*=`M>`E&-WF*pUm#5|J%R6;Q!S03-kYv z9@@YG-e)?msPzEh0-zU^SABopKkoYme|Z0K*9*~T6r>2s^1)~j&QUoD+d^937*3rF zy2%66so5<~Z z?)vD{Kx*I5f03kRpwGyk+aoZ2-H#qaDgS+fPXKbiCgA%@pzwf>ny!|jU9Mxh3SuUU zT;^oySpkn%0kp2e%_Tevp$a84s2yfS_YZ%WpFHw>GhmVA{%EQ3dtX{DRT+$Csm>>A zz(rh5FQja^cA^bveVAhGj&Ax8ZT5CNBM(w9Bc`EP@0;uo}Y_RH?U>h#jR3&-f(6-rU&eG^`6h&@n?r3uS%*r4{ZR(jwE+MIx(mAE$gZRjnZ)3)uK&Va7oI|+~ zb2AK&vBWk6zIAX9>WXW(?u}rDS1u}zp!}R{%W2zdZQDg3r;rwj?Me!B8hE&gv7zQK zsFZFhp0#>TRUUXE#8RN-HKu4Nr@!6uPPHgtV1u!v1ZbZgLXw!iNodA6&Z=FujRa`Ip76Vl!AM{~=LsMh@ht_E6@> z72nFXSPretxX@)XVGQL~8C9+rnEjM8EFwQGnBTZ)Oo|~B*k^Oi#GCrT$BW{BW#+0! zh&grtHjVYe0^wWNlZE_0qLN9KtPnWpFWhCgglTr@oszr!GWBT@BK|UUj_<)L2m@6C z=U*hqO^)&kUiX;h^OhvD9S?A7gC4&17rWmv3w$mo&nOZGnl zU&D}M0uURDQ{s6@y^J&tu^mw(OcD4v5-G{eczr@AB=__RHwHEmoO^|F$-<-ipNxSa zah2e|nk}DfHpuozz$m=u7Q8qPu8}d3Rol%rYp%v z1+VFloF5-*0TE>fM&mIU7ViakWC@q2*FGEiIilZlW}}mB{C)f*YFI03XyZURM3H2K zg)P^$@$A1*h!g;pXT1R`fts&Y*kxo601apIA^pD*+)3%~_p+0%zh)$HFq2Xv9jx9u;B=1X9e`HcY%2E+WQn1YKxm5Y z1r@9W2sA%qOO;0Wkc!@xK&Hc^3;>Fo^Z3QXa#PvfpVm!b7TM8F1`G}}78+*Wfueui zrPWFp`+u#qr1F2FWyqzRby)6AHd&IbfQFZ`UBx+Dl5Qt<=r*Mpc{F>nFeU}9N&{SN z#^%#s|4rb|+l#(XtkX-21o|Bng6brjH4nn? z^rfUxAXQBz^Ta+*Cr*Gw9U;1szv?LhBmUo~JNTo1ICcw78g?&m_fKev0!s<&C7$Pn zTP^LO&NRD-^eQ8gcD_Of)l=n^M=q|Y~7qL ztf_a;S`^`Im>HsO!H^t<`DcB%)}W`Ov0P8phR7{NK&ACa)o}GZj9c*=kP{{A;?^*{g*_5*%K^NZYjgqoRV&u3DY>7gZkAaSUE z$gh&ieNDY6l|Ch=!0FA-z*4s_DEYt-5{B*J-vo~NGgf}v!eO{g^vBa|rPm@)8_%0t zC%AX|ZxsiQ04Dww6UE!40lV&n%3wYH`Pu~^%i0hD(_B$K#c=vp&zEAb0eE<|FVJL3 z7-;-6`-WHWY&;UQfHWN_O~fpi1N9c9%EJt;URX|z-cj*=xS8>EjWNQr4J?WoyTtU1 z78G~MzD|tlfVck>alF;C?=Aa@c%~$&0HQ_w{-~oZxlF zm<%Q`%(Q|&>FqaTw5Cf;-hyIlOXduNa^50|?Hc%^-K2i>fIp-gWqCnLhX?0P^mt$R z$ZLN;x5k6LVL#IP%rh^XEuphM$EZ3v&u817;;mLx3K298VwEXU@itXlgOa5p&?(8F zi6R@=$fa@R+*?GoZgfhRY&3)khnd$=4e4#AGs%CZaqM@h+<<;&Pb)kc=`a4kz)w10 zlP?_K2!%G1U}vrB_J`A+zX2-a``ZMFQZf{hh*H(x%Y+o{Pjn}dEG)Rb&yGRF@VoRo zJ&R`PGNVU5Amvd+m=rXfZV}Uq&x%?kemZcSC4;U!PGI18yWoeGT;!DmZdYN2CUyV7 zuS{JKkf6?-yDxkjkK3O&hR%~xO}u;+lBYUBq$m9WeI@>Huf$-2td^;)_+d<<)bUV_ zYk#x7e&dY-{qgRjZ!wU3<5$GE05xx_^>_i(SZQc?rp9&0MsT%5{63(8)eXJV_V~vW zk(xO))#MCUExHfb*Q<0GEgF-ch7KzikYPL%>TZH+X`s36VLkm62SBlm*=`Nn74wDN zM0emvZ40k5?7x>M{%Pz?>%f;F%8udGxO+_v1E=d`v>llr|0pKw*tB%Ijyt@0+|kR` zi+eT+?hY>8?BRe1?atmD5j^zHe4fMX&Q*Fh-)z92yyNVL1{&;Si3i(mrqaqPIyKz{ zBkLS$2hXkhR@&t{ml$`!>aPTN%Xalp0ehyhotlV#3&u}|9Sj*zWhwQ&`rd=JXIv5R z^ZJ&W2!%+>28+?(qDj8wnp)2#Yp8nakUvNLMnVmMx-QIgX>(i4<`;1DyRVwk^5lBJ zz8RipjM?CEm%G@$t`cB>H%U+a8Rj3!-m(xs5mj^~&C(7D2%AVX%A5*1(Tne4vYjEH z=sY-eUzTMMLY}BFT%|k;+Zv#h#%j9YNqJZj_@B%tLum1dMZNB>#GNKzm~GKR*%iH_ z3X~V$<5v}>#cpisDtP9OnD{c`c8j}ulleKOJ4@IW%G44TIwTmo@+Qg3HGuc(v9Ra7 z`Ql9R@bLi5g?7sJfhna@^QA(=BB}E_wou$~i$l;OQV9&b%cDB0Q4 zYD0h!gGALTtm(eXox#F=400b~A80B1mutMIjJ%aDJ0y+Q-{gvI&|VxY;ti>eH$z>6 zdwj*(vtzqcBhUIb5^YN&bhb!l}TO>IjvVz5Y#M zg}~?hB?5PRF^AqGE-eBMiucrkC9Fu!yErCjh7Xs$$c7G}atNo!SP366pahtB4V*d_ z%+7}V(44^l=)wdjG|%X*Qto~v7Y~rb|B>geW67SdzRNSh*{3luJ-`rOoH6G$g9lV3 z4v(o$Rrw&{G^lIIj_s*7`QHMg6AY4w(27WNJq2k>+D-CN0z6%~ER*e7OdsJAV%rk? zHWM-wbVi6g{geR$8-K=gp7&qxC*bDcHLK4!p|{&jzofQSOb4yqyd<4Rl28ig8{?)LO=Yt)fFs#*){tfF4%~uu5_`Q??Yq7XS83JTv@P9)VUbT~ z;Hi#g!|LEo9#-BVOZnzw(g?}~)b$U01X-s^rhBGvKH;jueBB{Po{jE&5LS`L|7%Aa2Mh7_5y+DlCh-&chbZdJH#!V}F`#hh%@#W_VEx;)v%xn?R0355(n{>>QsKGx56~9NX4ajg z1_??4lAMJ_b?m}~@%q2xKN#$E?p7oGxSpjV(L`c9b~FI<=h9<(KGwi)J}Q1+HBo98 zuI!T4J#4!1_3dRI0c?O%LwaBQm>N|8x41-MyEy$0DI0$_BgS=r*FcKUGa?56RZGW| z^GYdp>J#Q%;~ggq)o1$Cw*BPy0OH|w(OBnVsik}m0YPn{n%lo;-kVp3!LoTx9x-Yn z75i(YJPWJi+Jl46VQVtu)`JOX$VOc=i@w=mXsZ1dI7)7v1nJ2X26$ll03{CCCnM)A z;>d#+K#3@kW?75`&4+c*>|JdcRkI4vINJKMl%82sHBPf*jnAv$!~l+)fWCMW3*q3< z8PCWXp;T&|?B9;3*2jfW4aPHbJLbw35`cbe&c@k7QY1k^xve)MNTX#__7U#%4pVdbYP6(p>OX6wtd5YIQy z4L|g76+77m7R2tR^U+f4S{;oov3_6F$DBA;lCy^FPRctggb;=(c4GJ9HaF{EVANT~ zR@@{Hu@h)u(mrKzpN{U8^6(%Yjy8Vmxwnv+VFW&IaBDZ!i<~hBVV+%O8bS(8|0t)o zZ;SRjdfx3@3OmC=v0sIJ5o%WC$KVIljPgQ1E2vT$?sPO!6u5*4krSGEs z$s_H@p<5;?9BD|Z8kYGIwduPB^=^I<-%EIrWTH(&WW>`|P)pbVBFy4DJ@WM3Sl;gE zr8`)f?6BNr3HX?U{V*Gz732zh7R~|Xuy&!3y-yNzMzIx6h@bl z?!EXyn2>?;@GGLySA}A{qVe3cyzozugG+Tip#?j`4v6>#oE}e=6vQ>}alc07Z0`Kq zW|cy8>8*6JqL1x>O>V*sth-3Akk3tj$V)Bl@GG@xT_LBB_EV5l7XY$}8))6-0w1p51Y z3{{!h4yDyKD%gSqoB)nqEym^i+Is-x;_u(4 zJ&Bj+GFV12T_hjab6yKzxJnb)KbUL#Xt0zUL4&*T`JKkg8VjTBeBByy(_>wUm+;Yq?*3#LA z9SO2=c0fN;Li`-aygLeV=ur$Qq+HalxNWz%bImhm4)R8J(%E|J1IJM}IsSBL^RL`o z)o~)fZQpPNM|^!{Z>dW6sl{pQ(9)8FYbavFS(NG6py9GAV%P440;N*DTZt=1LKQ7O z*29VX!#81gFJiH>Tcz5mwUM2%_1EE$If|04G9*|9)6NgK zl%;|rluN6=LbmEGLcn`!aAI8MSWKLls8j&}Ok#d|fWPyD1igR@*F%go>yV znPCKoz(N`EV?vL69!&3^k~%4OAB)YHP}cXsp~86JMf3hPGYtlT?Hl=~PKtip1929C zUH|Oi*7099I$k*o%M1;d9n*&1k_Kn2Wdha zn2%gV+g@k`Jf@=qfICdSB*`Y+8K9{HF-i&ISb>(MqH#uS>weyxHBz{x3D;_aK1(4k zH!-%hJQ+*ReAbaPrbZyO<(o^_J`IHsL$GY{#%@}0VGX$ln(#<7PFYCqjnPY3fz5J( zZ~FFKU-wAbVU8!+$Gk6aGf|gz9#RpziT8{YuB0tc>OmRb{l#m%<)yMt^^2{qq z^WJVzu6c3njBp$zgjQXu;>RBQ0s{oNT6`zCW(<{aGDe)M8>77dWl;cj5Y%h=R^HE* zmC7i-a-D3>r;{tH!<4L!0Lu$Z=lo6pLaZdQ(E{k6JA7jzJj{TpEwt<^O)kQ)LbKJ9 zP&M?7JAFGQCzmGc+Ucnbwu!)xsvi_78*|}%on$Z5WW4r;?h7M*$FmB7o2h@ z@aRY+Q`^`|@kbMG-)X?L(bPkVg|E zs_^m22LN(BwgPg~rN_XoK5X+qaKkwW?4bxUC9!}Ra(VFpHNulo-ZI*^5?B5ych9+V z!ryS1LlEAHiJnsx<9Cs(6zy(;o&ZK7tlcH*BKh;KCI7TTpi~&IV-CQa?=7qHJ(IA4 zrRgFxUBOAkwq_Gse6GR^FeYDc_$3wD7`^`)W8|QR%kgAekhW1MB77BU>Y;z8sRi#@ zVEOJDVR^>+?By9Vu(K0P_yFS1DhqtTBj_@K%cL{eCT_K$+KME_N0vqJ>mS-Pf5Jpe zvUqnY_y+z@??&e7xU<-5s?rx}yeSD%#9$e`%bI9+ygHdyR`_M0!RS-`Hx_9Max7Qy zjWeY8qv$sQ@T~&gVF{#8BW3T7&Z4%lX;}`|{^4-`gL28b5(EJ2kz+iA$R5^)Z6x_EPrscO1xE`5KMPY zkZC698JIMTZ_jCd#GIJ;DU=v9dv=>O7TM{sbDoOI7*GRMG!SnHGs!gi-jFpWWRC22 zmlN>Z-$@CWkJDUwBG(8)>w3ioCYhPEBlQdr=7x+rJ++15$j{mviKmzexD{iBjKFH? zsyS>*lZba7|9kmx5SJx`r_H&Evy{CcRvk!G6y#c8q8;TLlN)g@>+KX~Quu%Sa>bdn z;5&vmOafSl;hX1Z-)IH;fzyU(#B|*!8$O6N)LKP@i71Jmf69@IF}g3TmElX*t_3kw zRaaf~oFAz1m2vJar7H>B3D4Id{mD!@f>0GY{Cp+g8yuIZ>4Dj!GkjXnSlKF~8wLwZ zYH~4dx$2{hGTgy`ximmhU}UeIT(2 zG-ns$kB`l|j#@jp z=*_!@{@9lWBe@wlOkgp|WtWO2Nn=C=&B^=-DJnf2FbosrOns2Dd!%7^--B{RfzU44 z27KMnMWm0E(sY7t>vB-4*b4^py(;!$Dfb% zJD6LocG&BpE9y`ng&%dhC0QL~gC zV*pY{AHD?0wRu(8(-w+ah-hI$!=H5$ zjy*VZiy_4eVv=>(sYDRAe_vD@_)YiOX%7CoDRI=_AiU z_S4b7%G}CGQd=A!+^%0&V0g%QKVcys!E)P$aD_jHrX;murWM30AS z0wRu3D4rG`dB3vIXL7E`UDK zDp5FI=6da#0|Rejdan{Yl2#taz&FQai?7cQ1>tN^`_9y#9>Zl!y5I4ySgzfY2U)Yi z&JGo)iVm9`f>w{*Qz^keOAz0m7JM0o5z`w=%&(bGodk9u+(XQV6RE?7T(oh0_iY!% zKe2Z{j2)QpGgezme0Ui`6^_i$=S}pOJkR~Bu?1(F=L&a}6U*Q{pilu49}xbSOKzvl z_9%QJ=tJar5!|1)by=23;-1=;Y3B92F3re3+1~KV7=pKB9kLaVCDC*zmD1o+zp3H< z5u2#DO$jHqTMEePt8kMZ*2V%-N_255fbRyALV~+?|7*W4aYO>t(#1=Bx2PvqR#^x< zR}j7`QZ!>>7v#jSRC29mW}+9|-Q&x~IZsk8COi*^GHbR+4hex1=mJK}3&M+nbj!QQHo?fN zUD3`lug|Hcio`sf=$!n4P2p074AsN^uvrB&{sGeFKl)&h4gwIAixh<#Gd5xhd9&Fn zKP=Ox*jMC~twA3XH5Zzr)G^72d%=gj-f6ow5?QAkjyas5=0J9_lb9(eVe&6r+DJQb z7{@V`k0SBHplFAX9{j1A#BI2R(aNaOFX3@ox^WW;FNiWou~zs`CnX@(s8a^Xf0i*v z9g?O?zPsqzj6>;Nli@g6BK~xG-lvz8e1K)6E`P}U3o?!O4DHE2-8%pa>V=2j@Ms=! z3>$OWZ^;LBTyfeKpys=4{Wqlaz&WPLa~6(pM3E^geS{h)=JY!Wf56?%5gMy=^}Jfc z!GMDJEi| zcFb;2oZ;$!=(49R<4b1@Z5N=x zH6foVf+n^K^nzDN=e$QfVmj8nx$6w#H?1A;ZhS8o(#Zyn*s-C6sLzkn+0^6c{Yfij z@vT1Y6C$?{UxN=gjwLRTV7Mv4RI!==8O{ab-InuxjTju(DrYXqjDjnnI8NM4w6LXZ zwZYNEJOZ2bpS@rPc$+E#u`*PE2T1bMKxH*C2s!wm03p)xp90cW?BBk2hdIWzxhPwL zN-~NKy9bkiJU^{jp_Lp$lx%H7`u+&@!=lltN@d5HITK@=%n20<05YQ5u?G+Q6Ro*5 zl-sE+gK0W6gG^F4XU9uG;+vcxIR`|KZF+`?#mVqF*_N+K?M3MPcKG}$BZ17bI81}q za6pNUM7%SfM^Jio_5W=NDQCEf_(ZsH_MwF4CO@i|!%{{OPi$34>J^%*KJ0knx3MTi zHPl;F*xCuQTwq4=EN=8{UwpdmTZn*HH8Ri*v=EXm+1*m7H<S}B|qMr!{pvIli28qhT?vvp$VB;M2KKMPLB!6)DJBJAS6ux}FN*j(cTr@C@3HZRX zd7TyxuvKH3HcDLQFAA?uGK7!$(sl)o2?JK}+A=Ns@4bY3GZMA%XM)_2(?W~JZEAN& zZem`;uhX-*^+sT$qMM4Kdzk(uhG`=k=FDceTPSA9^!-iV2Nt;{SL`mBF|sBO7^~s* zvC+iX6_pjsj>wh8$*77Kw08Yk-<>odg>-AKt3p;h#hoAvMb%5D&LX-JvxK^S2t7{F z(F>sHBr|cAEDZih1S;i*wR)s}two14LN-Y9rGR|3&3&ifw>)3~0tFkkZLy(Q1WKoJ z(^>i7TN(J;%LzN{Hl2@Fm%)#gnpMrGUaTqIOUzxacw>`;b0ur2vLJ9%;rET59o7fa zwB!%XcU4U;$(L#Tb3zUOzOF!|!kwsq^L+&@*CaWTm-*ifD)LBv^`s0`MORf{uhy?p zDeyw>k!~9^(j9ad6km3?_Fl(Q7&RS!+GXUCzbK@{&8bZso{>uPe4Ri|(#EH;&i=F@ zv7?4wHP*1?Uut-}?XkS3c8ldln=m!_r^WAatIuXlZ{*&uYq-^vpc}jZSR_{CvX5-< zpUtbF%*3iDl5HG>s0CO*E9yG`A*8ne8v4hYy_gNHD9`Wa4`tb8-x<|Lw1=ZiQH1?U z;hjm;j@`;cIZKjQbNJzgbl&(HKxEz-wqM!R%u#Xt zzRx#9FcmZ7DQJ&gmc2P?T*9_g<<|!9funebLsKUCnsdF;e1Yzzxi^+-@Kx+$+M_Zu zQ{zR?Kgv}|^I!89KKLA!9o3J?swL~-DH}x0;TS8Hx>%5s1!FghU7XV}FJj}?xL%2CTWuNE0z1O%B($*3t-Tzb8 zQ0)e?5^mrKUyr{1y_?G_Q%vOR?A9CrGl=FpzI&Wq?Ph%VrL>>vC**rOD8*`DjB?-< zm9Cwb0uY@zDRhyB_7ScCpOsryylTRtxZgAXAIRU{N!#%1`(UrU5$5AEe=m8iz8$S_ zs;g~=E4L$9aBFib`Q*cC$&PJJaYTLG-*Zc7itTUrTuQqHIw(hvVE^^}=}0?sMxq?GaNFoqzVa<05r)?Hi8+QV;bs&XQLSqY39u#b>m!#{|@6 zO8qS~NyCW+reZ#BlWI46dE(L>-&gACOqoetgmZ3emYAXNpr(QGgy;`UMGe(8`lmA8 zzn@c}CH9N;HW$u`7Wq~J+||8K$e`3G(KRb!K8BFl8hWJFlBg8JaifaDwfh@PfP^sH zn71JGdDg&*G~HBC1li=U(8O!W>a9B8u)@P{K)QQVbwAiTQCzl^0|oR=e#gDs65?T( z%H*CbS((w31hJ!pLcUgHW4@twEF8n*!Tp%*gL<|><@FS$bh93bn(d;iN*U9i2fTLzn z9vfr!W1_~VJ!0%lXpyD=wZHy4+;Kq*Axvh5rVD3q#G1u@N4ncr3$pamTXxnwSDZ9nu2?$&2PFVjmora^P#2h{? z*Vn`}&Z|%aB0$I2+t@N$s?w%d=t{Or(V`5iDiitOL#38Q{jeh{mFh8=+6=v64zNsc zZOlYU3ci|Nz7c4!C^^9Bp64cch-O|G5-%h0&z2N;mI5<`w*Ij5BB?M;z z)xydZGdAc<)VAN<1F|p$^%}}qY!m+M5To_*4C3Y=tBb#JSun17s7M)WmHGW9IoSy6 z%AtV=KwbPTY!e>2p|5Vu2r25e>**}*>%;g z^Y-%DkI4&P)~Y)S2AM!KU@AI|YZ+i!Ziny*jSFw`LZm2%ILxL$fL;F>V^Ia2s;J>f zAMHV3A((o5J0LAeZGBfjz{_7#h&iEipqT8s1lS0#=ojwR^)8^qIT5^?)NYFpOr;Q6 zP6hnht7kHs-5QQzPC8%sVFn3VItd(c#}nL-nB(GLua`9F?*{Av%W9gOYYDg)s485ir z3VvZCGx1|G5iGii@qW#eOU9f2se-Zdu9pVIWUWb$BlH|G*VIhsZfJlQ?07b6+6pkv zjREp^jn&EpYisVT|Gxsoq^c=QqIx5 z)}W;ERd-C&3`p*=OJvsZ2+{y6{V7v;E$;}Qem97GV#ptUZKL1TEpyQHUk@cAXk^EP zE6;CPDOHrXQe{ARXjU7qyD})>1#YChq#b3&((4uWJ{Qzs=$d1GMCi44{YW}h5(b#n zga5xVu0wkLlS7UPJ72l7{iW>d2NhoYGS1cHR2ulu^p+w{HakjZrL^b@q3A!Yp!-X5 zZltGoMJP4Bhy=wBPIY>>$WDM&&%bwyiRtd=4%*sEyAyduEzOIVPzJV`s(~iRV;p?} zqj@FMaSFp)Zu*h@y3CVFvropDCt+)0gkm}PaV-6MXa|JIv%R2{N)8qJYOx?s3Iy0XIvIV?&c_0vx6)(1YQAy)? zvsrhxZ>n&bS0xr&qYnuKgJCoW%754%7P!LohtoT&kkS5}vVddUFpy8-@CHmCy4ExJ z56@+jD&qRAPTsX@^1_Q_)KRA6peAL^%^oGjw8I{Qssi`cUx9IECu~*G;Xc^tEBiPZf(QR-dfRP=)&t*&k`l+l+s0GlA%rYm4d9!4 zRG>^G$$ZK%RK!yD2`^uz@f)z>REAtP4Z%X%MO=64o{|x5JVR}+MG8y6gXP6(3`YQ_ zX?6EnP)StiMi8;61P(*RyvFD`moQjJSG^fZ`{!mRHn2ffI#2l*%Ds7$@N3oQ^Fy#`RIPh-`6vFBOip87@j zdf#6Cwg5NBveF2Z9f<=X^GnlyjYyV5KdJ?ex_Y@}H zr=o(vd&NRX{Q?BTo1O^#i1c(s0O`Lh=;B=PoJy!ngssa!O!+uI*pm}x2K*$q^sSmY zx^dBLu@_V7v03Km{t5%i`XAPg_MDY`?MH^D=*#%28613uqwd+*1*g+(@(D@IiEqmr z-LUPGMP~shVvL+z^qycj&$0L@TB?8hT5B9?D_0AJUXgWbvl6`A5cBp7gb8a(G-6;u zvUh>KRn*XC{>*yF>Ed^A$6@}|>YJIVpX=e`clhJ}OZ62@!NOWvpa&ANlQ*jdOz#)u zGF8?m-?=_#J8PJa96i%54%-DL{_YQ`lYBN0D#I{8jmoBJ&xhBRg9Ap#ihY?(*> z(V{axbUHsJso1_p#;?hqi|qz?^#yZ9SIP5_L|Y|3%bpm4Wsz#sEj#^elUmGZ`G-vJEVM>|;%@rVt4+Wd+8 zlh8qx%YwkxRp`8EbJy7HnM5m25uzVfHI{%%`F-@x7$C*O0Tf3yJ(pM=q9vENzJXMu zds3zX4)iSITLY=E5z9W%(>j+^4|<4`{TEZ`&9!M0vJM=muXBtgha==uw{XX(Kp@SF zJG$$AjovZ6F(X2 zLl_ZpeS#q*Oke!h4E_>$mv%3?(1N5?Fgj8=Y$9%pEhF)xfQLdaDbbzNY%zGSol|_Q zj)3g$K#TUC@dj-vN7!rPqRck3&A*nFOL}vwVzwq9^rXY8I~W3E(|C^E1|YkF8LXG& z+HC#4{Ji=e+J#fRw~3AmzwXcTvn8WD4pR5!D}{R1Adqz~knJxyEKSNO%i%C%R)UN& z+gp0v3xra((*#^D6Vq5!TAeO`Cv`XZ(zPmq4ka|~)|@{xwEXBEO3t2gdOL?(06ad( ztHo-uiV8B5eO-BK#deBO98Fx*h|}FUrt55qqMDY6UQzSN7+OD-@3pEw$eMJo@FE?& z%&I;d|A=kR@UQAZVCgPcNBmmY7$Q_bz6%2e1{eSQ5*b1UtO^*Yhs%7s+idbv49Lhs zItsE%Y4DL8%E*X=25(ke#`i_@T40>nSzHj78?-EBRyiqtUFU+${L@BtvM&k2y;Pyt zM)okpy%W+wrM;|-oFU&L5dmmm_DtLKS*fxlAotzOHeqR{xN4{gmPCi9ETlnDb@FHoZG!kaVk~& zA7f`+d|0qVqOnwnWa&* zubKYdb!t=QY`a7Wd`UTc!Q>xJ#^~s+z5rNE!B}03*jIjMw&V7 z@!6ffV%6F`gmf_Mkuy8Y2*o>=1Hi1eXxq)zR(_%!HQ2slG+d(2!Zxt)g-`Gy%F?=8B?(uZdj|oHH_K& zMGO>=s**W8f)4C>S8r?xQ&V1eh$iwi`)MkgH-`m1ZZ2546Y)1+O zUh{4Xd=PW!(7>xPuA9#bY#)HWId7Imn~N|rSFs5rWjqdgL=IV4Z8E3v!Xx^%)tLCF zWzO*V2pL{;e+gmN-=f{<4MXQ&wbRF7Ga`&*yBVbq;K)d!9(+8l(H|kzJk5%=;tRg% zDwB$woaXZ+aag%XI?=Iu$k?R7pO(0;Kqzlf6%BbpH@l~37%gKQNliM}PT^L;zn+YZ zzFkydftYPVEW)78wp`zGK$#;jVRPy$)}e!oXP|)4FkX2+s?S(3Y)B&nEx>U|2&=cb z^sw{}Sef*hF0V$Y<=X2E9djlE^hE<>o6DasGsfCAvZE`fmU zVU7DBS=ITxnAgx)vjQ+DT{*(;qnXsT<#~p>t+3|+ zXrJBE#pFcHnqMnIf+wwf)B(_LP7nMwNb&)M#%ZQUN8#s*I`i-uCa2UFDsUNSMdzu> zK)N*w?ZD8TSriDBLzs&QpkNj8HL6-Pl0Rm+GgXY@hR9Zh7;E0yE~tVUPP{}7-a(o1 zi5s8~b5;*DDuxT1-HYX}ls%PJ{4#wTCtV6cHC{)WZ`AL-CSKxv%ew2p0`maQ_U(8y&Q5C}6Kr!%Ke&e$XLXXg(N-KUEYDy4 zMe%JkbH)z*{c}F#P)A0yufhmSaes$JPK?_i6FEVqJeQ?Kb0X<`Jh;b+sF>FwjN5VU zyPEgFOB=h5*H&kdg}dFmtHe5FRq`2t&O)KJT5L;O$wI<2A1FVFvz2jKeOy#sXGFG2?Nm}Sq ztw)Lm>qt&+9kmi@V7q1ruUaajhTMDT#Y5?YMatbZZ&v>*z{qi?-LW{uhlf{D{1Q6P zAGMV21gKLBH}r`>dEKI3uACav;g6#X+O9VZ{~i46lY1I8x~0fH(}y%wRgmft);;7H53UE`u3mTOw!ZQt z;6?BvJa<_UaKVI6VCzg4fQmXXy1dA2dALNE8M}n4wCqLn(0I>dtD`(+B-C#T}ml& z8mNDzm9;KH%ncedzp|xthS84}Pdv%F0>GjFnVB>xDbfiRSL#Ox%|&k< zH%PMCi?1YQ;T<2*>na`joPNIqL#3ocnebquWIY7>INkf2W+~o0DkGOVICv>Uu*N(> zfn5eDEwuhY@`K)Sw8hxYKs>3^LB1e>JBwJ+c36Jgw-*qxelQcl_f^SwHO7zYoPL&V zkc?2zP7uq$Y@AB|mf32h;Lp6GNfnfzAmpVGNh3l20Wk~C^zpH_f}pkY{O@_=^r)W{ zUGdt6<~7iqi6ghw1T%WOKA2Sya$Oe{&Z-;@PwhkpgXjgQowMhi%=v&=2VA*jUc!n^ zDtThEB|S{+g~s*SZujyt)8-!B9@(&-8@Vx@EO(wkssG zB&gu7(ki^^@@2&mr~?P zDl+^##?5*%X%Y z+1HP=8KQJzytsYJnVT^)zC=4ZG!*(x#jWAg>iGuUpX3^PwtBgNvgI5Fh@^(T=^0XL z6~!ZPSnS3>{sVN%OK}mdtCFc|Od{S_AG9}7 zo$IT=dYSPi?g@kYcJ*knlR3Qr_Mu0$)(!x@^)NrUhp(goC3}@-QKgfl0c;fUW|O`V zAT|y-tdKYm)YRq8D&dVjD_GFx?U#}$g_l>hZkFNzq}{u71{qx* zAFp5Cd_e|=8i()#$6b*XhVbFPVnnu~vr)_iEvfoX;SFto6qjC_I*05F+cBqH>X$8+ z!+?Vy;o0hE_d~jK6NAN{^S8g@7_tY~MpfTmzk5jGOlc3sUi8kLefFe+dgc4z_!8O$ z=O#-PT_wGknS|$dHua@(+7z2^kD!l5t%TR%B>TeCpKg7;PhjWhS>>n|klZGsBib|f z@`erRwl1)@NvhgcMoDlGj956laF)xD%R5WO>z3mh{6>ks@=`a4aacsq; zCQV$B8BxnOrwFqc^}cY)r1b#XLQxzE1U4fXQAlU-AFzaXd4eMO(@>nIx~*+hqU}uh zX@b_ULWcTFm8F^E)hArmym7eIgqOrX1a6={r0(h(vS+alr;hHE8+n>BNuQc6A@nunN}2nvb1hz5Ef?KF@~ScA zzS>y-IppyO{2!Gua(T^^9z!(8!C{1*Zg9OqBq_@DTRahtJUu6}P1NO+z%PxHW6F;A z8WNK7z-h~wKYX@I^wq)=(z5uwvzza8LBG6dU*j^+7vfYqMR-txDR07ggYr_OL(qC%e z4io6FAP}dg%sAp;%DWU-;Nsz=YTA(swnoad%%e4NaY>muRvN8lgS^zW83gpQPm7Sg z$UzM6SwE<(r|+LaaH9CYU6GHF?Q>af-L?AKv&&(ezVJarz&7+dWEpN?Wp6JwIna8+xw%TU;0u%XhYc6 z3bZav=`L`z#*i{(AK8V2jM$#8eQQPLwp36e?nhj2?X-FyJS5P~thMmXE_ed(b2DqR zcNHK$fcl9JfsHmT4hf^1gwUOLCJTA;J;Z`E_|L|8-l*or@!Qd#T;k8!AvT%x6$2iA z!dmuaD#&Lu3?)>0h`KjJrRG(B>8!#~Jctr?+aIoY;!KjYRaS_twa6{cM~KJvUoKxm zLYw(_1yM0`RUUs7&sWogB*>16L zkxHi*OdMs?!b!$>J=9+|uAChw`8)TMF1KB3k@F=t>@~no#35!m-pKJg42=Uzz^IpOUc=WTReDl z2(JkndBQ4?yHKPi{C-X8Xcv51%`z*cI)n##NhN+kglj}~Ae+)zb)eAE?Z7=lyyj(L zeOJb?h77cf({t(E&r>O^=sMBAXsHkcq{1FcWO^H&5zNA;@3Z5U zPi2r8z-iu0s6Y6IWDMW8IS=DlqGl{w4$MG!g~rRCt5=`nn^jG&A%`1pR~h$f+>R)K z4#J7;AjiK~Pf{hb$a;Kr)-7k(5@T1yoiL9X;d>+`57)n@I< z%|C9~D8uj`ocWdI$y^Ehb|?000%Fs}BGG literal 0 HcmV?d00001 diff --git a/app/src/main/assets/textures/ep_naturalwhite.webp b/app/src/main/assets/textures/ep_naturalwhite.webp new file mode 100644 index 0000000000000000000000000000000000000000..050f115dfc6e9f5454bf3194c869097ba1100380 GIT binary patch literal 15394 zcmV+-Jl(@mNk&E*JOBV!MM6+kP&gnCJOBW2!T_BCDv$w?0X|72jz%J)At54?Nw`o6 z329+=me+qOyoVX94-h7R&C+`GX7T>~A3z=*S@ioippKndS;R{=oGS#hSUHp2{K;?Ks);Sd@%cU;Jf08qPZN_Spcs5$Czb{z8QzGVFsK~WlQ^*il zNG^BUkfKyE%|{|W5sgkFW?zB!t-t#NK6Xm$rY?gYD+W^YAjT{Yb~xleVm7K*E3@@& zs!ZHkHxA~D|6Hmr?*(rape6;f@B4epMoINe?#3H2Ti|Y*Npj1k91a2}vDiJbDqxPb zSUZX;oLM&I0e=!H7jBOmbGFY^h^5@5g^ji1^tRlF+CsS!;Dq=Po1`6drS`$*Nt}{S zF%qGG>pM08LN^v#(y5Pae3aD_KIwM8BpZ{4T$~gYL8wb(1kpMVGt;!nFzH7lLfc2q4N^H-uzs$l++zv4 zR|=ZFV$*^ga*fEV%|YDR7k$rcFA$ScB9cV(f|Z$VdU zQ4M$Gk+L-}6%9z{Z~sH-?Y;W4W3uKAixS(|_U}p~A)lc;Aegg5w>Ztm69m_`tcdq( zY(*?}e`G1H`q13yH@Zm!Mjs!WIsegHRC(ox%`_!1|8Lb9X{igc4z`U>2t@eKPNr ztpy3X{hn7)DQEwiVOS0~gWmxwmRiO+)=g}O%_xzErlRRA``S1L7RV6$;Gs`ZX^T zeM#PR$g%yz6D1k}?y7HlE`Sf#EpF8U;Of*PD|AC4H@P9SDT6xkMG3Wwrl`eV;SssR zy23&}V<$BHU@Z7t^vMD&4X8y7uC0)xv7~WBBKyKTq+}W5e{8D+9ExFz5V?dY6P4J& zM%o6eynq#sV*l1XxCpN3cWAl_Qdtbv+K&!Ow@R$4p8$W<7(L0gt;dvRCEigTNVieq zZ8R-jKR)}o9B)6^%^rP!vZ+I)=Cy&HfUAar(FNn70NSU6ne#7(7049TxY{ER`(7UV> z)c!V@Vq-xPgX~`|oYGTW7$rK~q<|s#Ua2*&EGs4o79zgE+J?8a6)Kn8Qn2Pl!Z@1oS@WSXuRMCou(y z+%?Y!k=m6+irV@HsFQ^*GsDPQ1;&J&Bfb-e6s`aO{{1xOY+1OjyJmMu`$VS)+zoJNxi`V8 zCnkFl4F=3ah1ck!X$^s}@@EPssSoPI)?rzH-CZT??w2_1Jk_1c6k7zf;*rh|^PajI zEg?FhDVE8{)WFBBw*^=F7%#Y5a%Q<{FIS~7_xMoL94)dFtR;`13Gdhxr< z6EccyM8>M8vvm(y5b>)IN;nmP8vf2h{UtH)o=%RA3qD?tD^+KfEqZ!*h+~XN5GOe4 zvA+fxBIbxhc?Y;~UgrnqV^xzp!1I4rf)@oCQycu(c3T+{I~uu3IuX>>%mbWMsh5RFa%{+;nI*)LKMpXGy5#i z+NIEY2*4TrzRnB;7VSKyTd$@O^GMtvO;s$_Z2_VAn*X{?U|-&c<*aTDC0cn$$1$&P zf4xWL@;3qq5*d7#w8am5FlZH?_rrF5HI2BPqu28-U8Ym*H8F5OdI+PM5dWsCK9C5a zskPd1HjVZ}2#nmuIBl2J=iN8)!=L_`sQlfuAspvfrUM@K5gQmqklv}hOj*_B1e4S$3NRug+S z8Z!JnqFxE{vniwbleMePY9aRO1Ljg!D&sr5usDEuRXmc2nL!1t z=&q$g4_`<-6;hMIe-=aRGrcZqHsD5Eih_lI|-DU2&dV@BmppkQEPF|wQr4KKstt7(u1Ua^KG*tYb z20JInK-|cEkn4-p2OPJYpR-7Ul>KVY3(c>t{eH&kIr^(_?v)*@W{u4^l{tA(9o^5X zsTs7TB6ID`OaaNom2x>)-mFf|S#-3LGmjnI03sfDe%d0Y5~)CEfpEn?uPvtlg~jC8 zK0o)mU^df+gIs-khb{^JCtD0bYC{5#=+KgnP-t{~KUrnca0@g9RLM6&9iK}TVmUs~ z@~~ecPUz5DJ2?Ss;yO(<`2Dqd2%U9aRVS&)%Pq0hFMl7n)HFcRFNk^-e%0JuZjVeT zCsiuLeYqTv8xi~~)8Ecyd?I`XG|*FHs~8V0Z0HJE+heRoj<`ZkZnYV9i~?V7tJCkQ zZhN;J4ijsCtfg=!4j-xv%D{Z#XCzr$lhgQ#D?F&L1Xg{DqldB0KAZf*n68a;#uyQ( z6!nPkCK+CG}9AS~}v`7p?9VUu))SngZ+3}}|W+_i!sMqWe= zli2S_BumhRXHG&gq%rbg*KY?J!oVvUTh@9YP#3A0ACP_4HgdT@(MXFR+4_>%()Ni) z?taxqJOU6?@9y^1*T^LR6FJwouD`V`dehUNThfib!PM!Qr&!{6Cw8zzM4s+H$rA{spO~N<*Zt#P=7NbM7}~SU!%FbT z(35`$AfWl94Yb`7C>1x$Kc{fNKR;haCfDk-s%?+DHu(U(4-G3NXm|D!QMGvb076>%4q@5u z5libc$dK$CZBTOXVG3J%nn<#mYyiY_@`c!;Tnr&fUFBze#t3;?MinKXEeGpmD>AA4 z{=!)j%CJ(uMA_<=f5rPY&5(%A`VK%AN$dG+!_U?GZN>5C7b-l22~51a=BAPR6nBZ- z^`k`=9DV*5Y`iC?djhA4^p5??eX^miQbApo&=#{8JjkdfZR(9mlK zBWX!+yXa$sgg*}tLVmBxsvn9JVvT7Lxh=GSbbIYqnqXc|j)R+2`1mo!Ti4|+fti6^ zCyiyO-<@(&Tu;;p#I@c>WaG-HA6tuQuzjy4JStHqO^USWUaz>&gB1*XYGH>JX(Jq) zOd~7IA~Q%rFwYUl_a=R$SKAb&&GbnVGcI1tO6m{%`Rjqr?KX-`J442mEL}}TrogB+ zNp>D{2W-1bbvVU-tUVWmX3SWV;d)*2Td_%@kqWoJDbnw+>8oG$tY;99nPTJ2NO7(t zBJ;>J;4{GcxSnkt+wABn0OuyWOBVNB~|BIR3IgR-~b^(u4{M8lMZ&W zs5>|7RXnu7{@buqT;-(XEA4qw>u>Yc&!`9+G_i7D((0|hL;z)TD*aW#V{xC#Po3Gy zVPC?nm1){NNZi)sM<+^;2c#01wxUrh^pGBdbfWCE2x(GUz`StidBI3gTSI4-pLTAp z&4xGcXQ*V@nuDuU!V_7RAV`OuydSgehv&iNpWf>-?Lc2(N_+L{1LuvJpV!64=u{RV zdBQh_d!37n08MT&7o3d+<-ru->BEnGGn_5+l**@B9vMl+F+3hyIpO8S_enD3Am!9xxse8lt=QHs zaut@s#YPHC9^3ruqK6u%bSy+jEM#K&;M$X5(pbzE9>C_pWc46%OT|=A_;6RTo?0H$QJ95cZ1IAMM9_^cHoMq4Q& z9f(^9xe4{4n^-)idlv#c9Qi;c{6+-`%%aeQIQ;#tn5qqtoqAv`Q88~ zY@r|oAS0cTHW@b1VKU)uKmX(#W5pc2;8i82Ieht@+dT2C$ToRLAcyN7F!Sh~fePCM zJ6BnIqGPR)6fZx7fQN$GX!7&kwZwu@q)z1L&>kXq+2WI|A>vQS4@dN2lht|7&&r`U z1ihP8ne#;f9RnI*pS+bZ-r7$tkzs#V?(@G+T|x?rzfMY%PdSH>Ocz)r2D=w;y(!Tvj zO4lvNm7-*?N)%GK3{u`(*(-m!1ZWjH$~2U8=r;De^-DXciD~|jG66sYS6PQ|a3E*G z_m0_?XpyT4ih&`uY~2zX)TyonmY~b7L;$snZc~>K63-zYgtZ|Vw#V;M@kF#5R1>^Y zq>e08HUxVa=3VA7^o1zqiw0yI&DW9Bha+jCVKRw4vcB&n^#_A7)H6>!Ubur5jv*40 zvDVEs5^!O{YdkdY;e@T?q|z`*^UTMzuw4?Gj{UkMgJL|ME|RYS!MdE zl|x3$t^uY4#))Ns(EVbL`k)<2CwZyI5tQskx5=Q(9552m8+WT{fdqDXZvsB_D|RqT zk-Vvg?S0pa7-Z%5Yq+9Wz*wRSEJ_7)5MJTg2F^!{8erZ43wvo4;6=*u1B%XXPp(x2Ze`πrtz_XRejDELRE-a)?zPT&J;-8eWA^ zeW7Jnej!xPX;PuV0rZ-01abQ4mxg~jqo^ij|Ifl=ZI{`TdhWuLLiS|oW;c^4DHDTa z2%eNqN$FqS{hEhAVaZJ1G$f{~`hB-${M^$!mElAI{YWl3#@iMV}kr3LMpdGeO=W z`KULR7#lL;JN#w_h%l~zBN z+vjve8nle*OPD`vabkmA0BTcE+^K}PmQT~|LKyfKOZPDD;lH;q5&U4m^0#JWb0zUy zb}Cf6Xjug_B*f8T6tNcajk#Q0=(;$`D>mSSER8N#_+W&6@7!eVvq0O`bmsya?K4|e z_sh#*VDOa#=MAQn*rZDjJ!$ImLdP* zF|8f9D^FQ);>-uD84$@(x~vLx>w9kSQQr>zjF2JWk1dg(AA?jJ#7ZN%het&``$u%s zzgPUfpiWfWz6aaGYU9zIQ$ao%Obw>$ZF>@@A*1%Y{(ehU@n}q1?swEto87q2((N~S z+)+_<*M5Y>`VF_sQ_J~KN56st@93oVS1?(F@K?dr)9VpR7cTB9fk2m(;t*A1OgsHl z&z`LtQ9Xbi`Aq#N5=-kPuur|E23zcyX?hh@PNNb5 zs@B2=GiWe9G8aH_h$8MJiY8`VsJED6mX48+6xTfsu<>OmIGvAa%WDz`0Lz_{kk>T}_{QfIbG1lDXr(i~TTS z3q9~-GPwbHf!{n9Ucc`UGCTPo zug&#>XO`ha-oVXTUxV6fF}hrW#M<@jU+86FeHhX87$>Um{r+7g?xjZJWvbjvUs+V_k}CwpNm+Lr|68F z>c$VX{Hl;?q%Oe;jeOK&N`Tp37q5gCZ>!r z@D8%JRsxQ;*%y^`De!uS$Ah!(Ewa?EOdG0kAEH_&fvv>yj7UV-pzl{zI78Z-4EFlW zi{ma8m>!qcZ!q#Gk7M|%g&4L6m!y<2;_X5M<#if3p6u(`T_7U|g6hK=83ZA@;ayla z4C(u6t)*i0ecAC&TGL2q4wIZQa!#M2u7I2Qli&EPP~ozr+aGoK0TPE#M`Uq(6Va{! zZg~tP91e;RjrpUtqrZ$SoB1HUZzLm0gr35Obc*&2O0P)VPZ=a&*5| zow|{hHQ%rgZPAUe_fKudcZPdMFd^W3q}KNGbJy>IdIv>PW~Qm?ux7%G0YV04Vn|R3w9E+iqG2hq4UX}@eUfZmS9gp7nwA?4oS9wSz?=Tqab#kVONy^nZ5ph$BZ0J zio}D8uxDJjDNmUTI|^{mMy z;LuOJSqat}KU`wFlF93+2XOp}o_>wKA5DdwcH6WIp*uBbzucKzE|P z$P;wt3|F!crccoHsjh7ES!948sQBuBmC5uWFTN{s*=X2n;5ubNao_V+!7F_+4Wn18 zvw%bE$CGRBzps#!Zjh1Y8&hn)nnE}aBAPv*oW1Er7KVHs2A%8SCGBgt=8>Ul%(~!A2x{*)TEP@^6n(p|A zaokm8YL&TC3elVbM{7S_E}KC_cD>b*Yjw$KV^xn%Oe%AWSS)Zt5ta6E zJZ`d$Bav&2ZSf?D9Ir)U(X^_`B|_{1W?4(vr13=x7(|kRdEE9K?W_VZ-t;N`ZH7Bs z=PcSR+Zs-`3^p@ZhkT^r2N&Nf86dOp!v9kH&BiL}s?4UaU_QVeDxu;K0gCXM5N=&T zlomsS=%?c3^$Nc;cLYRVK3>*eAFD`uLT=tNy;D5c1iHZ*(ugg>S0|0MvQuK%zVt+H zPX~H*#z`guz^m*_+RSapx6G3?yguo8p16*do>0XD?ONDL>C3zT_B)>c^d?nmnM~bh zXru)jPIBX2yuI8~^&4=M<^_%H`$!lmvoKB-!beoJg-hcSg`sRl&uda8>=~8dT{ykN zoWG@DidXjph|3PsvSZl3$*uPi9k%CyrNrfSlau1UYqwzYmuJXmZ({h3qg0I!9;rB@ zLx$dw63a#iMYv9m7(;jWUvcxx>iExDZ%J0Rnx_c}ze z8g$Tps(+eIu-M?5$59%6#g}>($>V*nmVB)|L@pzWH#Kinw+VcQOe9RCA#h-BS?ucv z#V=S=+m6tuI9L4F5{xcJVpMwMif$V@BxqNMMaf~i&PPUeEbZ8WBhzt)fpHP?)@hb)McH%~1> z4mQMU_O}e|TN_#sKADF3JWEsN@L}nH)k=<@0Q>Pgm%Gq>1?Y5%KOoaw@JVZ$KKx#y z;;#P@XU}`NV*WXc&3tBQK^Bu>(Bgm?uy?;{vIQ%Cm+`PaJ5X+2&)mq`1o5$jpraX@l%*3p5$j1--C z1O25EeXyeHPrL~nYs{r2gB$eoGswc=ijvq|moBbWPmdWBI^N~%PjkP%T9i&+l?a=z zw@!mNo-)0z;=>g>(9yMqq8*2_ey~~~?4r_DqOru0V`gD1gt`&187%s6*cPt`{pEZ> zIW_iR0CkY1nCwhMLQSRA{E=iM$|i(U=G_^5S(G14ZML|_|8|X|m0A*o!g<_~*<#(Q zcE(yv(g$l~=gPQ5%uJ9M2(&d5Zf?M^eUY$uu9){%ArAP(Eu&~4>pzaaOom()gWm@D zE*@j6K4=Ct1AiWQl?-H~LRJM~Ixf9@7+PA9Fj7FJUl^K|LAgX(dO7Cir7!F2eLWcy zDWoQpvjg6tl_o**!}*|uzT^J2yqO?j6vG_L;&%haR$80fFb5+-r0Y{taqLY~?Y>R6 z^i^&)@^_=iUNeVwUz_iFZn}+X4%njXMGp=tj%D-#u``m)KXdDA!>~CEiyw@inoEI}jYo1*IuC%=1P80(4=vU7h24VzR5G5r0W{D_UrH)X>K5cfIK!PdT+q zmgk_@;g6D&bpw9C&fn|T#GQeEO3~j0a2^YqlpNi6x3{=s_P!QvMQ%djh^pl%Z=C*W zdTRo(ZS*@Ch-$v8L2W~J6cH9fm6m8pJ!a9 zn3urq&OxsfE!SAvJCrm|M14ONsnrx`5;|OinywiJJKU< zEUtI$$;krHpMpAJy6Oa)?kHag>kL)exuW5H|6z4}MEqR6i@t&~sQvvYxR7-*nOafP z68R`?8aSp#c1V%aj;LHe{!=hQ3{RZq=jswgI?>kwqm(|!Q?nmuom=ovU2;%xhK|4asA~DDs2m9Qf zL|*TbZRFqskQ4DvJOEI6bY2)Q9BQVxc`Bn2KU7v%=YuBYY$W73)#OV~OraLoTR zI%;syk0ax+NBm|h&{5TB9J-Mr@TT1(uzF8YC~TtOAl!M?bKg^~+FN11S&x(|P96Nu zJ{8IT8<0-p085cc;e;#SN;f7j5gHX7UQt#evh;zRawKVN=t3Yt;pM2&YjQT3A(=O9 zs|y|JRET&0yWxcH-oo_Y9Dr(jUpHwqXUgTo&~fRNEU=C0md1QKe74hYNEO0_h8--K zSydqKY@bl@i`_0l^RAYAL4l?b80T$1?%h@bgKH+O>^MYTs+74if3}hF3pysS=!Efs zoXUHfyOE}pFh1nV_>_NhNAI z(Z@U=ja&iBr|UkoOQui|n&wFk4aW+8y&2B&&&zVKq%GKR+@OAH@0egUFV)=eJqx^d zC*ie$Kw0I5`KtZzBX|yxA8`GsUoa_Pv&-l^WCB_vE+|~CBJ83C0Y{V9kmNVUci=+p z!C-7p;=KWFbtn<&2f73Rv0d{E*)mSvv{m>KCZmfLH0g%F>W-F@W4CfG2vLHf+dL$I zEitN)`n_YY1rQ?hmGxXm^4|5tcVAMhe`-9`*J_p9Yi?HvTK1WuTpeOKU;e%4f8ceA-#xYI%jF~0JO85nnWH$26tUWlP?9l*{sBPAUc~F zAoh0=&t@JWX+4SpFfzz3c_7QxR7#zdUrbKv2H7YD_O{f@j+n9oo*Njero-ej40n4h zjSyFd5zCvb3_llA>m+PjhQ zN>EWb<0RK;0s(=u>o~hVQ}|Vzm^H+^J%uFBBWWN|o1&v4nwoHBRqYA%mS5Y zPJp&R!zL_)F3^Kv3z2$EKJ|ZqZw&ha;X)47=AkExoyd>YvF8e34RVlJOQK7J)@gHc z35CujM7f6j+-Rws#_u3fZgv5oz|vF@zW3g`zBQ4u%U-qcv5$~(0qQs2m9X82Mfi;e zV~b9~ain;Ta9^nA3iXPdx>tdZL#dE;ESJS%`WCwXMJ|=JE=d7oTeOrS#~v<{8h%DI zoGdJ;Qv17QM5vp@My69GQ|`Zkh#p~%v#%k-IsyUNCHqa8OSX$cow1Fq{DTC2qYVcP zK72$-7`xlN)_bGhl%%e6g<7%P(vV+2cnCjlQ)Z`KKI)%I8U>nwXeIl$t7nt2sV_=j;0 z@rc662+q8gSo0)xv!-GWuIhiop4E1tF+`vy(0E+}C|S@kvfJkYcrdht5z;8eu(N|95W^)y!hQUUbDL)vJn7+_JsfYxEqQOJM=0Af4eFfud)Ga6fqtD( z!uY+SyXMosG)PY6hGKZwX?HoY4}OOGYNOMMF}u)lj!UFY=xGs?mKXFClE1kWrabiq zvQ)wiBsw|w1X5#riS609x!n6DhLZ_B_F$*FR+_u;u^EKqa*xZi+tU!~>(`i$j~?o| z5`|-iy9#ycT1r|{PqsLU!*=;ICn;^152PK5C%g9+V5$_4@^-OLfsGQfe~xxGVIG4 z!ZNv-6JNfpm54M#5)Pu)a0%Rb9s6nQ1XH-6Pe(&;Fv@ODWkE~Vezl!?;`VRHuQJC~ z*u#1B&g2>Lk?iz<(oqyczt3XKfZ`37bUiFpLN5De%xxwwV6bN!f0(mnE~Lt9^1e5G z4n!569X>5dm!?6eC;#u@&B}90jj3*+N$wh>!nnOwi6gewrpG#GNsO2IiMLNhioT|u zD9X7$aJF=wIF#gq~?y8aR!-6Ff-)@L-=Bm0RVEz9^{lU$}9J7y<_dH(0$HA z39#SlslZu{F*dB7DdcqUw@1G#J@bj9N((0^NwNFzYto=dy|+Z$h{{NZt)oSA`FngA zgvT$zTmLDh!;VR%k7hdMbOK9*w7_Po6Uoe#A-1ZP+O;NxZV4on(CKQ~7h5tOT7=eA zunMP5*=GTBJ?-;-t8rN``p;%=yp2{+tzf89V}4C|1f!{1c7%xaf?L666Pa`T87M(U_B*wy9%#csC=*7vsb_1{Aaue+)@Qezh-Uo?Ut}AL9aH@eXGJFT zAdWoLBHf8>F5E6?&7dF5aH)go2T!%)7?2 z5w=GodElv08=^>I1*Tv126>l&Mo2juJ_5F*C*!jlood3I;A2jGjd8T^YmLh9AJwO< z9IXIQO8>rYyL<2et2>)72hT)2Fq?IS>ps(&*EI0$OO{A*DYo!n^#@pnejmm(M~Z^O z7P?*|$lwpVkm{yNw~_roc=3~y)a!2){qxK!shWgO>+wnbnzRji@iO{0}S0Zrd+ z@d3mhM!`)E_C?7%q$EVA_Wxw4YtuDnwWq~B&svU8U=O0Qt5(`=FszCXgbHJ2E1FJ- zCAPEN7`R-gsm+a@q2zWY1-Ia%%Oy_zBI_q@2@4^QX1GUFwaRrU<_z<^BStU_%T2-U z`9X7+N+P~Q1gqEIABO5w@4}MBzHpn1Ki`jcYCyi@0e7-qXzb$O!JU$KF=&dWeEMR@ z#GqreyU`p6mE~1f&%Jl}!&6U-Y`JjztP8Z+y{cO~C&ftw*r`u)f228zw$Uhsmse<&aCV72sU7Y$2xNk1; zm|%)pIq{yXp-^HlL+s214D6o+<>)QvLZiTWUy#-MyMLu+%6!WzNx}!3e*_;zHST!m zWDhMtPRA_^CGx>Pf(&;JT2lbCS7{oQD_7rLH25nayR%t*`^JO*r~_!giL9jpUR!f- z3p*mFCM;@q_#N<^u>MrV0RNVK5P?o1y=PJLeShjJ03xnr*_-bC15KSFRI)t#yZkBw z)c#Bu9Gx_9z!1f0PxV#RM!saqR!D@T|9_L|w?i`=%+bvjGXWK)6@c;9tO;yx9v1Lz zBkDa_>8ieNM>V?l0cDjp@qrd)RDzHq_v1O_gXLp+duSd|^&ryY7d41h14PC)NOO{a zv3#~+Un|EyP~h-8_-?@C*UDS(y``*Ijpo2coS<8im1{0L(I>w3lxk7?-%cL%|DGA?`ca)k;fz_;uV?< zl`^&t8?=M;sN=6C(WSK5Va*i!TK$eTK-@T}k8V#A{Q42&G=@Oy0J>Li{)e`2Ih70Q zHYSnY=3|Bn^b9DwB2kk3{sRk4@FO98ai4K@#WjtdZqtOqHz=Z5;Gs1748pf2*3e8D zhXiyYUnQO{@l~R=xR~Lv+?6SjI?8w`gC3W4SoWqpyq%#8``( zxNxpibO(fM+R!53?(Xqovrc`>l8w|0w5M%p0Y^Vz<+mxcX?p!SV$Wr@G%+R4B$2U2ON0+kq@t50KxP!(mpeaS3Xl zJYMGJ9KMALzh>U&!nPrS_WA;1l5|Xi%{Q{jl|OA)LpWBEmu|PCZKY>~nSUkIL*d$E8Tu z3r2*c3M~MkQYwVReleu30)+QbRpPt9Ix*h!G#-7thmCYD3D=+2AALUdg=tCODu!6C zR`-{FyOxnd0u7; z`xKj<7Z;o$mO)g-==p`iu*GM5`eI528lc1eS9QsM7YLS)d)-P#uVRad1#C-?5^@Jx zj-Y!povOEOaoktWw0J?2op${Q|A8?6d$7Y({sY!va3x$I=^`Hk_ZlLm?SG_HDNa;Y zsaqx@og_SI)Gsul24)Yt3_#Wi8EnW)xXrcY$?BKD?~5AF{e*m{&DF^lD(ZU?x<~;j zl7(H3l67NwMgc)+h!CH?WPMkdM(VsX;8c~j#+YkD&E^bGt3hHq+h=~4tWayquivyG zl~;)FJvI&Y1HOjb(eyItyrG%n_k;N^N&n>|R6)=EdLkmO9TX<2Yugu4;D8XCD0i~) zxI|8a(c3~OB%k4q*e)?GuyDEudy8c<{c@&=>}D9GRj)-hcJw<$=1CQmqarkw$HXe= zV^9hD%TheeU zrb)^)Vqo{{?>bnLKa};gH5_IQ+&Nxtd-lXTlw`4ox=z(n%T`$m6A400%ba=Wv!;l7 zr-CsBsk%&NxN|9*M{H$BVDQ-`t z*w6V0=$2}Qk;B!Q>>X#|{vHD9LSb83FS{R?4cHbIC)U<}5uICvdnY*!@cCVw7JRA- zLX5s(9N)!K(=D~p?U05)tjT)6qBSH3{jAiO?ZdjWM6?TyI4kO8?zJEMhOv^vek%+x zLB>%MSV$+Ck<8j$_28(6$db8m77 zDf(oUnU@d1d(%Vf#vYczNw@A!5M!f$u@FJ7(c+&E{;{G!>U(#NA7(H%=uEpZ`1tc4 z$M*7jA=wSBMN+J0;i-l&=eDMttor?D85rk(lc2v>Y}>(RLpKXHF!cE2we~}D94fZ# zNT!9yO%U|(JdZ#)>St^aiB=oDV9HOcWFzLvnBI27^JAq_mD7|dnd0Jt^=mpl3p(qPXwHV9AsF$*q| z{sG1&w#P3t+~zkZ*5b_!QN$Ltk`BwEBg!*_#yKvqrr5- znkv{$bnEri7-WJ7kc=~sEyQ`;6;2F1mcAFay&CKX4wCZSPgyu~y z{5fyuBQO7X05AK|XaE1JKk~o-fEK~A8zWc6V8c>%%z2jjFkIF;R9YBObOFUW1VlR2 zP6=(PS5i!n8~}JTv%m>4BN+gR%>Cl1wA?g>fZ2UrnO}RBSIq3WNVH1ij+H2pKiW2- z<4}@kL6vUOP}AKJamwWB88I_X!Zk4A%XP5()4OjHpE|F69W{gD&O zsf-0TXF2NVuoXM{NaOG1{6HEuXAo<({;6w83cmk8Ka)9JT_D1*2G!M>!#zddJ*cce z)V-_coU$ca)f_m}B*X4jV=vQ@Qt~MFqA-pm^gK)$VKMX5N;8!Js}_%=#d*BpPRezR zpx1=RT)hw*E}t!}3SGO0A&s2f(u!KqW>@?&ML(C3=!%Bv;P$qDJSK2Qu0is?_(Plvy7fU#0m^>$mVW+_xQlw{9(f7EgT@c5HIQM z{Hvp)rB8L42X16c9Qwza!;piyJUC`(!%WV`xpl)lNace<4(F-G8%TW-n0I=d#$bWy zIKm8T0`K+PnRs@bcjsITh-1e(Bnwdw-{W%4`}ir4)!l&@8eM=@6N6qwWm)Sj`eU71 zKJ?w4W75PiX?lqBCNaOCU>1j`F2A62OD2vgXOMqy*yh}_s5kkLtW~;0<|3DQ>Qabe zM|j+{!Rf4*cmM(%bWh8%Inbm(k87aDps{Jbo|X(C>$kD%HkI#=pmQOg$BX*TZZy`V9}1!5hpZin4FD^8N2^ERxD7B=Pj>p|05Esc}p-a zZG6^r>$JpN$28Wfk|LNJpazP)X?%EkEhz;Dgfbr^xmu}7->bvh&dnO1ZEim!&_*0P zv*8?e&wuZ0*$rd8zSKvb?qK1&L#f1ch7b0u(~E7iueIgwtPk3{|K$_S{Sa6}q5K+` zbbd>7hd7%h5dgdqsi|v`PRUx`r3f`=zVQ*906wSXU*zS??Bv!w27~_qZ3_NexxqAj zgcMBlRt*zL4md$l?wQW8w2fpoA>p5;0R&uZZdaK%g6b^3d}ssIa;sW>Ar|(t*F~l_ zfz7_#0092(u`uz>0~5Cj+b2Z>`5Vs=jq>pyp$t*%l2B8KylJm~6h7!Sd(lb*T3NUt zc9RAK&3cjkW$Iz32!Um5?*TcDStz>NDYvEJ#(}bEwjgazwDk2^+Mx8SVGI@F5&{Cd zvrk)wbsou2uS7nZ zQNwk`53qb!0ua1VMYldPzb_Nj^rXqR){oVD3d0nu+^}g_0{lvFf{z&aQlfdJ+pydbJWtmD92TGn)%BRNs+smV6-k=Y&DFKu|Ui+unE8#sy^Utobzdc&u$ z04-apB3EOf+TV<>qZ{KLsMRi_uPEEag&b?r96I7_+Sx^`yN<3+&*`?Q`6AtC@B6>H z5J%Jy|E&hF-2H90NR4{qM4~33jnr48l9_Yk> zK^g|Pm9o+^RoTY$eRdulb`FXhLFiUtEV0D0Zo?w&cZtJbgaD9_wLrzy))XgVr%w~^ zq4FPYxLl|3=)Q8)emW6yZ?7ZvhV$acY|a(t|DRL-QBcDSriWSzD=&qzK%V=j9Iz|- zB5|_X*)337>CJ)0t~oB@<0dMSeQf!-_CM!6h9G`(YC9+fY~Cg3Bx3Ryo*S`(bK6YBcyWe{V2A{YM`R$58CgkRHO6h~a^?h(3++psOI(^cwQfW6Jp&vM3Hzi8uv?*BW zS_V@WEZ4v8(Cb^HLpB5}#u-wimyHBsNq&2ez_H23q4>{W0I*=fPl14!L z0t(znv7PU$50A(=#_Bv5Bz)Jbqz8kNP=e8pauTpjpz}0fhxcy5*1-2lGMl(8tMvWE z0%(H=^FXY8WBsj?Fr_?-Gv+|DO;>(ato!P-6~Z{>hFG9{S5x}+X*Lq}SSL}=POO>J zVq|#t#W%J|2jZS>>qgXJ^ua2@7WCY9*CSLgE?}mfzP`)c#QQLrzsgMwJp*OFQFVru zI-nGlmj0dLguxEBPz^fJJ59GFMYfaDrGLmr>FaOj;nlCwA-zQ{niuQ?w zWw|QDdc5y934UpTVKc?GxYp|1WNo51z@joXZeg-O)KZo0@+HB znq`~d(zgqOM>VpN^ai)Qouj6(d;2pcCSNVNLL=pu;wVO%dnmAMrxL5yEO*_ghb47@Bg7j^TFpO@UJk(zWR;$mFR&fF*ZJl+Z0(ue=d0}R3yxIf~D*l^DHyd zI#LQq^5qu?x2o787t|y&tJC|qdx*gC)SOTFO3LeGDBHmBvv@4<*r(yv77^e`in(Nh zn7tIwGt|!X7}J0?iv0Od`r}+(qxVbZpSw$$C@4nOuL|4qgxl&O>E8E$6BsI28dx!; zh+|v?2i}*kUN`yeR^(>yxnxC^D9ZPU1##5pc}rH;;r0bL$G@^@)45(R2W`n;1M1rh zf8Zj9)IO27bpD1eZJ`}jBs4QgK#*4BmDR11Ac9Ku6?lnOcP}U4iR0|lBRNuG=r&Or zz}u>E^P;`X{8%|2R8ep{H;OZJcsD%a>wBu=V&dNptiE ztWCCLN~W3@GY84}Ypt`WH|f!sxL~t_7edurJv+?^@N)k(I=9w~Qx^>Ci-DdJpOD0a zfK^Oz9r|@O;?ZbnQ2v9bZ#QxeNwin8s$q3t)0^MFMLPVMZOSVWIYYnL>8Jxl`cws(gaI1a!;t`CHVtOZgrfxsB3RY!m22ej|AdFeNouw3 zS1xCL3$#&}4E!&|Z#i-%m%Bz%hTEnPMyg~xT;C4GcHXoz@ zwQ-v6srLF!tX|U)V$gwq(=#gPT!RJSE7LRU>`tPjvsy+wSvyWW84Xw_KH2c^=R@dg zH53rxgK61(jNYOQq{i(LYIunum(&c@7uX_T zpB6smFa#!bQd2psaV=C7G`lfsO%=?t+RbsUD3KfgyO4F?I2Z9W5dawtFIRCZJ(B~~ zap~##p^sJa{Jn3nEprkifPEc^wUUyM4Il}#8f#{i!}jme0}!Y`ond91z_mr{8pHzg zQYx?-jvqpY5p=c#5mSRxq~cI2=86$Di-Wv)gpb_K>j$}NB!5k+rN(jvnr4p*hefdQ*bwS&)*n3 zx(LA^j)RI&A&~4&T~9p&=IRhmv)_@I*n=XIsli!yyP&?SvJ8{1k8$yC;ptSbO!fpx z-$Xsu)1l@;orT4oom3-48&Q+)(-+mqu@LHr-@1TXZjE5T4q%nOV&+x?oc(~sLD}Vy zohldoI1I^qQwY3vt@^HR~dT{}ukc$x`^a;7_8$9oRy9TXUSvN|0S#p;eW|$)0 z*QVpITgXXqP_mAlewAn)c)tfO{s|_~vyv>IYcVuiy)`**$9AV zagjCe6M#6RH51ikY<$Ml-{n_w%1!kzlzvbF>O?x#UJ6$y5%hsW!W-**i$zJ>A z^INGxQSDW1qdH*Cp|^NO^vkwwvZ{!(^qpC8Dw~FIH}ZS@1WMtR_b4jabP2 z^Wl-$+o=Q^NISf}duTX%%To?Zy49yV7z{1XJapUlr@Q@OvH8whRQo*OaL_&$bE6Qn zPR-|IRm%zFV-D-vX?|;;>Tu-f=?kTFnI;oGGf#yyF!%b`#q|1d#zT9r692>HzAL1` zdA50cy|Zf(0KfJ|$%ahVdoLj;oSAFu%=a5yPf@?&>8pU;VT_qxM(F@{9>-CP3~2qxXnC>z3(S>645&Vk0nu8) zOMX^opyJ0AHZ|UO0*&!jY50sj6{>v?De}Fl{eUa;zz!;|Xm4Djp_^C4k@50JuK$cx zbIJ*YAM3*IeWU{{{B7t+)BV{09o7FZP7cdMJlkKl^38#}GVQgRe*~-VtSAp);}uXY zLV?RRhJEsEPFUaeNEF|$HB*euHOgi1u#7oDaT7AKJ8^#7gTpDo^~n~4WY(GBFGq+} z*5_H6V1Jz4F>WSnVpoM^8Nv#a%}O35a!R7S;~JP`@>k$pTzyPYh7E1}7!em&nk4uf z`s~Fvy77vvZT<03(!Kq(iw~AKU=?s^Q6@^C;OYT1-Ae8fkZ5m?7TAPspPmo3#2BEf z&3YFp&le%i5@;|CIT}xfCi8S&f**nIet0F?_}aO}#$$Qd$boh{3=}I}8MARp56H4y zXIl8uhY-A3R;9NccU*Fg_{ZNYZp`yz&AIgUh- zTGG17sH~`Cj4J4L>Or9CMGCV`=EvBbxCC_6%y71>B9?2lKp}VET+$X-K@UK<3-;3` zeUE$i4Fox>eztG^xyeMPrS z>&vx!2ViO3ZVN?AJJ5%zSiN$cGDwuUa;%r3ff>aekKFF7J{+)<7F!Akpfzhwv_`%T z&iKINL?+C%tg)0}8p9c_eA6B`2}HSSg5;|>OG4VFv>Hj0gX ztJ9GBZ5`?Z$)NtH6!+Rk?Bnh@A6GDy^kt<2!6HO1?`0=7BU`NHUuuFw?*C9P0zgOq`hfviPbD%r}Fh`rw zo8?;5$95aYE(u+NK)i{c>2sG(+SbQoyhSQ2#SQtRkUw4`6+00B@1j|!MWzXY4~C>5 z&PN0agF^|Wh<;`si6?%g!S+~)QGI4Fgj5OJTDj6J+o}Xbk|Pj~adm2LzhOpc7PkB# zWfw37L)cOrQvpbg`!o5PtVy)Tpl-^?cRhZKyMvGk9{v@^jh-K9cAh7Zz!wR?R5L(B zv>QZn&L4xUGjg@Oq1;`3aq>Owlst_1UwwCR)xmGeivDvHFje~IU%<`(@KT;yBuoBw zZ0ST#z`NrU&341cz#%#o!efG7kk{Z+K>W4~Z z*sRo^I}_DBViAg9qH(|WXF&JimAx{xtS!)XX1}DG2Bmu5XmY&E z@1*OX`9AFSNGH@U*oBH_=848ckLlknv--q=12q{43I`Hr2>~AmOZ4aO53}gt02!PE z!F_`^PoL!M1fyPywQM{9K`wF(ThA|}T2E}p>|WR9G^dLmI1YfPNeSFF*1Q^SO%~^n z6l%tHoTY*{$orYHBOqy}K8|H^*cGXk#E6NXpo=9L(11%Dwamz0>+yWAcgq|R+(~(v zJmnZ&MS&rFP=`|me8-zn^b{*d>m8u`*wHOYu_++t5voa{a&r~yG|SQeuQ!R%Fs&=G zX?E6@9z#FyM5h4BQIZ-LfmZdWhF%D6Uj`ZYH9k8dOmKTsuOCqA68nFcQP*|(3G_yC ztI%xZbSeEuE&}TY;))CfIvLZAD*hX9D?_*^7!~g7{bi_6IQ%&+DlQKG*9@?3N~vfl z$&G{|hG~eM8qob5yZc$CK@Jzg6IcB3o+)`$$+qXiN18MWYR>HP*YLd@HGNrt*^UVoHewtG@AkdlMH{P-;^i(of zf&WuWhXC95ET#*00%t6F8|`Fes%?n6msEVSh^w&0d0W}sy6YS~CkHR2E-}|>9PO*} z|IdTxupSUL9igF8Bvk>J+r=jdP$}39bGL~uZ`>SJgIS6t23HF0*%U1hRl*vzo2?<3H%H1Mazo6YfE zo+$`SXlWl^!39i$uij=>k<%zs^vqZLs72bRQmG4m3Pz63V#BIk;F8E*c(vWTplv(W4|!&+mWx$^uJwalOT|0)X^dt zAsP_I4~G7UkSBU+@DEy>N4%kBp~>}DeEa(OO*BGIH)`jwHJ+0sIB6)dFO$^SRLZeU z!W3%z@+Yg08Q`6HSD+8QTNAP{RRP~eG>K6V0F%cUnMC=;OT+QdQ8SZTGOY=a@5wkz zw&Ni&55JDo1Ay`9&xexpK3FFq0)qouv|NFdvB11cXn8K`n(rpuYR$9VFq)F>w=%3D zF-ICLMHOSPTIzOV9gahiA~AnbmB)h3wi-?l$lo^odJ#?$X=m(cw*#E={kCaDv)%loQ#ft5WZLJKwW+GZNsu%UwzPcY;zVedhT1M7X{yi-! zJ?D?j;QgY7S*-P#QQ9)rL|*0(mXwbGNML8sG?A@1|rCwk7Idfi(_5fz|gFKX^FI0~*Zfz9=bqXF72|NHS zv#0YTCgaK?zD)$+AhZPevqt|mZz9E+5I6a=SQze1kP21>6TyT{t^;fjNk^9r+`IlW z^Y0ewrDeBqVG(9uf$zZ{{Q*^Ref_^cjfyuSQa^cQuYfn>@64P#_8JQ3&?AxAC@r>&-V9~50<@u zUb_7w|M#*iF}|(M-GBK1e*@O!{@@EhEtvL|0;d0>urI3+_kn~}gNtCAt{~oO{D7zc ze9$t7&3s`scY%n=+p!aQG(8m~iB}b&(Z&G=@<` z)vcgRRK@~aC}zzjXxA7K;FJOK8&A!FfQ!Vc@HQ%KzPTu^O^p=6mL~J5TY2$%8~q2w z#+7Xp?ofa{@#6U>@wQ$;G!k1e;fYMQqOAbo-%jMr?sFp&`Xqt7+wCaIe}e<0V+`ca z4TF-g3G*7Uzyv+@!Gh)WCfqBq5&A&Yc$DVE(>~9o8STBoeci5dhy~Q&6&X*v)$A{B z0#0$|t$-VRX#nQwAVT==LhF#<<@LIQp&U`MhUQ)gKji7XG_467UI@#F+ISC(` zOy?=%T%2yokUplG&6OS$if?gve)>$Pdu$!J)h?N^_EB_&4|Jh9Q0+lJwMfC9o&$yo z!)1=*XE zJyXn~Y0uNuifig+y+X1W!^#QX85zPYf7Hj$^@$``J<{O+9xf)z%m0sz{(frKf*tT& zR^xHABCom86B7lss^k+g;Vn>HK(z?EvZv)OP_3RE22u9qYt4U+pcrI!T6fDS2&~7P zW!FE4zI1p+{7)z$_%y&HHN{gewMlA0XxOoTY5cmsIKu$Eay>!S;b}=GT^oO|G|9Ev zdPgLk1ieeTR)-jX0RHl*$N%?3`pf-g?AX}-k8bbrsLLs4IO+PPQ_j-3|Izpn&#T<%}IzBW1Bq z7t;jcOQ$(Ijlwz5I}K6&+i3?({^bh0p-W3rH3LF^KBK5>vcoVTM^x(jFzO=Xa;q_< z^usgA!3Tgh?q6;JN*Q%n0JHwEGJFlYj{qpEtD=jhQ(KuW+z-@o|2p_|u5%9FqV7)h zYGy8U;P-H}YVd4I75Jq}+ycm(>%vLsExSfqDW+C7fWqso0WOIqG`%kgA0`9kU;jC~ zaM3D}m!%p{t6jwz%%D&A>6VX3UGJXO~ugs8R-?kceeEnS^qZ+KLS^!3BJfz}1+G7=ww^4abds^i{W z9+fZMuTZOa83F!LoZG;Es!|%;AZ=i!BFq4CgW*JK!gA?j&8gW6roXYVCL!XS_=W+u zPwttNV>u=+R0530V~17PD$`~!0b z;4@c@G`oax(_k=83O%L&u1zEBk}^CKs6K9msea;0TB2A_1IZ`~d45ciMrqZ_IQ8Eb zGxc2|J0k8P3w$sOjIeVG|GA`L@T!LycmrKPpLwJ${q0Nz&Si&d9+F9i@T7AsA~{F92oKl|P4HQ< zc0o*Yiu}|u3ezx~q|tZ5{XYh{@k7fj9{cGQ?RM%~AY3P9GB3Vq#S%@21-}6cqd3L7 zqy$2<#cNlD;O|!BthO3t1i|=S4m4XLTBChzOP5xs5ltaf$p$~cD~J=E-m!S4ob;7W zOs(6OJp!>C3D8gwHo!iw}_`FSG9K#X2FTPn2U zUh^qTGrX;oi+77auo#eTqz zk5tLQgiSjs%<&8y94!&q>iPT2NFxAM=W|a(DnH23=OcA&;S#)Pck69U@-4g#s-wbw zF{yI$(IKZy%F2xiQmXPJbHccyyLPdop#k)%R>p=l?aaP1c;Eqg=&hoR!?rHQJ$OpV z+GYS5GiJu24d!}ABAO?J18gos7mCzgHzT-4=iS;f&3-R%CDcPjnDIL3VHO7D(94>Q zU>W=k+25nx47>_eFb93Z49_!*Q&#JT9C5S&@?n4(Xd-Q8?m?xFYJ6M!Bdy1c>WVhw zkp)o|^B>XP$Ks(FsD7VK;4Wt9$Kv&!AlUq@m>ddTI;*ry{QvSB!%g*~n%EB2wb|-b z6mB=*lI$KgHMzB2ZF0By5On8Pwv$HncPeFFP|IrMzueFqtEOgDxC#P@C8BEXUBn1? zDy@Rl7gO`ep$EaQ)G-+=^M&k(y}#UgaClkI(DI2wyBum}xcitsl&h=e5~7Y?2mX(I zbauUp5m478(FB`k`5|PRE)os<};abA=HfX4iu+g7sAOFsx8kLN+grl+?i?@x6Q z0b$N5ROO6InE8R%DKPIe523eR|FT-9KQC`4Xfq}cs=9-abpZdB>pZB!pIUa^mVOG$ zIu|(0G9i#ZcRZlS9>NWSm#5=^c*d4CmsIVnx6=7}R%Mrv#4{t`Se=?@o_Rw^M8Rd9 zN>S2*DUJOq%~A-sUJaCC02fT=)bN6C?3qGeOI899 ztz$`P>7oTEK3?i1?I(a|T=4g%KmgEekD@}{-$@KlsD%H7a*MG0iv}e3IELbGIIy;} QjWZ48%KmWD@^{@B`KkqMZ#ap$1Zx{RxgZ+E`Z!bX}i|6Wk zZ+duPBy8{4`K$d4q3`>jPH)ox)b&E~(W%_0uOI*Tsmy=&)sLIiXU{&ozyI|A+gIHM z-u%6V{7?VyO&@mu-^-ue|LNiRXXJf+tN;6t=Fs2&)f?9X;XNOIe?UDDRJ8n5JQDox zPOuT)mMqZNiKL1GSIsUhqAt|av9^$p2SK1|yE@y5CIU1PJd6&SA)~37WZ@S!9qV6? z6Z_DjX6+U;6vivt;s;Dbyg`_!E8)J`U8QdkS)6rzB8y0wzC!;^CEKYGSJPsaLrh)) z2a&}ca~7~jvj;+PpvTq+q@`Gvvt}7;%ye;oZfsnW);AQVk#774+T%QwJN8vL*X^NZ z&QLb!u%++}+Kx$$o4TAiohjmwE#<^4@D>;=ePB2$zEh>pdX|90ty(Kb33JE z2(Rj~hojY30bw~pO^9Z_{%EclulSS)o4~z|B(Mb?`Ebo9wdC>@nAfYRYP#?~ka;GP zer^gr*sPK8AksQ1DcglK_u7919jv5z3$g$K{!tcWX@CJ13`0k7%^qRRLX_K}x_EMF{&Bq5Y&k)n%LK2_Z;a={d z`}NvyynF~CkXg^`!0w?dX;>PlyQ^sKu$by<-dSWvA6362!2bdN1Q+I8ZV2;2uYw>W zi&u!r;8|17s+;FNWzRNv8-X#Nmb4bMhHrnHZSt61=)$M5bSAIZjTL+9W_}X`$crTRmQNV zy+ek{1X(6WB&-x_94qn1k7K zh|{NZ_uG1ED|2XeGksplpJd5&GKSZ0ffdmRB+V*vu3cFVc79b$Vv3?vKJ8d{K^kkCY?N zd_H&nZizsKew!(B!H!vp0nVwU#By3JJk0+BB^svZ6ncX|J#x4OYIZd~uA#z<*9D18J1rym7o?*wPPw zFi>p8j4VzdPsq5bBoYyyG<3K^oN<49Oe+VXvXgb)uR&A7d>8iivPjao^f=mEVBYHc z2`E=iDaSLAk>pj!N`8}pb2UV@746XDpNPU_!(~6wt}R%B3!42-tb($merV#!1=5+c z<^BcJvQ;<-o~YONAkHL_M^V()BuKc|9I62zX8_e~ATI+l;E%=1-6W~K{YZhltk?58 zfZ-$Y>y1*$eN|E7sZ}_|dM}~QMX|-6*`Ruf%38#cJQqxiGy-;fC;(`4SaslKKy?`?LU*PqF|KL2>l2oG)Rg;WVkqP=w z#D&+PS-3Elq`?2Ka<47I>L6ER;;=lV+R95a?PpanG1IntpfD9Jk1bfMvK?XBsNekr z%fLf*SQf)Kkf=t)RF-<{u83uk788B@G>%Ut{iYRFE|5qYU;UzVsaXc6hMTD`+KufU>^b_m5>6%ru}l7@^q#M z>mQf>>sGz?Y0E3@Jov^G#Gt(!=(sp+db2@q6`;p~!Q*RZpjFHkS?OGHzP5`dfH3>z zY6=DUFWu*W~LG&_p5giTES}@SPj~Qm#@FD z^|hQpp)I~E!|lKs=p}6eqfu!-Sm}e0{bo>=h>wE*N=U>B@IInH5+y;eFJ9#C5+v)O z>IGUvsYEn(kSj5uV;?myU`GkzA9d$wmr&!JzoI<15ku8i2}Hq@Zg&fXcunDfkK6u8 zX6OnQ4K>fLyLx}z_$A;Bm+(i0kp<2wNVYc{8C52RxH}a2CX7Rc^8f(;|LK2;ncxgc zhyU(}`y>7%dt3in{_TC*-~{Yc;{XS!b43I^7JYYDc23;7LfsyB6q9upV_Ojmny=}xZfDf zIQud8$aV%JgQ4^!-LVLKM}s@$v#8-vNZgtK|8n@$F>lGxy}Mk{pMjNhL*+vkp-ep)I9jj0w$U6D!Jd8gfBSEFb0} z(V(uKa>pK2OQ8X0B;}2!#%w+7;Be|GEU;!8>QRF%%;$~nF4wt8mg!f?O7fp{xbV_7 z^Vaa`4**hW#OSwbvpqkZ5q!ojqO%$J#AkucQH}0`k9ya=|L)THDg!iTi{=^>7hN+a zYcu!eXk)*z41z^G6?tB){wiXi{&IgZJQnpSd6^QxDlt`Jmt9#isUXfwv|<2qU3`>) zoLdA7uEse|_%j*nlO1DgWtsFB8pv$P#OXqvB`jbjISPq-Z_@tsky{A^EX!_w*W zf>|Nb52`o2elXj29sK&mi-AS_&osfp`1!tFF78k>K-;hf5di!q)C=y7^1d5LWwfu( zecTL_=N;>DDwqDm40?B-pex{5kvlCMHv_IA674*hfy`{2eW;G__n(*w%6BEr?AqGV{;{{$sfz>qbyN>D*iyEG^b8L#b&7FwMjZ6{8EZpMa6o_u} zehznyc^Od=QO%9Z*x$L`K`Xw?ww@6KF!4caPS)t_%Qm7q?pTsn|L0*3@;tBlYE0;X zgR&rtf-9fMt4&#Qik2Bi zfNyT!VVOmH(n|77VP)Vc^BlaaUmow!XiR+5vx#>?KOtf_)E1MiWe3*v~+*kx9S4a zX<)YQfBYgkvM`u)#=X8n*0*lW`VIFB&R1!(AR5k#DFe*7V@P!Pv70?GIc$C{Ms zUpJtS>!wp_m?^6GsWka&9W|eC^_nIXKM4t6Y%4_^&cR^gs`L|QjRB~Ub!|?)s^*lV z5onET(Bm?T??`DFLX2Vhtd6XiaJ?7&wSa2eR?k$w&{!nEDPf2Zpm!fCAU$AjnojUr zi}bFffSb-vh}FWustQ7`0IOKHJHt&md9=xdPF!s!1#T!GY$DO1jfqA^U~!533TR+1wdh!{=z} zvOOwdfbxKgZUQR2^@|Kw%#=3L$W0zxRb>0E9+bF5&x){jd@~N+&)aM5q+^_Thk&?R zFq)6aKA!4`c){^Gxt#=PMAI5yISKafenuV~RH=3ZCq_P~I&=f+i zlP{yD+bdg9KV>?937UQMkr)#G9r|(#txKhSwiJ+w$OOC7AwK~r_*{|f)p^V68~8d~ zVXGcGOI_(n4U<+B9EQv7&MubRo-WYhXi{asx6BME`QWp_s0Cuuyn8*-kHgB>LO6P# zn&FMWRK9)f`>rR%8}{P~8RYxiNvNLiYqHTS9A3m64C0jH8&)()v0oEcBf6%;R_Ph~ z%!bi4d;I4LWr-gEG+{7GOd0J&T=iV7poiUE@^5?ii7?Fbg-5p$2)|D8p0JdWU>5%0 zD&IRY3&_o%M2OMq`^T4C*b?cOf0C^bBNks*;wFiFM}unBb9RuYF!$F3nTjtN@q7j2881`2tu#nvvXcTWJ6FJXZWGaz*j zLeSkeO7yD6Oj^7|UlfGxF#$>}UmYc`A`w_*x2{k)FVmuv^KP$gzq?MY z7$ZAO-F-RPUQJFZ$Y(ht)e{SDr-T4h@V|oFB@?N3r9qJ7qh(xOFN1f;el$B=CbD~~ zL|Jq%>og5WdD&PcziIrnCk(S7*)&PxYR;^aee2I}dBDXx9;%gf;e(BiQf9__KJD%dnnPLAmC3sJ3O8`|oy952##eQqnc^O}BSnRs z`?svU$a-DBbr3DQhhQrtEO^hM)MSl*o}B|}B9U%4@@y7=XFtZNZCNCDIRj@c1ux|e9c@bKyMNWHq?BKUmA)byP&gVOiMGulHFC$7kq zl7IW%E;k=G0f(`VePB9$_4lw*e;Q)Ok9xB3()b8)rR6=slyJ^V?CPe3o0@F=1+}GE z<7(4Mj#OVwRhw)0*o8Q_2J;q+2?@X*!R#x240X|==vA5GF~HigB;@5#a&m*AY2gPS zBzfn$;dGAe1#uU|Il(JyG8-MU_a}gjQeAFw&AWUEt6C^L?^BMf zabSs!dVPu-luIo_M`2KE!p|`)Jbx+6|KDybWO_(cpzzAZ+sXDXV3NZtDf5IZNcuS8 z&O$@A{$i7a@s~J)#HwNQu0VI_9w(?hr=x7DUg8oIz{)kd@;1Qq=f+g_o>&g^6!sxo zd*i2Tk=*?!ClROWK&bVUwjdOp?X7H4YosS$VB+SvN^{xw*8d**k8Z40^Z+5KW#U=VZ>M2 zHaT2vzm>!l8bz4Pl*M=`y6?uxI}{k3JcNj=au0YYA>;8WO?wZOSg*4FPecb8L(_jPUE_yH%O=F$QhUL(2O49Qb4KUOOy*ZGI=@UDqJ=Y|d zPg-}8G&M3BTb^d6RfFI4#pwCJd?hak!{DZ!Yh3~3x31i`y?mR33EaEOHQWl$Gb30c zxuQ~=0BajEZfY2zQqIa&@G~71vuT-af0PtvRYqrD_;*szanOOJbD#6 zo&d5(Ru?|mTDRmu4`yI zm1;0KP$ij8EN~juzrgfaET-5qI_OScf&|SY_K_! z4aT@Q6VqgCqhiwcCM14%i=1*{B&yWRP~ASgvkLIIU_d6z53)9!mzGdRPs_m7l8*g) zYz^!L+VTG+P!o5$RF!r-E;`vks&)$)H%7hr%B0Oydg9<>Rb_@Hk~K4|Gr7O-j*A9K z!C-^bhV;Tk$mCD3mGhYfMChh5$sXj?cYk?2rix-;I)$3dw7gR>-`Vh$mAb1R;i!7d z;CFsdjEoCdK0yYZjZ(IxzBos(#m=?m0!7slt)eZRq}d>c8j7$86HQYw3{ zpHb2}?sRW%KJ+Ok8iw$zFoH=!DEC?<0+(uDLNZM7PL1|}sMbxt#4SJSl>sbj^Gkx( zvU@cWhHe9+4p-l0*CC1f|Nj42I@1mh)skhfa|2HDkTi#S)gRln2;a9zxwnE=^-tF$ z4V%e4`bte?5+5TKxnsl-AowVRt*8-SOvHv{YzSg!xl!jb z@WAkUd;c0qk=sja0~;EV5L!?TvrA^GY|AT{Pdc{Q1NA)P zauwMVxD)9n9E?xeqc^$ul8;~4x4LP2Y*8Isf+>Ni3?oyLP>(kC$7)IvI-r4j%(PG! z7hnY;*%c7<1J1EDPyJ_^%Y0o}IAt^0y|4%Z{l|gXsB*-KwFNZ4lgzgO>?cmWiq&ni zkdEb%NwoJC8P$(~g%sO>QtR%c_{DfQSKli_4z~_JwP;Hk8nvC!7{0%RU2nJ3iCUNA zxc#L6BoK%@^1|18w3{*2)l?yDPcq(Ownmh^t&Xtlq~-li&At?NHZTtWXi*R(h^hTg zWLP)ko8(ATac!jriwlr8hPS%i992;?tilyTDoO%I&wt}#=(SjV*DMuypyhGeJO{f{ zcigaqVK=HcO0;w4BVCQmW(Mi^zsJ&RBV&cDT9tC)U1r>M=rNI zGtBASZZD1d5TqdCnI7N#x7-1BuhF`YNNdigN7GfJoIMyR?3f)}S8*;TXjVzr19d1$ z!*$&8KNWOhXn(U%&uKIgw0%`5^D#O404Z^|!!MW(+Ebkn+UMN26^RSJwmc%nvaZ4! z#L=kN8$A4$90_oM`~;R)(h~^a7c~)(ux(09ir_2F80Ua!lPb zFDR?TaRrL*L&C~rpQ;+Y(9O?DPp>gf8_is7uL|s2^;Y-m}& z&;;OqT!tEPNXIx)7AFG_uW$KOp&$XXNG?XN!ZlIBa)477jBC8#?9D6H?%e zPgEJfq0Kl=3I>x9Z?suuQ#M#>Lp%D^_4fD%!2MPU!5_|7@)Vu`U85hUzG%uyF_;#G z?_Qe6Tq6SrMNE3q|7z8;u}XnLBPRk_uQa?AYmA-%{n#njE3Q|TjTQszDQ5?onE`Vm zVwn6;(R1tFmWaXXp?D%C2Eo-UvMDD+@$=t@aGyQ2ZBw?HJWr@T^%IUrI)f_YUxzRj z(&hTWp7ByD*yw+6K3AaDF2|DM(*vnL_c1)+x^{n{tE~7=Ant2fvP(iC=V`iJTw~pC z>puhn2bu}DSje;vx3#4^&rmvHyXIhYAJvm#FTN2m#Rajy9Kf;}a*p&i6wJN$%YA(rC1Bz4h|~#3VmnYsdKCs>_1fomjJ4 zYAv@Z`!AqGb5=-D%oC@fhAg2*tKHSg;YI{ON47828zvLW?5RXt0cNIZz2c^N9cUau z_4(wi{7x>SKBzxa^b(GEeT}-xvY*zFut$tMILk@G zHBZrIH|B1r#I(>B-VH1RhN)Qcdc7hn9?d5snNQ;R%}l%Mh{%^*W9ka=TMnMBu`ntg z!)7X1_TT~A5f+C~uMg(QAz-j$Ml6@{0>H z;mGhYDoZ?d1Cg!*ZelTooa5IumZye(j5!sAT$35yyW?G;)liO%!;WyKC?N3Yt=_hA zZ8V*;uA+qq4s9O>YAw)B0toaE6KXMl$LA)ln>DPlR-qLWb!Duzo58>rKv_cRb|i+9 z)b(*#>$bko)CtNU(b)<>Ho}AFphumFB|aY6{s;?F^x>n)nHfTGLM(UU3aul# z=)#!wiK<1QdNq-s-@BXg5-%yD2bh^<-Av{~la$*mRTFc_dI2=w8;Zx_X*GNUqKL6h zJAEszoxs!hZXomLpB9Ov%jKYgaEMcbYYRTckMyTEpJKGXxUJXPq+_}SefoSx_oEvS zf1}05SZ{Hn(aIj+f!lWXN~7XqN5aN&pxTW}K`}!* zG#0mPS?92wBZgNI>O#67fIUme zDeq!u`Ipwd1-Z=ss1{IJ?{sWTGx~mCGR)K-VVt(uoemx@O3Y%)km{=gf+=+FuTU?z zdxBPv$GziBESQ<6Ky_{#rtuO8F;XY);6J3#75`XjaDC`-fdfe%tD$c8bkO78OTiBl zUgZe@`5y)r*9BfY=*`K#B6xYSjNp#NbazP`Q^Zk5N}O}mu=(@PouFk2X?d}iCPfHd zSj|p^Aa`6rr+(qw>igYl#+~~qb*|aFp7*ypCq0wxKUvlYo zcqDWGm%Z(C!?x%qOZY}i%t#-s`G=wuehxbRe!-EHO!x@SOr?$EfUB%riUcQ-AH-}| zfNyL=>)77p__6Qrrv7GQFXvi{A+2O1goxWV9s0~0)QYz$fgMVl2Ta1p?;R=zOQK6| z3n|WLwDbUJ29{LA+P-G-Mv1+6>YosvJk!at77q_lw+JJ{RANtAN>A01T+u^*pX|BC zR1SsGZ{n9~`=ia-3&4FT3-rirR57-G-sa2JH;(Y&Xs5)<7-B#ukxyOTNJRc9G5A8v zb)ZXA3X~JrqLg7F)1!n=wWg}%0TO2^cNi`pk+*wf36UZj1T_*owifmmBlT2fJoQxdw*21nr|%a~ zr>3^4Irc>&)3zN+2S?a|zF6j)O~%Xdx>cJn@P<%+5-kq}Um*-Pah!8^p%&Z)rKy@q-SzODswJ|6tbl3L!K1uesJJ&QJSd)ml z$6=dM8<3>pzZai*&($cKFVP&=P4gG5a=Iwr&AMNktzmCJj~)9{w*&xIxlik;&MIPq6-MDSZGmiV0 zRQn$Q51t56N}Pt#ZC&AnU+vGt8x0S(;qTMjIesz2j30?pf lock(g_pdfium_mutex); + if (!init_pdfium() || !get_font_size_func || textPagePtr == 0 || index < 0) return 0.0; return get_font_size_func(reinterpret_cast(textPagePtr), index); } extern "C" JNIEXPORT jint JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontWeight(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) { - if (!init_pdfium() || !get_font_weight_func) return 0; + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || !get_font_weight_func || textPagePtr == 0 || index < 0) return 0; return get_font_weight_func(reinterpret_cast(textPagePtr), index); } // Bulk extraction for blazing fast formatting processing extern "C" JNIEXPORT jfloatArray JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) { - if (!init_pdfium() || !get_font_size_func || count <= 0) return nullptr; + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || !get_font_size_func || textPagePtr == 0 || count <= 0) return nullptr; jfloatArray result = env->NewFloatArray(count); jfloat *fill = new jfloat[count]; @@ -196,7 +199,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclas extern "C" JNIEXPORT jintArray JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) { - if (!init_pdfium() || !get_font_weight_func || count <= 0) return nullptr; + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || !get_font_weight_func || textPagePtr == 0 || count <= 0) return nullptr; jintArray result = env->NewIntArray(count); jint *fill = new jint[count]; @@ -210,7 +214,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jcl extern "C" JNIEXPORT jintArray JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) { - if (!init_pdfium() || !get_font_info_func || count <= 0) return nullptr; + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || !get_font_info_func || textPagePtr == 0 || count <= 0) return nullptr; jintArray result = env->NewIntArray(count); jint *fill = new jint[count]; @@ -226,7 +231,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclas extern "C" JNIEXPORT jfloatArray JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) { - if (!init_pdfium() || !get_char_box_func || count <= 0) return nullptr; + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || !get_char_box_func || textPagePtr == 0 || count <= 0) return nullptr; const int stride = 4; jfloatArray result = env->NewFloatArray(count * stride); @@ -247,7 +253,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclas extern "C" JNIEXPORT jstring JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) { - std::lock_guard lock(g_pdfium_mutex); + std::lock_guard lock(g_pdfium_mutex); if (!init_pdfium() || !get_annot_func || !get_annot_string_func || pagePtr == 0) return nullptr; void* page = reinterpret_cast(pagePtr); @@ -294,24 +300,23 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass extern "C" JNIEXPORT jint JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectCount(JNIEnv *env, jclass clazz, jlong pagePtr) { - if (!init_pdfium() || !count_objects_func) return 0; + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || !count_objects_func || pagePtr == 0) return 0; return count_objects_func(reinterpret_cast(pagePtr)); } extern "C" JNIEXPORT jint JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { - if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0; - const int object_count = count_objects_func(reinterpret_cast(pagePtr)); - if (index >= object_count) return 0; + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0; void* obj = get_object_func(reinterpret_cast(pagePtr), index); return obj ? get_object_type_func(obj) : 0; } extern "C" JNIEXPORT jboolean JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) { - if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE; - const int object_count = count_objects_func(reinterpret_cast(pagePtr)); - if (index >= object_count) return JNI_FALSE; + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE; void* obj = get_object_func(reinterpret_cast(pagePtr), index); if (!obj) return JNI_FALSE; @@ -326,9 +331,13 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *en extern "C" JNIEXPORT jintArray JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) { - if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || !get_image_bitmap_func || !bitmap_get_buffer_func || pagePtr == 0 || index < 0 || dimens == nullptr) return nullptr; - const int object_count = count_objects_func(reinterpret_cast(pagePtr)); - if (index >= object_count) return nullptr; + std::lock_guard lock(g_pdfium_mutex); + if (!init_pdfium() || !get_object_func || !get_object_type_func || !get_image_bitmap_func || + !bitmap_get_width_func || !bitmap_get_height_func || !bitmap_get_stride_func || + !bitmap_get_buffer_func || !bitmap_destroy_func || + pagePtr == 0 || index < 0 || dimens == nullptr) { + return nullptr; + } void* obj = get_object_func(reinterpret_cast(pagePtr), index); if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE @@ -379,6 +388,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jcl extern "C" JNIEXPORT jboolean JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jclass clazz) { + std::lock_guard lock(g_pdfium_mutex); init_pdfium(); // Return true if we have ANY way to handle actions return (do_annot_action_func || get_link_action_func) ? JNI_TRUE : JNI_FALSE; @@ -386,6 +396,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jcl extern "C" JNIEXPORT jint JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { + std::lock_guard lock(g_pdfium_mutex); if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return -1; void* page = reinterpret_cast(pagePtr); @@ -412,6 +423,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, extern "C" JNIEXPORT jfloatArray JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { + std::lock_guard lock(g_pdfium_mutex); if (!init_pdfium() || !get_annot_count_func || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr; void* page = reinterpret_cast(pagePtr); @@ -432,13 +444,14 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jc extern "C" JNIEXPORT jint JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) { - std::lock_guard lock(g_pdfium_mutex); + std::lock_guard lock(g_pdfium_mutex); if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0; return get_annot_count_func(reinterpret_cast(pagePtr)); } extern "C" JNIEXPORT jint JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { + std::lock_guard lock(g_pdfium_mutex); if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0; void* annot = get_annot_func(reinterpret_cast(pagePtr), index); return annot ? get_annot_subtype_func(annot) : 0; @@ -446,6 +459,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass extern "C" JNIEXPORT jfloatArray JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { + std::lock_guard lock(g_pdfium_mutex); if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr; void* annot = get_annot_func(reinterpret_cast(pagePtr), index); if (!annot) return nullptr; @@ -460,7 +474,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass cl extern "C" JNIEXPORT jboolean JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) { - std::lock_guard lock(g_pdfium_mutex); + std::lock_guard lock(g_pdfium_mutex); if (!init_pdfium() || pagePtr == 0) return JNI_FALSE; void* page = reinterpret_cast(pagePtr); @@ -527,7 +541,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl extern "C" JNIEXPORT jstring JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getLinkInfoAtPoint(JNIEnv *env, jclass clazz, jlong docPtr, jlong pagePtr, jdouble x, jdouble y) { - std::lock_guard lock(g_pdfium_mutex); + std::lock_guard lock(g_pdfium_mutex); if (!init_pdfium()) { LOGE("PdfLinkDiagnostic: init_pdfium failed."); return nullptr; diff --git a/app/src/main/java/com/aryan/reader/AiSettingsScreen.kt b/app/src/main/java/com/aryan/reader/AiSettingsScreen.kt new file mode 100644 index 0000000..7e2cb11 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/AiSettingsScreen.kt @@ -0,0 +1,314 @@ +package com.aryan.reader + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AiSettingsScreen( + onBackClick: () -> Unit +) { + val context = LocalContext.current + var settings by remember { mutableStateOf(loadAiByokSettings(context)) } + var selectedProvider by remember { mutableStateOf("gemini") } + var providerMenuExpanded by remember { mutableStateOf(false) } + var pendingKey by remember { mutableStateOf("") } + var showSaveConfirm by remember { mutableStateOf(false) } + var providerToDelete by remember { mutableStateOf(null) } + + fun refresh() { + settings = loadAiByokSettings(context) + } + + fun updateModels(newSettings: AiByokSettings) { + saveAiByokSettings(context, newSettings) + settings = loadAiByokSettings(context) + } + + Scaffold( + modifier = Modifier.statusBarsPadding(), + topBar = { + CustomTopAppBar( + title = { Text("AI keys and models") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + SavedKeyRow("Gemini", maskedAiByokKey(context, "gemini"), onDelete = { providerToDelete = "gemini" }) + SavedKeyRow("Groq", maskedAiByokKey(context, "groq"), onDelete = { providerToDelete = "groq" }) + + HorizontalDivider() + + Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + ExposedDropdownMenuBox( + expanded = providerMenuExpanded, + onExpandedChange = { providerMenuExpanded = it }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selectedProvider.replaceFirstChar { it.titlecase() }, + onValueChange = {}, + readOnly = true, + label = { Text("Provider") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = providerMenuExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor() + ) + ExposedDropdownMenu( + expanded = providerMenuExpanded, + onDismissRequest = { providerMenuExpanded = false } + ) { + listOf("gemini", "groq").forEach { provider -> + DropdownMenuItem( + text = { Text(provider.replaceFirstChar { it.titlecase() }) }, + onClick = { + selectedProvider = provider + providerMenuExpanded = false + }, + trailingIcon = if (provider == selectedProvider) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else null + ) + } + } + } + OutlinedTextField( + value = pendingKey, + onValueChange = { pendingKey = it }, + label = { Text("API key") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { showSaveConfirm = true }, + enabled = pendingKey.isNotBlank(), + modifier = Modifier.align(Alignment.End) + ) { + Text("Save key") + } + + HorizontalDivider() + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Use one model for all features", style = MaterialTheme.typography.titleMedium) + Text( + "When off, each reader AI feature uses its own selected model.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = settings.useOneModel, + onCheckedChange = { updateModels(settings.copy(useOneModel = it)) } + ) + } + + if (settings.useOneModel) { + ModelSelector( + title = "All AI features", + description = "Smart dictionary, summaries, and recaps all use this model.", + selectedId = settings.modelForAll, + onSelected = { updateModels(settings.copy(modelForAll = it)) } + ) + } else { + ModelSelector( + title = "Smart dictionary", + description = "Used when defining selected words or phrases.", + selectedId = settings.defineModel, + onSelected = { updateModels(settings.copy(defineModel = it)) } + ) + ModelSelector( + title = "Summaries", + description = "Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini.", + selectedId = settings.summarizeModel, + onSelected = { updateModels(settings.copy(summarizeModel = it)) } + ) + ModelSelector( + title = "Recaps", + description = "Used for story recap generation.", + selectedId = settings.recapModel, + onSelected = { updateModels(settings.copy(recapModel = it)) } + ) + } + + ModelSelector( + title = "Cloud TTS", + description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.", + selectedId = settings.ttsModel, + options = listOf(AiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)), + onSelected = { updateModels(settings.copy(ttsModel = it)) } + ) + } + } + + if (showSaveConfirm) { + AlertDialog( + onDismissRequest = { showSaveConfirm = false }, + title = { Text("Save ${selectedProvider.replaceFirstChar { it.titlecase() }} key?") }, + text = { Text("After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it.") }, + confirmButton = { + TextButton(onClick = { + saveAiByokKey(context, selectedProvider, pendingKey) + pendingKey = "" + showSaveConfirm = false + refresh() + }) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = { showSaveConfirm = false }) { Text("Cancel") } + } + ) + } + + providerToDelete?.let { provider -> + AlertDialog( + onDismissRequest = { providerToDelete = null }, + title = { Text("Delete ${provider.replaceFirstChar { it.titlecase() }} key?") }, + text = { Text("Features using this provider will stop working until a new key is saved.") }, + confirmButton = { + TextButton(onClick = { + deleteAiByokKey(context, provider) + providerToDelete = null + refresh() + }) { Text("Delete") } + }, + dismissButton = { + TextButton(onClick = { providerToDelete = null }) { Text("Cancel") } + } + ) + } +} + +@Composable +private fun SavedKeyRow( + label: String, + maskedKey: String, + onDelete: () -> Unit +) { + ListItem( + headlineContent = { Text(label) }, + supportingContent = { + Text(maskedKey.ifBlank { "No key saved" }) + }, + trailingContent = { + IconButton(onClick = onDelete, enabled = maskedKey.isNotBlank()) { + Icon(Icons.Default.Delete, contentDescription = "Delete $label key") + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModelSelector( + title: String, + description: String, + selectedId: String, + options: List = aiByokModelOptions, + onSelected: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val selected = options.firstOrNull { it.id == selectedId } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selected?.label ?: "No model selected", + onValueChange = {}, + readOnly = true, + label = { Text("Model") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text("No model selected") }, + onClick = { + onSelected("") + expanded = false + }, + trailingIcon = if (selectedId.isBlank()) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else null + ) + options.forEach { option -> + DropdownMenuItem( + text = { Text(option.label) }, + onClick = { + onSelected(option.id) + expanded = false + }, + trailingIcon = if (option.id == selected?.id) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else null + ) + } + } + } + } +} diff --git a/app/src/main/java/com/aryan/reader/AndroidFolderPathResolver.kt b/app/src/main/java/com/aryan/reader/AndroidFolderPathResolver.kt new file mode 100644 index 0000000..c03bdeb --- /dev/null +++ b/app/src/main/java/com/aryan/reader/AndroidFolderPathResolver.kt @@ -0,0 +1,53 @@ +package com.aryan.reader + +import android.net.Uri +import androidx.core.net.toUri +import com.aryan.reader.data.RecentFileItem +import timber.log.Timber + +class AndroidFolderPathResolver : FolderPathResolver { + override fun relativeFolderSegments(item: RecentFileItem): List { + val documentUriString = item.uriString ?: return emptyList() + val rootFolderUriString = item.sourceFolderUri ?: return emptyList() + + return try { + val documentUri = documentUriString.toUri() + val rootFolderUri = rootFolderUriString.toUri() + val rootDocId = rootFolderUri.treeDocumentIdOrNull() ?: return emptyList() + val documentId = documentUri.documentIdOrNull() ?: return emptyList() + + val rootPath = rootDocId.substringAfter(':', "") + val documentPath = documentId.substringAfter(':', "") + val relativeDocumentPath = when { + rootPath.isBlank() -> documentPath + documentPath == rootPath -> "" + documentPath.startsWith("$rootPath/") -> documentPath.removePrefix("$rootPath/") + else -> documentPath + } + + relativeDocumentPath + .substringBeforeLast('/', "") + .split('/') + .map { Uri.decode(it).trim() } + .filter { it.isNotEmpty() } + } catch (e: Exception) { + Timber.tag("FolderShelves").w(e, "Failed to derive relative folder path for ${item.displayName}") + emptyList() + } + } + + private fun Uri.treeDocumentIdOrNull(): String? { + val segments = pathSegments + val treeIndex = segments.indexOf("tree") + return segments.getOrNull(treeIndex + 1) + } + + private fun Uri.documentIdOrNull(): String? { + val segments = pathSegments + val documentIndex = segments.indexOf("document") + if (documentIndex >= 0) { + return segments.getOrNull(documentIndex + 1) + } + return treeDocumentIdOrNull() + } +} diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index 921919b..1d43227 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -43,13 +43,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState 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 kotlinx.coroutines.delay object AppDestinations { const val MAIN_ROUTE = "main" @@ -57,19 +62,74 @@ object AppDestinations { const val EPUB_READER_ROUTE = "epub_reader" const val PRO_SCREEN_ROUTE = "pro_screen" const val FEEDBACK_SCREEN_ROUTE = "feedback_screen_route" + const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route" const val FONTS_SCREEN_ROUTE = "fonts_screen_route" + const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route" +} + +private fun NavHostController.isReadyForBackStackChange(): Boolean { + return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED +} + +private suspend fun NavHostController.awaitReadyForBackStackChange() { + while (!isReadyForBackStackChange()) { + delay(32) + } } private fun NavHostController.navigateSingleTopTo(route: String) { - navigate(route) { - launchSingleTop = true - restoreState = true - popUpTo(graph.startDestinationId) { - saveState = true + if (!isReadyForBackStackChange()) { + Timber.d("Skipping navigation to $route because the current entry is not resumed yet.") + return + } + + try { + navigate(route) { + launchSingleTop = true + popUpTo(graph.startDestinationId) { + saveState = false + } + } + } catch (e: IllegalStateException) { + Timber.w(e, "Navigation to $route ignored because the back stack is mid-transition.") + } +} + +private fun NavHostController.navigateToMain() { + navigateSingleTopTo(AppDestinations.MAIN_ROUTE) +} + +private fun NavHostController.navigateIfReady(route: String) { + if (currentDestination?.route == route) return + navigateSingleTopTo(route) +} + +private fun NavHostController.popBackStackIfReady(): Boolean { + if (!isReadyForBackStackChange()) { + Timber.d("Skipping popBackStack because the current entry is not resumed yet.") + return false + } + + return try { + popBackStack() + } catch (e: IllegalStateException) { + Timber.w(e, "popBackStack ignored because the back stack is mid-transition.") + false + } +} + +private suspend fun NavHostController.syncRouteTo(route: String) { + awaitReadyForBackStackChange() + if (currentDestination?.route != route) { + if (route == AppDestinations.MAIN_ROUTE) { + navigateToMain() + } else { + navigateSingleTopTo(route) } } } +@androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable @@ -80,34 +140,31 @@ fun AppNavigation( ) { Timber.d("AppNavigation composable invoked.") val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = currentBackStackEntry?.destination?.route - LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { + LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { if (!uiState.isLoading) { - try { - when (uiState.selectedFileType) { - FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> { - if (uiState.selectedPdfUri != null) { - if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) { - navController.navigateSingleTopTo(AppDestinations.PDF_VIEWER_ROUTE) - } - } - } - FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> { - if (uiState.selectedEpubBook != null) { - if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) { - navController.navigateSingleTopTo(AppDestinations.EPUB_READER_ROUTE) - } - } - } - null -> { - val currentRoute = navController.currentBackStackEntry?.destination?.route - if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) { - navController.navigateSingleTopTo(AppDestinations.MAIN_ROUTE) + when (uiState.selectedFileType) { + FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> { + if (uiState.selectedPdfUri != null) { + if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) { + navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE) } } } - } catch (e: IllegalStateException) { - Timber.w(e, "Navigation transition already in progress, ignoring.") + FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> { + if (uiState.selectedEpubBook != null) { + if (currentRoute != AppDestinations.EPUB_READER_ROUTE) { + navController.syncRouteTo(AppDestinations.EPUB_READER_ROUTE) + } + } + } + null -> { + if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) { + navController.syncRouteTo(AppDestinations.MAIN_ROUTE) + } + } } } } @@ -153,7 +210,7 @@ fun AppNavigation( } }, onNavigateToPro = { - navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) + navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE) }, viewModel = viewModel ) @@ -226,7 +283,7 @@ fun AppNavigation( } }, onNavigateToPro = { - navController.navigate(AppDestinations.PRO_SCREEN_ROUTE) + navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE) }, onRenderModeChange = viewModel::setRenderMode, customFonts = customFonts, @@ -276,7 +333,7 @@ fun AppNavigation( composable(route = AppDestinations.PRO_SCREEN_ROUTE) { ProScreen( viewModel = viewModel, - onNavigateBack = { navController.popBackStack() } + onNavigateBack = { navController.popBackStackIfReady() } ) } @@ -286,10 +343,22 @@ fun AppNavigation( ) } + composable(route = AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) { + SupportProjectScreen( + navController = navController + ) + } + composable(route = AppDestinations.FONTS_SCREEN_ROUTE) { FontsScreen( viewModel = viewModel, - onBackClick = { navController.popBackStack() } + onBackClick = { navController.popBackStackIfReady() } + ) + } + + composable(route = AppDestinations.AI_SETTINGS_SCREEN_ROUTE) { + AiSettingsScreen( + onBackClick = { navController.popBackStackIfReady() } ) } } diff --git a/app/src/main/java/com/aryan/reader/AppUiModels.kt b/app/src/main/java/com/aryan/reader/AppUiModels.kt new file mode 100644 index 0000000..d3e7741 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/AppUiModels.kt @@ -0,0 +1,127 @@ +package com.aryan.reader + +import android.net.Uri +import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.data.TagEntity +import com.aryan.reader.epub.CalibreBundleResult +import com.aryan.reader.epub.EpubBook +import com.aryan.reader.paginatedreader.Locator +import java.util.Date + +data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false) + +data class ImportResult( + val internalUri: Uri, + val bookId: String, + val type: FileType, + val bundleResult: CalibreBundleResult? = null +) + +data class UserData( + val uid: String, + val displayName: String?, + val photoUrl: String?, + val email: String? +) + +data class NavigationEvent( + val route: String, + val bookId: String? = null, + val uri: Uri? = null +) + +enum class AppThemeMode { + SYSTEM, + LIGHT, + DARK +} + +enum class AppContrastOption(val value: Double) { + STANDARD(0.0), + MEDIUM(0.5), + HIGH(1.0) +} + +data class CustomAppTheme( + val id: String, + val name: String, + val seedColor: androidx.compose.ui.graphics.Color +) + +data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?) + +data class DeviceLimitReachedState( + val isLimitReached: Boolean = false, + val registeredDevices: List = emptyList() +) + +data class ReaderScreenState( + val selectedPdfUri: Uri? = null, + val selectedBookId: String? = null, + val selectedEpubBook: EpubBook? = null, + val selectedEpubUri: Uri? = null, + val selectedFileType: FileType? = null, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val contextualActionItems: Set = emptySet(), + val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL, + val sortOrder: SortOrder = SortOrder.RECENT, + val initialLocator: Locator? = null, + val initialCfi: String? = null, + val initialBookmarksJson: String? = null, + val initialHighlightsJson: String? = null, + val initialPageInBook: Int? = null, + val shelves: List = emptyList(), + val viewingShelfId: String? = null, + val isAddingBooksToShelf: Boolean = false, + val showCreateShelfDialog: Boolean = false, + val mainScreenStartPage: Int = 0, + val libraryScreenStartPage: Int = 0, + val showRenameShelfDialogFor: String? = null, + val showDeleteShelfDialogFor: String? = null, + val addBooksSource: AddBooksSource = AddBooksSource.UNSHELVED, + val booksSelectedForAdding: Set = emptySet(), + val booksAvailableForAdding: List = emptyList(), + val contextualActionShelfIds: Set = emptySet(), + val currentUser: UserData? = null, + val isAuthMenuExpanded: Boolean = false, + val isProUser: Boolean = false, + val credits: Int = 0, + val isSyncEnabled: Boolean = false, + val isFolderSyncEnabled: Boolean = false, + val bannerMessage: BannerMessage? = null, + val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(), + val isReplacingDevice: Boolean = false, + val isRequestingDrivePermission: Boolean = false, + val downloadingBookIds: Set = emptySet(), + val uploadingBookIds: Set = emptySet(), + val syncedFolders: List = emptyList(), + val lastFolderScanTime: Long? = null, + val hasUnreadFeedback: Boolean = false, + val searchQuery: String = "", + val isSearchActive: Boolean = false, + val isRefreshing: Boolean = false, + val reflowProgress: Float? = null, + val recentFiles: List = emptyList(), + val allRecentFiles: List = emptyList(), + val rawLibraryFiles: List = emptyList(), + val pinnedHomeBookIds: Set = emptySet(), + val pinnedLibraryBookIds: Set = emptySet(), + val libraryFilters: LibraryFilters = LibraryFilters(), + val recentFilesLimit: Int = 0, + val isTabsEnabled: Boolean = false, + val openTabIds: List = emptyList(), + val openTabs: List = emptyList(), + val activeTabBookId: String? = null, + val showExternalFileSavePromptFor: String? = null, + val externalFileBehavior: String = "ASK", + val useStrictFileFilter: 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 customAppThemes: List = emptyList(), + val allTags: List = emptyList(), + val showTagSelectionDialogFor: Set = emptySet(), +) diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index 845270d..98a7f64 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -4,8 +4,15 @@ package com.aryan.reader import android.content.Context +import android.graphics.BitmapFactory +import android.net.Uri +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties import android.speech.tts.TextToSpeech import android.speech.tts.Voice +import android.util.Base64 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke @@ -19,6 +26,7 @@ import androidx.compose.foundation.gestures.drag 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 @@ -35,8 +43,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -76,7 +88,9 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PlainTooltip import androidx.compose.material3.RichTooltip +import androidx.compose.material3.Slider import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text @@ -117,6 +131,7 @@ import androidx.compose.ui.graphics.ImageShader import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb @@ -126,7 +141,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -182,10 +196,16 @@ import org.commonmark.parser.Parser import org.json.JSONArray import org.json.JSONObject import timber.log.Timber +import java.io.ByteArrayOutputStream import java.io.File import java.net.HttpURLConnection import java.net.URL +import java.security.KeyStore import java.util.Locale +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt @@ -199,6 +219,232 @@ const val recapEndpoint = "/recap" const val recapUrl = aiServerBasePath + recapEndpoint const val PREF_NATIVE_TTS_VOICE = "native_tts_voice_name" +private const val AI_PREFS_NAME = "ai_byok_prefs" +private const val PREF_AI_HIDE_READER_FEATURES = "hide_reader_ai_features" +private const val PREF_AI_GEMINI_KEY = "gemini_key" +private const val PREF_AI_GROQ_KEY = "groq_key" +private const val PREF_AI_USE_ONE_MODEL = "use_one_model" +private const val PREF_AI_MODEL_ALL = "model_all" +private const val PREF_AI_MODEL_DEFINE = "model_define" +private const val PREF_AI_MODEL_SUMMARIZE = "model_summarize" +private const val PREF_AI_MODEL_RECAP = "model_recap" +private const val PREF_AI_TTS_MODEL = "tts_model" +private const val PREF_AI_MODEL_EMPTY_MIGRATION_DONE = "model_empty_migration_done" +private const val AI_KEYSTORE_ALIAS = "reader_ai_byok_key_v1" +private const val ENCRYPTION_PREFIX = "v1:" +const val GEMINI_CLOUD_TTS_MODEL = "gemini-3.1-flash-live-preview" +const val GEMINI_CLOUD_TTS_MODEL_ID = "gemini:$GEMINI_CLOUD_TTS_MODEL" + +enum class AiFeature { DEFINE, SUMMARIZE, RECAP } + +private fun AiFeature.displayName(): String { + return when (this) { + AiFeature.DEFINE -> "Smart dictionary" + AiFeature.SUMMARIZE -> "Summaries" + AiFeature.RECAP -> "Recaps" + } +} + +data class AiModelOption( + val provider: String, + val name: String, + val label: String = "${provider.replaceFirstChar { it.titlecase(Locale.ROOT) }} - $name" +) { + val id: String = "$provider:$name" +} + +data class AiByokSettings( + val geminiKey: String = "", + val groqKey: String = "", + val useOneModel: Boolean = true, + val modelForAll: String = "", + val defineModel: String = "", + val summarizeModel: String = "", + val recapModel: String = "", + val ttsModel: String = "" +) + +val aiByokModelOptions = listOf( + AiModelOption("groq", "qwen/qwen3-32b"), + AiModelOption("groq", "llama-3.3-70b-versatile"), + AiModelOption("groq", "llama-3.1-8b-instant"), + AiModelOption("gemini", "gemma-4-26b-a4b-it"), + AiModelOption("gemini", "gemma-4-31b-it"), + AiModelOption("gemini", "gemini-flash-lite-latest"), + AiModelOption("gemini", "gemini-2.5-flash-lite"), + AiModelOption("gemini", "gemini-3.1-flash-lite-preview") +) + +private fun Context.aiPrefs() = getSharedPreferences(AI_PREFS_NAME, Context.MODE_PRIVATE) + +private fun getAiSecretKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(AI_KEYSTORE_ALIAS, null) as? SecretKey)?.let { return it } + + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val keySpec = KeyGenParameterSpec.Builder( + AI_KEYSTORE_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build() + keyGenerator.init(keySpec) + return keyGenerator.generateKey() +} + +private fun encryptAiSecret(value: String): String { + if (value.isBlank()) return "" + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, getAiSecretKey()) + val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val combined = cipher.iv + encrypted + return ENCRYPTION_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP) +} + +private fun decryptAiSecret(value: String?): String { + if (value.isNullOrBlank()) return "" + if (!value.startsWith(ENCRYPTION_PREFIX)) return value + return try { + val combined = Base64.decode(value.removePrefix(ENCRYPTION_PREFIX), Base64.NO_WRAP) + val iv = combined.copyOfRange(0, 12) + val encrypted = combined.copyOfRange(12, combined.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, getAiSecretKey(), GCMParameterSpec(128, iv)) + String(cipher.doFinal(encrypted), Charsets.UTF_8) + } catch (e: Exception) { + Timber.e(e, "Failed to decrypt AI key") + "" + } +} + +private fun maskedAiSecret(value: String): String { + val trimmed = value.trim() + return when { + trimmed.isBlank() -> "" + trimmed.length <= 6 -> "***" + else -> "${trimmed.take(3)}...${trimmed.takeLast(3)}" + } +} + +fun loadAiByokSettings(context: Context): AiByokSettings { + val prefs = context.aiPrefs() + if (!prefs.getBoolean(PREF_AI_MODEL_EMPTY_MIGRATION_DONE, false)) { + prefs.edit { + if (prefs.getString(PREF_AI_MODEL_ALL, "") == "gemini:gemini-flash-lite-latest") putString(PREF_AI_MODEL_ALL, "") + if (prefs.getString(PREF_AI_MODEL_DEFINE, "") == "groq:qwen/qwen3-32b") putString(PREF_AI_MODEL_DEFINE, "") + if (prefs.getString(PREF_AI_MODEL_SUMMARIZE, "") == "gemini:gemini-flash-lite-latest") putString(PREF_AI_MODEL_SUMMARIZE, "") + if (prefs.getString(PREF_AI_MODEL_RECAP, "") == "gemini:gemini-flash-lite-latest") putString(PREF_AI_MODEL_RECAP, "") + putBoolean(PREF_AI_MODEL_EMPTY_MIGRATION_DONE, true) + } + } + val settings = AiByokSettings( + geminiKey = decryptAiSecret(prefs.getString(PREF_AI_GEMINI_KEY, "")), + groqKey = decryptAiSecret(prefs.getString(PREF_AI_GROQ_KEY, "")), + useOneModel = prefs.getBoolean(PREF_AI_USE_ONE_MODEL, true), + modelForAll = prefs.getString(PREF_AI_MODEL_ALL, "") ?: "", + defineModel = prefs.getString(PREF_AI_MODEL_DEFINE, "") ?: "", + summarizeModel = prefs.getString(PREF_AI_MODEL_SUMMARIZE, "") ?: "", + recapModel = prefs.getString(PREF_AI_MODEL_RECAP, "") ?: "", + ttsModel = prefs.getString(PREF_AI_TTS_MODEL, "") ?: "" + ) + val geminiStored = prefs.getString(PREF_AI_GEMINI_KEY, "").orEmpty() + val groqStored = prefs.getString(PREF_AI_GROQ_KEY, "").orEmpty() + if ((geminiStored.isNotBlank() && !geminiStored.startsWith(ENCRYPTION_PREFIX)) || + (groqStored.isNotBlank() && !groqStored.startsWith(ENCRYPTION_PREFIX)) + ) { + saveAiByokSettings(context, settings) + } + return settings +} + +fun saveAiByokSettings(context: Context, settings: AiByokSettings) { + context.aiPrefs().edit { + putString(PREF_AI_GEMINI_KEY, encryptAiSecret(settings.geminiKey.trim())) + putString(PREF_AI_GROQ_KEY, encryptAiSecret(settings.groqKey.trim())) + putBoolean(PREF_AI_USE_ONE_MODEL, settings.useOneModel) + putString(PREF_AI_MODEL_ALL, settings.modelForAll) + putString(PREF_AI_MODEL_DEFINE, settings.defineModel) + putString(PREF_AI_MODEL_SUMMARIZE, settings.summarizeModel) + putString(PREF_AI_MODEL_RECAP, settings.recapModel) + putString(PREF_AI_TTS_MODEL, settings.ttsModel) + } +} + +fun saveAiByokKey(context: Context, provider: String, key: String) { + val current = loadAiByokSettings(context) + val updated = when (provider) { + "gemini" -> current.copy(geminiKey = key) + "groq" -> current.copy(groqKey = key) + else -> current + } + saveAiByokSettings(context, updated) +} + +fun deleteAiByokKey(context: Context, provider: String) { + saveAiByokKey(context, provider, "") +} + +fun maskedAiByokKey(context: Context, provider: String): String { + val settings = loadAiByokSettings(context) + return maskedAiSecret( + when (provider) { + "gemini" -> settings.geminiKey + "groq" -> settings.groqKey + else -> "" + } + ) +} + +fun loadHideReaderAiFeatures(context: Context): Boolean { + return context.aiPrefs().getBoolean(PREF_AI_HIDE_READER_FEATURES, false) +} + +fun saveHideReaderAiFeatures(context: Context, hidden: Boolean) { + context.aiPrefs().edit { putBoolean(PREF_AI_HIDE_READER_FEATURES, hidden) } +} + +fun hasAiByokKey(context: Context): Boolean { + val settings = loadAiByokSettings(context) + return settings.geminiKey.isNotBlank() || settings.groqKey.isNotBlank() +} + +@Suppress("KotlinConstantConditions") +fun areReaderAiFeaturesEnabled(context: Context): Boolean { + if (loadHideReaderAiFeatures(context)) return false + if (BuildConfig.FLAVOR != "oss") return true + return !BuildConfig.IS_OFFLINE && hasAiByokKey(context) +} + +@Suppress("KotlinConstantConditions") +fun isByokCloudTtsAvailable(context: Context): Boolean { + val settings = loadAiByokSettings(context) + return BuildConfig.FLAVOR == "oss" && + !BuildConfig.IS_OFFLINE && + settings.geminiKey.isNotBlank() && + settings.ttsModel == GEMINI_CLOUD_TTS_MODEL_ID +} + +private fun AiByokSettings.modelIdFor(feature: AiFeature): String { + return if (useOneModel) modelForAll else when (feature) { + AiFeature.DEFINE -> defineModel + AiFeature.SUMMARIZE -> summarizeModel + AiFeature.RECAP -> recapModel + } +} + +fun aiModelById(id: String): AiModelOption? { + return aiByokModelOptions.firstOrNull { it.id == id } +} + +private fun AiByokSettings.apiKeyFor(provider: String): String { + return when (provider) { + "gemini" -> geminiKey + "groq" -> groqKey + else -> "" + }.trim() +} data class SearchResult( val locationInSource: Int, @@ -853,6 +1099,28 @@ suspend fun fetchAiDefinition( } Timber.d("Fetching AI definition for: '$text'") + @Suppress("KotlinConstantConditions") + if (BuildConfig.FLAVOR == "oss") { + if (BuildConfig.IS_OFFLINE) { + onError(context.getString(R.string.error_network_check_connection)) + onFinish() + return + } + val systemInstruction = "You are an AI-powered dictionary. Your goal is to provide a concise and easy-to-understand definition for the given word, phrase or paragraphs. Keep the explanation brief. Respond only with the definition text, without any preamble. Do not send your thoughts, only the final definition you arrived on. no emoji." + callByokTextAi( + context = context, + feature = AiFeature.DEFINE, + systemInstruction = systemInstruction, + userPrompt = "Define: \"$text\"", + temperature = 0.1, + maxTokens = 256, + onUpdate = onUpdate, + onError = onError + ) + onFinish() + return + } + withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null try { @@ -925,6 +1193,330 @@ fun countWords(text: String): Int { return text.trim().split(Regex("\\s+")).filter { it.isNotBlank() }.size } +private fun streamGeminiAiResponse( + connection: HttpURLConnection, + onUpdate: (String) -> Unit, + onError: (String) -> Unit +): Boolean { + var hasReceivedData = false + connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> + var buffer = "" + var line: String? + while (reader.readLine().also { line = it } != null) { + buffer += line + while (true) { + val start = buffer.indexOf('{') + if (start == -1) { + buffer = "" + break + } + var braceCount = 0 + var end = -1 + charLoop@ for (i in start until buffer.length) { + when (buffer[i]) { + '{' -> braceCount++ + '}' -> { + braceCount-- + if (braceCount == 0) { + end = i + break@charLoop + } + } + } + } + if (end == -1) break + val jsonString = buffer.substring(start, end + 1) + buffer = buffer.substring(end + 1) + try { + val jsonResponse = JSONObject(jsonString) + jsonResponse.optJSONArray("candidates") + ?.optJSONObject(0) + ?.optJSONObject("content") + ?.optJSONArray("parts") + ?.optJSONObject(0) + ?.optString("text") + ?.takeIf { it.isNotEmpty() } + ?.let { + onUpdate(it) + hasReceivedData = true + } + if (jsonResponse.optJSONArray("candidates")?.optJSONObject(0)?.optString("finishReason") == "SAFETY") { + onError("Blocked for safety reasons.") + } + } catch (e: Exception) { + Timber.w(e, "Could not parse Gemini BYOK stream object") + } + } + } + } + return hasReceivedData +} + +private fun streamGroqAiResponse( + connection: HttpURLConnection, + onUpdate: (String) -> Unit, + onError: (String) -> Unit +): Boolean { + var hasReceivedData = false + var inThink = false + var thinkBuffer = "" + + fun cleanChunk(text: String): String { + thinkBuffer += text + val out = StringBuilder() + while (true) { + if (inThink) { + val end = thinkBuffer.indexOf("") + if (end == -1) { + if (thinkBuffer.length > 7) thinkBuffer = thinkBuffer.takeLast(7) + break + } + inThink = false + thinkBuffer = thinkBuffer.substring(end + 8) + } else { + val start = thinkBuffer.indexOf("") + if (start == -1) { + if (thinkBuffer.length > 6) { + out.append(thinkBuffer.dropLast(6)) + thinkBuffer = thinkBuffer.takeLast(6) + } + break + } + out.append(thinkBuffer.substring(0, start)) + inThink = true + thinkBuffer = thinkBuffer.substring(start + 7) + } + } + return out.toString() + } + + connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + val trimmed = line!!.trim() + if (!trimmed.startsWith("data: ")) continue + val data = trimmed.removePrefix("data: ").trim() + if (data == "[DONE]") continue + try { + val chunk = JSONObject(data) + .optJSONArray("choices") + ?.optJSONObject(0) + ?.optJSONObject("delta") + ?.optString("content") + .orEmpty() + val cleaned = cleanChunk(chunk) + if (cleaned.isNotEmpty()) { + onUpdate(cleaned) + hasReceivedData = true + } + } catch (e: Exception) { + Timber.w(e, "Could not parse Groq BYOK stream line") + } + } + } + if (!inThink && thinkBuffer.isNotBlank()) { + onUpdate(thinkBuffer) + hasReceivedData = true + } + return hasReceivedData +} + +private fun buildGroqPayload( + model: String, + systemInstruction: String, + userPrompt: String, + temperature: Double, + maxTokens: Int +): JSONObject { + return JSONObject().apply { + put("model", model) + put("messages", JSONArray().apply { + put(JSONObject().apply { + put("role", "system") + put("content", systemInstruction) + }) + put(JSONObject().apply { + put("role", "user") + put("content", userPrompt) + }) + }) + put("temperature", temperature) + put("top_p", 0.95) + put("max_tokens", maxTokens) + put("stream", true) + if (model.contains("qwen")) put("reasoning_effort", "none") + } +} + +suspend fun callByokTextAi( + context: Context, + feature: AiFeature, + systemInstruction: String, + userPrompt: String, + temperature: Double, + maxTokens: Int, + onUpdate: (String) -> Unit, + onError: (String) -> Unit +): Boolean = withContext(Dispatchers.IO) { + val settings = loadAiByokSettings(context) + val model = aiModelById(settings.modelIdFor(feature)) + if (model == null) { + onError("Choose a model for ${feature.displayName()} in AI key and model settings.") + return@withContext false + } + val apiKey = settings.apiKeyFor(model.provider) + if (apiKey.isBlank()) { + onError("Add a ${model.provider.replaceFirstChar { it.titlecase(Locale.ROOT) }} API key in AI key and model settings.") + return@withContext false + } + + var connection: HttpURLConnection? = null + try { + val url = if (model.provider == "groq") { + URL("https://api.groq.com/openai/v1/chat/completions") + } else { + URL("https://generativelanguage.googleapis.com/v1beta/models/${model.name}:streamGenerateContent?key=$apiKey") + } + connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8") + connection.setRequestProperty("Accept", "application/json") + if (model.provider == "groq") { + connection.setRequestProperty("Authorization", "Bearer $apiKey") + } + connection.connectTimeout = 15000 + connection.readTimeout = 120000 + connection.doOutput = true + connection.doInput = true + + val payload = if (model.provider == "groq") { + buildGroqPayload(model.name, systemInstruction, userPrompt, temperature, maxTokens) + } else { + JSONObject().apply { + put("contents", JSONArray().put(JSONObject().apply { + put("parts", JSONArray().put(JSONObject().apply { put("text", userPrompt) })) + })) + put("systemInstruction", JSONObject().apply { + put("parts", JSONArray().put(JSONObject().apply { put("text", systemInstruction) })) + }) + put("generationConfig", JSONObject().apply { + put("temperature", temperature) + put("topP", 0.95) + put("topK", 40) + put("maxOutputTokens", maxTokens) + put("response_mime_type", "text/plain") + if (feature == AiFeature.DEFINE && model.name.startsWith("gemini")) { + put("thinkingConfig", JSONObject().apply { + put("thinkingBudget", 0) + }) + } + }) + } + } + connection.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) } + + val responseCode = connection.responseCode + if (responseCode == HttpURLConnection.HTTP_OK) { + val hasData = if (model.provider == "groq") { + streamGroqAiResponse(connection, onUpdate, onError) + } else { + streamGeminiAiResponse(connection, onUpdate, onError) + } + if (!hasData) onError("The AI provider returned an empty response.") + hasData + } else { + val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null } + onError("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}") + false + } + } catch (e: Exception) { + Timber.e(e, "BYOK AI request failed") + onError(context.getString(R.string.error_network_check_connection)) + false + } finally { + connection?.disconnect() + } +} + +suspend fun callByokGeminiInlineAi( + context: Context, + feature: AiFeature, + mimeType: String, + base64Data: String, + systemInstruction: String, + temperature: Double, + maxTokens: Int, + onUpdate: (String) -> Unit, + onError: (String) -> Unit +): Boolean = withContext(Dispatchers.IO) { + val settings = loadAiByokSettings(context) + val model = aiModelById(settings.modelIdFor(feature)) + if (model == null) { + onError("Choose a model for ${feature.displayName()} in AI key and model settings.") + return@withContext false + } + if (model.provider != "gemini") { + onError("This summary needs a Gemini model because the selected Groq models do not support PDF/image input.") + return@withContext false + } + val apiKey = settings.geminiKey.trim() + if (apiKey.isBlank()) { + onError("Add a Gemini API key in AI key and model settings.") + return@withContext false + } + + var connection: HttpURLConnection? = null + try { + val url = URL("https://generativelanguage.googleapis.com/v1beta/models/${model.name}:streamGenerateContent?key=$apiKey") + connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8") + connection.setRequestProperty("Accept", "application/json") + connection.connectTimeout = 15000 + connection.readTimeout = 180000 + connection.doOutput = true + connection.doInput = true + + val payload = JSONObject().apply { + put("contents", JSONArray().put(JSONObject().apply { + put("parts", JSONArray().put(JSONObject().apply { + put("inlineData", JSONObject().apply { + put("mime_type", mimeType) + put("data", base64Data) + }) + })) + })) + put("systemInstruction", JSONObject().apply { + put("parts", JSONArray().put(JSONObject().apply { put("text", systemInstruction) })) + }) + put("generationConfig", JSONObject().apply { + put("temperature", temperature) + put("topP", 0.95) + put("topK", 40) + put("maxOutputTokens", maxTokens) + put("response_mime_type", "text/plain") + }) + } + connection.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) } + val responseCode = connection.responseCode + if (responseCode == HttpURLConnection.HTTP_OK) { + val hasData = streamGeminiAiResponse(connection, onUpdate, onError) + if (!hasData) onError("The AI provider returned an empty response.") + hasData + } else { + val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null } + onError("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}") + false + } + } catch (e: Exception) { + Timber.e(e, "BYOK inline AI request failed") + onError(context.getString(R.string.error_network_check_connection)) + false + } finally { + connection?.disconnect() + } +} + object MarkdownParser { fun parse(markdown: String): AnnotatedString { val parser = Parser.builder().build() @@ -1007,6 +1599,41 @@ suspend fun fetchRecap( return } + @Suppress("KotlinConstantConditions") + if (BuildConfig.FLAVOR == "oss") { + if (BuildConfig.IS_OFFLINE) { + onError(context.getString(R.string.error_network_recap)) + onFinish() + return + } + val systemInstruction = "You are a sophisticated reading assistant. You have to create a recap. Synthesize the provided past context and current chapter text into a cohesive summary of the reading session so far. Conclude exactly where the user is positioned currently. Do not add a preamble. Also Avoid including or mentioning text from administrative or boilerplate sections such as the introduction, copyright pages, preface, or table of contents; focus strictly on the core story or informative content. If the the book has multiple different short stories that came before then summarize them too, its a recap of the whole book up to this point." + val promptContext = buildString { + append("--- PREVIOUS CONTEXT (Summaries of read chapters) ---\n") + if (pastSummaries.isEmpty()) { + append("(None - User is in the first chapter)\n") + } else { + pastSummaries.forEachIndexed { index, summary -> + append("Chapter ${index + 1}: $summary\n\n") + } + } + append("\n--- CURRENT SESSION (Text read in current chapter) ---\n") + append(currentText) + append("\n\nBased strictly on the above, provide a recap of the content read so far.") + } + callByokTextAi( + context = context, + feature = AiFeature.RECAP, + systemInstruction = systemInstruction, + userPrompt = promptContext, + temperature = 0.3, + maxTokens = 4096, + onUpdate = onUpdate, + onError = onError + ) + onFinish() + return + } + withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null try { @@ -1092,10 +1719,12 @@ fun TtsSettingsSheet( if (!isVisible) return val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val isOss = BuildConfig.FLAVOR == "oss" - var selectedTabIndex by remember(currentMode) { mutableIntStateOf(if (currentMode == TtsPlaybackManager.TtsMode.CLOUD && !isOss) 0 else 1) } - val context = LocalContext.current + val isOss = BuildConfig.FLAVOR == "oss" + val isOssCloudAvailable = isByokCloudTtsAvailable(context) + var selectedTabIndex by remember(currentMode, isOssCloudAvailable) { + mutableIntStateOf(if (currentMode == TtsPlaybackManager.TtsMode.CLOUD && (!isOss || isOssCloudAvailable)) 0 else 1) + } val scope = rememberCoroutineScope() val samplePlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope, getAuthToken = getAuthToken) @@ -1122,7 +1751,7 @@ fun TtsSettingsSheet( } } - if (isOss) { + if (isOss && !isOssCloudAvailable) { Spacer(Modifier.height(16.dp)) DeviceVoicesTab(isTtsActive, context, TtsPlaybackManager.TtsMode.BASE) } else { @@ -1807,11 +2436,117 @@ fun ColorComparePill( } } -enum class ReaderTexture(val id: String, val resId: Int, val displayName: String) { - PAPER("paper", R.drawable.texture_paper, "Paper"), - CANVAS("canvas", R.drawable.texture_canvas, "Canvas"), - EINK("eink", R.drawable.texture_eink, "E-Ink"), - SLATE("slate", R.drawable.texture_slate, "Slate") +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" } +} + +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") } + ?: "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 + } catch (e: Exception) { + Timber.e(e, "Failed to import reader texture") + null + } +} + +fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? { + if (textureId == null) return null + return try { + val bitmap = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) { + BitmapFactory.decodeFile(textureId.removePrefix(TEXTURE_FILE_PREFIX)) + } else { + val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null + when { + texture.assetPath != null -> context.assets.open(texture.assetPath).use(BitmapFactory::decodeStream) + texture.resId != null -> BitmapFactory.decodeResource(context.resources, texture.resId) + else -> null + } + } + bitmap?.asImageBitmap() + } catch (e: Exception) { + Timber.e(e, "Failed to load reader texture bitmap: $textureId") + null + } +} + +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)) + mimeType = imageMimeTypeForExtension(file.extension) + file.readBytes() + } else { + val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null + 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) + ByteArrayOutputStream().use { out -> + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out) + out.toByteArray() + } + } + else -> null + } + } ?: return null + "data:$mimeType;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP) + } catch (e: Exception) { + Timber.e(e, "Failed to encode reader texture: $textureId") + null + } +} + +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" + } } data class ReaderTheme( @@ -1830,7 +2565,13 @@ val BuiltInThemes = listOf( 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("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) ) fun saveReaderThemeId(context: Context, themeId: String) { @@ -1843,6 +2584,28 @@ fun loadReaderThemeId(context: Context): String { return prefs.getString(PREF_READER_THEME, "system") ?: "system" } +const val PREF_GLOBAL_TEXTURE_TRANSPARENCY = "global_texture_transparency" + +fun saveGlobalTextureTransparency(context: Context, transparency: Float) { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + prefs.edit { putFloat(PREF_GLOBAL_TEXTURE_TRANSPARENCY, transparency) } +} + +fun loadGlobalTextureTransparency(context: Context): Float { + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + return prefs.getFloat(PREF_GLOBAL_TEXTURE_TRANSPARENCY, 0f) +} + +fun getImportedTextures(context: Context): List { + return try { + val dir = File(context.filesDir, READER_TEXTURE_DIR) + if (!dir.exists()) emptyList() + else dir.listFiles()?.map { TEXTURE_FILE_PREFIX + it.absolutePath } ?: emptyList() + } catch (_: Exception) { + emptyList() + } +} + const val PREF_EXCLUDE_IMAGES = "exclude_images" fun saveExcludeImages(context: Context, excludeImages: Boolean) { @@ -1914,13 +2677,22 @@ fun ReaderThemePanel( showExcludeImagesOption: Boolean = false, customThemes: List, builtInThemes: List = BuiltInThemes, + globalTextureTransparency: Float, + onGlobalTextureTransparencyChange: (Float) -> Unit, onThemeSelected: (String) -> Unit, onCustomThemesUpdated: (List) -> Unit, onDismiss: () -> Unit ) { if (!isVisible) return var showBuilder by remember { mutableStateOf(false) } + var builderIsTextured by remember { mutableStateOf(false) } var editingTheme by remember { mutableStateOf(null) } + var selectedTabIndex by remember { mutableIntStateOf(if (builtInThemes.find { it.id == currentThemeId }?.textureId != null || customThemes.find { it.id == currentThemeId }?.textureId != null) 1 else 0) } + + val plainBuiltInThemes = builtInThemes.filter { it.textureId == null } + val texturedBuiltInThemes = builtInThemes.filter { it.textureId != null } + val plainCustomThemes = customThemes.filter { it.textureId == null } + val texturedCustomThemes = customThemes.filter { it.textureId != null } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -1934,6 +2706,8 @@ fun ReaderThemePanel( if (isBuilding) { ThemeBuilderView( initialTheme = editingTheme, + isTexturedMode = builderIsTextured, + globalTextureAlpha = 1f - globalTextureTransparency, onSave = { newTheme -> val updatedList = if (editingTheme != null) { customThemes.map { if (it.id == newTheme.id) newTheme else it } @@ -1951,67 +2725,112 @@ fun ReaderThemePanel( } ) } else { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.65f) - .padding(16.dp) - .padding(bottom = 16.dp) - ) { - Text(stringResource(R.string.reading_themes), + Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.85f).padding(horizontal = 16.dp)) { + Text( + text = stringResource(R.string.reading_themes), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(bottom = 16.dp, top = 8.dp) ) - if (showExcludeImagesOption) { - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.theme_preserve_image_colors), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) - Text(stringResource(R.string.theme_preserve_image_colors_desc), style = MaterialTheme.typography.bodySmall, color = Color.Gray) + TabRow(selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent, divider = {}) { + Tab(selected = selectedTabIndex == 0, onClick = { selectedTabIndex = 0 }) { + Text("Solid Colors", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTabIndex == 1, onClick = { selectedTabIndex = 1 }) { + Text("Textured", modifier = Modifier.padding(12.dp)) + } + } + + Spacer(Modifier.height(16.dp)) + + if (selectedTabIndex == 1) { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Texture Transparency", style = MaterialTheme.typography.labelMedium) + Text("${(globalTextureTransparency * 100).roundToInt()}%", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) } - androidx.compose.material3.Switch( - checked = excludeImages, - onCheckedChange = onExcludeImagesChange + Slider( + value = globalTextureTransparency, + onValueChange = onGlobalTextureTransparencyChange, + valueRange = 0f..1f ) } } - Text(stringResource(R.string.theme_presets), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(8.dp)) - ThemeGrid(themes = builtInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected) + val activeListBuiltIn = if (selectedTabIndex == 0) plainBuiltInThemes else texturedBuiltInThemes + val activeListCustom = if (selectedTabIndex == 0) plainCustomThemes else texturedCustomThemes - Spacer(Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxWidth().weight(1f), + contentPadding = PaddingValues(bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text(stringResource(R.string.theme_my_themes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - IconButton(onClick = { editingTheme = null; showBuilder = true }, modifier = Modifier.size(24.dp)) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.theme_new), tint = MaterialTheme.colorScheme.primary) - } - } - Spacer(Modifier.height(8.dp)) - - if (customThemes.isEmpty()) { - Text(stringResource(R.string.theme_no_custom), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { - ThemeGrid( - themes = customThemes, - currentThemeId = currentThemeId, - onThemeSelected = onThemeSelected, - onEdit = { editingTheme = it; showBuilder = true }, - onDelete = { themeToDelete -> - val updated = customThemes.filter { it.id != themeToDelete.id } - onCustomThemesUpdated(updated) - if (currentThemeId == themeToDelete.id) onThemeSelected("system") + if (showExcludeImagesOption) { + item(span = { GridItemSpan(maxLineSpan) }) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.theme_preserve_image_colors), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.theme_preserve_image_colors_desc), style = MaterialTheme.typography.bodySmall, color = Color.Gray) + } + Switch(checked = excludeImages, onCheckedChange = onExcludeImagesChange) + } } - ) + } + + item(span = { GridItemSpan(maxLineSpan) }) { + Text(stringResource(R.string.theme_presets), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + } + + items(activeListBuiltIn) { theme -> + ThemeGridItem( + theme = theme, + currentThemeId = currentThemeId, + context = LocalContext.current, + globalTextureAlpha = 1f - globalTextureTransparency, + onThemeSelected = onThemeSelected, + onEdit = null, + onDelete = null + ) + } + + item(span = { GridItemSpan(maxLineSpan) }) { + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.theme_my_themes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + IconButton(onClick = { editingTheme = null; builderIsTextured = selectedTabIndex == 1; showBuilder = true }) { + Icon(Icons.Default.Add, contentDescription = "New Theme", tint = MaterialTheme.colorScheme.primary) + } + } + } + + if (activeListCustom.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text(stringResource(R.string.theme_no_custom), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + items(activeListCustom) { theme -> + ThemeGridItem( + theme = theme, + currentThemeId = currentThemeId, + context = LocalContext.current, + globalTextureAlpha = 1f - globalTextureTransparency, + onThemeSelected = onThemeSelected, + onEdit = { editingTheme = it; builderIsTextured = selectedTabIndex == 1; showBuilder = true }, + onDelete = { themeToDelete -> + val updated = customThemes.filter { it.id != themeToDelete.id } + onCustomThemesUpdated(updated) + if (currentThemeId == themeToDelete.id) onThemeSelected("system") + } + ) + } + } } } } @@ -2020,57 +2839,48 @@ fun ReaderThemePanel( } @Composable -fun ThemeGrid( - themes: List, +private fun ThemeGridItem( + theme: ReaderTheme, currentThemeId: String, + context: Context, + globalTextureAlpha: Float, onThemeSelected: (String) -> Unit, - onEdit: ((ReaderTheme) -> Unit)? = null, - onDelete: ((ReaderTheme) -> Unit)? = null + onEdit: ((ReaderTheme) -> Unit)?, + onDelete: ((ReaderTheme) -> Unit)?, + modifier: Modifier = Modifier ) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 80.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(themes.size) { index -> - val theme = themes[index] - val isSelected = currentThemeId == theme.id - val bgColor = if (theme.id == "system") MaterialTheme.colorScheme.surfaceVariant else theme.backgroundColor - val textColor = if (theme.id == "system") MaterialTheme.colorScheme.onSurfaceVariant else theme.textColor - val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant + val isSelected = currentThemeId == theme.id + val bgColor = if (theme.id == "system") MaterialTheme.colorScheme.surfaceVariant else theme.backgroundColor + val textColor = if (theme.id == "system") MaterialTheme.colorScheme.onSurfaceVariant else theme.textColor + val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant + val textureBitmap = remember(theme.textureId) { loadReaderTextureBitmap(context, theme.textureId) } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - modifier = Modifier - .size(56.dp) - .background(bgColor, CircleShape) - .border(if (isSelected) 3.dp else 1.dp, borderColor, CircleShape) - .clickable { onThemeSelected(theme.id) }, - contentAlignment = Alignment.Center - ) { - Text(text = "Aa", color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) - } - Spacer(modifier = Modifier.height(8.dp)) - Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis) - - if (theme.isCustom && onEdit != null && onDelete != null) { - Spacer(modifier = Modifier.height(6.dp)) - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Row( - modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.Edit, "Edit", Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary) - Spacer(Modifier.width(4.dp)) - Icon(Icons.Default.Delete, "Delete", Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error) - } + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(56.dp) + .background(bgColor, CircleShape) + .then(textureBitmap?.let { bitmap -> + Modifier.drawBehind { + drawRect(ShaderBrush(ImageShader(bitmap, TileMode.Repeated, TileMode.Repeated)), blendMode = BlendMode.SrcOver, alpha = globalTextureAlpha) } + } ?: Modifier) + .border(if (isSelected) 3.dp else 1.dp, borderColor, CircleShape) + .clickable { onThemeSelected(theme.id) }, + contentAlignment = Alignment.Center + ) { + Text(text = "Aa", color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + } + Spacer(modifier = Modifier.height(8.dp)) + Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis) + + if (theme.isCustom && onEdit != null && onDelete != null) { + Spacer(modifier = Modifier.height(6.dp)) + Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceVariant) { + Row(modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Edit, "Edit", Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(4.dp)) + Icon(Icons.Default.Delete, "Delete", Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error) } } } @@ -2080,25 +2890,31 @@ fun ThemeGrid( @Composable fun ThemeBuilderView( initialTheme: ReaderTheme?, + isTexturedMode: Boolean, + globalTextureAlpha: Float, onSave: (ReaderTheme) -> Unit, onCancel: () -> Unit ) { - var name by remember { mutableStateOf(initialTheme?.name ?: "Custom Theme") } + val context = LocalContext.current + var name by remember { mutableStateOf(initialTheme?.name ?: if (isTexturedMode) "Custom Textured" else "Custom Solid") } var bgColor by remember { mutableStateOf(initialTheme?.backgroundColor ?: Color(0xFFF5F5F5)) } var txtColor by remember { mutableStateOf(initialTheme?.textColor ?: Color(0xFF111111)) } - var textureId by remember { mutableStateOf(initialTheme?.textureId) } - var editingColorType by remember { mutableStateOf(null) } + var importedTextures by remember { mutableStateOf(getImportedTextures(context)) } + var textureId by remember { mutableStateOf(initialTheme?.textureId ?: importedTextures.firstOrNull()) } + + val texturePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { importReaderTexture(context, it) }?.let { newId -> + importedTextures = getImportedTextures(context) + textureId = newId + } + } + val contrast = calculateContrastRatio(bgColor, txtColor) val isDark = bgColor.luminance() < 0.5f - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.85f) - .padding(16.dp) - ) { + Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.85f).padding(16.dp)) { Text( text = stringResource(if (initialTheme == null) R.string.theme_new else R.string.theme_edit), fontWeight = FontWeight.Bold, @@ -2124,62 +2940,45 @@ fun ThemeBuilderView( color = bgColor, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { - val context = LocalContext.current + val textureBitmap = remember(textureId) { loadReaderTextureBitmap(context, textureId) } Box(modifier = Modifier.fillMaxSize().run { - val texRes = ReaderTexture.entries.find { it.id == textureId }?.resId - if (texRes != null) { - val bmp = ImageBitmap.imageResource(context.resources, texRes) + if (isTexturedMode && textureBitmap != null) { this.drawBehind { - drawRect(ShaderBrush(ImageShader(bmp, TileMode.Repeated, TileMode.Repeated)), blendMode = BlendMode.Multiply, alpha = 0.5f) + drawRect(ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)), blendMode = BlendMode.SrcOver, alpha = globalTextureAlpha) } } else this }) { Column(Modifier.padding(16.dp).fillMaxWidth()) { - Text(text = stringResource(R.string.theme_preview_quote), - color = txtColor, - style = MaterialTheme.typography.titleMedium - ) + Text(text = stringResource(R.string.theme_preview_quote), color = txtColor, style = MaterialTheme.typography.titleMedium) Spacer(Modifier.height(8.dp)) - Text(text = stringResource(R.string.theme_preview_author), - color = txtColor, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.End - ) + Text(text = stringResource(R.string.theme_preview_author), color = txtColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.End) } } } - // Animated Contrast Warning AnimatedVisibility(visible = contrast < 4.5f) { - Text(stringResource(R.string.theme_low_contrast_warning), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(bottom = 8.dp) - ) + Text(stringResource(R.string.theme_low_contrast_warning), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(bottom = 8.dp)) } Spacer(Modifier.height(16.dp)) - // Sleek Color Swatches Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { - ColorSwatchItem( - label = stringResource(R.string.theme_page_color), - color = bgColor, - onClick = { editingColorType = "bg" }, - modifier = Modifier.weight(1f) - ) - ColorSwatchItem( - label = stringResource(R.string.theme_text_color), - color = txtColor, - onClick = { editingColorType = "text" }, - modifier = Modifier.weight(1f) + ColorSwatchItem(label = stringResource(R.string.theme_page_color), color = bgColor, onClick = { editingColorType = "bg" }, modifier = Modifier.weight(1f)) + ColorSwatchItem(label = stringResource(R.string.theme_text_color), color = txtColor, onClick = { editingColorType = "text" }, modifier = Modifier.weight(1f)) + } + + if (isTexturedMode) { + Spacer(Modifier.height(24.dp)) + CustomTexturePickerSection( + importedTextures = importedTextures, + selectedTextureId = textureId, + onTextureSelected = { textureId = it }, + onImportTexture = { texturePickerLauncher.launch(arrayOf("image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp")) } ) } Spacer(Modifier.height(16.dp)) } - // Action Buttons at the bottom for better visibility Row( modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End, @@ -2190,7 +2989,7 @@ fun ThemeBuilderView( } Spacer(Modifier.width(8.dp)) Button(onClick = { - onSave(ReaderTheme(id = initialTheme?.id ?: System.currentTimeMillis().toString(), name = name, backgroundColor = bgColor, textColor = txtColor, isDark = isDark, textureId = textureId, isCustom = true)) + onSave(ReaderTheme(id = initialTheme?.id ?: System.currentTimeMillis().toString(), name = name, backgroundColor = bgColor, textColor = txtColor, isDark = isDark, textureId = if (isTexturedMode) textureId else null, isCustom = true)) }) { Text(stringResource(R.string.action_save), color = MaterialTheme.colorScheme.onPrimary) } @@ -2205,13 +3004,168 @@ fun ThemeBuilderView( textColor = txtColor, editingColorType = type, onDismiss = { editingColorType = null }, - onColorChanged = { newColor -> - if (type == "bg") bgColor = newColor else txtColor = newColor - } + onColorChanged = { newColor -> if (type == "bg") bgColor = newColor else txtColor = newColor } ) } } +@Composable +private fun CustomTexturePickerSection( + importedTextures: List, + selectedTextureId: String?, + onTextureSelected: (String?) -> Unit, + onImportTexture: () -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text("Select Custom Texture", style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp)) + + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + item { + Surface( + onClick = onImportTexture, + modifier = Modifier.size(72.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Icon(Icons.Default.Add, contentDescription = "Import", tint = MaterialTheme.colorScheme.primary) + Text("Import", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + } + } + } + items(importedTextures) { tex -> + val isSelected = tex == selectedTextureId + val context = LocalContext.current + val bitmap = remember(tex) { loadReaderTextureBitmap(context, tex) } + + Surface( + onClick = { onTextureSelected(tex) }, + modifier = Modifier.size(72.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (isSelected) 0.95f else 0.45f), + border = BorderStroke(if (isSelected) 2.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant) + ) { + Box( + modifier = Modifier.fillMaxSize().then(bitmap?.let { + Modifier.drawBehind { drawRect(ShaderBrush(ImageShader(it, TileMode.Repeated, TileMode.Repeated)), blendMode = BlendMode.SrcOver, alpha = 0.6f) } + } ?: Modifier), + contentAlignment = Alignment.Center + ) { + if (isSelected) Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.primary) + } + } + } + } + } +} + +@Composable +private fun TexturePickerSection( + selectedTextureId: String?, + selectedTextureAlpha: Float, + onTextureSelected: (String?) -> Unit, + onTextureAlphaChange: (Float) -> Unit, + onImportTexture: () -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.theme_texture), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + TextureChoice( + label = stringResource(R.string.theme_texture_none), + textureId = null, + selectedTextureId = selectedTextureId, + onTextureSelected = onTextureSelected, + modifier = Modifier.weight(1f) + ) + TextureChoice( + label = stringResource(R.string.theme_texture_upload), + textureId = selectedTextureId?.takeIf { it.startsWith(TEXTURE_FILE_PREFIX) }, + selectedTextureId = selectedTextureId, + onTextureSelected = { onImportTexture() }, + isUpload = true, + modifier = Modifier.weight(1f) + ) + } + Spacer(Modifier.height(8.dp)) + ReaderTexture.entries.filter { it.assetPath != null }.chunked(2).forEach { rowTextures -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + rowTextures.forEach { texture -> + TextureChoice( + label = texture.displayName, + textureId = texture.id, + selectedTextureId = selectedTextureId, + onTextureSelected = onTextureSelected, + modifier = Modifier.weight(1f) + ) + } + if (rowTextures.size == 1) Spacer(Modifier.weight(1f)) + } + } + AnimatedVisibility(visible = selectedTextureId != null) { + Column(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.theme_texture_transparency), style = MaterialTheme.typography.labelMedium) + Text("${((1f - selectedTextureAlpha) * 100).roundToInt()}%", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + } + Slider( + value = 1f - selectedTextureAlpha, + onValueChange = { onTextureAlphaChange((1f - it).coerceIn(0f, 1f)) }, + valueRange = 0f..1f + ) + } + } + } +} + +@Composable +private fun TextureChoice( + label: String, + textureId: String?, + selectedTextureId: String?, + onTextureSelected: (String?) -> Unit, + modifier: Modifier = Modifier, + isUpload: Boolean = false, +) { + val context = LocalContext.current + val textureBitmap = remember(textureId) { loadReaderTextureBitmap(context, textureId) } + val selected = if (isUpload) selectedTextureId?.startsWith(TEXTURE_FILE_PREFIX) == true else selectedTextureId == textureId + Surface( + onClick = { onTextureSelected(textureId) }, + modifier = modifier.height(52.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (selected) 0.95f else 0.45f), + border = BorderStroke(if (selected) 2.dp else 1.dp, if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .then(textureBitmap?.let { bitmap -> + Modifier.drawBehind { + drawRect(ShaderBrush(ImageShader(bitmap, TileMode.Repeated, TileMode.Repeated)), blendMode = BlendMode.SrcOver, alpha = 0.6f) + } + } ?: Modifier) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (isUpload && selectedTextureId?.startsWith(TEXTURE_FILE_PREFIX) == true) { + readerTextureDisplayName(selectedTextureId) + } else label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ColorSwatchItem(label: String, color: Color, onClick: () -> Unit, modifier: Modifier = Modifier) { @@ -2811,7 +3765,8 @@ fun AiResultContentView( modifier = Modifier.weight(1f).padding(end = 8.dp) ) - if (result != null && (!result.summary.isNullOrBlank() || isLoading)) { + val showUsageBadge = result?.isCacheHit == true || (BuildConfig.FLAVOR != "oss" && (result?.cost != null || isLoading)) + if (result != null && showUsageBadge && (!result.summary.isNullOrBlank() || isLoading)) { Surface( color = if (result.isCacheHit || (result.cost == 0.0 && result.freeRemaining != null)) Color( 0xFF4CAF50 @@ -2823,7 +3778,9 @@ fun AiResultContentView( 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 ?: 0) + stringResource(R.string.ai_generated_free_remaining, + result.freeRemaining + ) } else { stringResource(R.string.ai_generated_cost, result.cost.toString()) } diff --git a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt new file mode 100644 index 0000000..ceca8b9 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt @@ -0,0 +1,94 @@ +package com.aryan.reader + +private val codeOrDataExtensions = setOf( + "csv", + "tsv", + "json", + "xml", + "log", + "java", + "kt", + "py", + "js", + "cpp", + "c", + "cs", + "rb", + "go" +) + +internal fun resolveFileTypeFromName(fileName: String?): FileType? { + val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null + val effectiveName = lowerName.withTransparentTextSuffix() + + return when { + effectiveName.endsWith(".cbz") -> FileType.CBZ + effectiveName.endsWith(".cbr") -> FileType.CBR + effectiveName.endsWith(".cb7") -> FileType.CB7 + effectiveName.endsWith(".pdf") -> FileType.PDF + effectiveName.endsWith(".epub") -> FileType.EPUB + effectiveName.endsWith(".mobi") || effectiveName.endsWith(".azw3") || effectiveName.endsWith(".prc") -> FileType.MOBI + effectiveName.endsWith(".fb2") || effectiveName.endsWith(".fb2.zip") -> FileType.FB2 + effectiveName.endsWith(".md") || effectiveName.endsWith(".markdown") -> FileType.MD + effectiveName.endsWith(".html") || effectiveName.endsWith(".xhtml") || effectiveName.endsWith(".htm") -> FileType.HTML + effectiveName.endsWith(".docx") -> FileType.DOCX + effectiveName.endsWith(".odt") -> FileType.ODT + effectiveName.endsWith(".fodt") -> FileType.FODT + effectiveName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML + effectiveName.endsWith(".txt") -> FileType.TXT + else -> null + } +} + +internal fun isCodeOrDataFileName(fileName: String): Boolean { + return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions +} + +internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? { + val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null + val effectiveName = lowerName.withTransparentTextSuffix() + val effectiveSuffix = when { + effectiveName.endsWith(".fb2.zip") -> ".fb2.zip" + effectiveName.endsWith(".markdown") -> ".markdown" + effectiveName.endsWith(".xhtml") -> ".xhtml" + effectiveName.extensionAfterLastDot() != null && resolveFileTypeFromName(effectiveName) != null -> ".${effectiveName.extensionAfterLastDot()}" + else -> null + } ?: return null + + return if (effectiveName != lowerName && lowerName.endsWith(".txt")) { + "$effectiveSuffix.txt" + } else { + effectiveSuffix + } +} + +private fun String.withTransparentTextSuffix(): String { + if (!endsWith(".txt")) return this + val innerName = removeSuffix(".txt") + if (innerName.isBlank() || !innerName.contains('.')) return this + return if (resolveFileTypeFromNameWithoutTransparentText(innerName) != null) innerName else this +} + +private fun resolveFileTypeFromNameWithoutTransparentText(fileName: String): FileType? { + return when { + fileName.endsWith(".cbz") -> FileType.CBZ + fileName.endsWith(".cbr") -> FileType.CBR + fileName.endsWith(".cb7") -> FileType.CB7 + fileName.endsWith(".pdf") -> FileType.PDF + fileName.endsWith(".epub") -> FileType.EPUB + fileName.endsWith(".mobi") || fileName.endsWith(".azw3") || fileName.endsWith(".prc") -> FileType.MOBI + fileName.endsWith(".fb2") || fileName.endsWith(".fb2.zip") -> FileType.FB2 + fileName.endsWith(".md") || fileName.endsWith(".markdown") -> FileType.MD + fileName.endsWith(".html") || fileName.endsWith(".xhtml") || fileName.endsWith(".htm") -> FileType.HTML + fileName.endsWith(".docx") -> FileType.DOCX + fileName.endsWith(".odt") -> FileType.ODT + fileName.endsWith(".fodt") -> FileType.FODT + fileName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML + else -> null + } +} + +private fun String.extensionAfterLastDot(): String? { + val dotIndex = lastIndexOf('.') + return if (dotIndex in 0.., metadataOnly: Boolean): Boolean { if (folderUriString.isBlank()) return true val folderUri = folderUriString.toUri() + val folderStart = ReaderPerfLog.nowNanos() + 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 try { + if (!isFolderStillLinked(folderUriString)) { + ReaderPerfLog.w("FolderSync folder skipped: no longer linked folder=$folderUriString") + return true + } + try { appContext.contentResolver.takePersistableUriPermission( folderUri, @@ -137,17 +180,31 @@ class FolderSyncWorker( return false } - Timber.tag("FolderSync").d("Phase 0: Migrating legacy root sidecars to subfolder...") - LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree) + ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration skipped") - Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...") - val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap() + val folderMetadataMap = ReaderPerfLog.measureSuspend( + name = "FolderSync phase metadata-sidecars", + minLogMs = 25L, + details = { "metadataOnly=$metadataOnly" } + ) { + LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap() + } + ReaderPerfLog.d( + "FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString" + ) - Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...") - val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree).toMutableMap() + val preloadedSidecars = mutableMapOf>() + val existingFolderBooks = ReaderPerfLog.measureSuspend( + name = "FolderSync phase load-existing-db", + minLogMs = 25L + ) { + recentFilesRepository.getFilesBySourceFolder(folderUriString) + } + val existingFolderBooksById = existingFolderBooks.associateBy { it.bookId } + val remoteMetadataUpdates = mutableListOf() folderMetadataMap.forEach { (bookId, remoteMeta) -> - val existingItem = recentFilesRepository.getFileByBookId(bookId) + val existingItem = existingFolderBooksById[bookId] if (existingItem != null) { if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) { @@ -166,40 +223,28 @@ class FolderSyncWorker( isRecent = remoteMeta.isRecent || existingItem.isRecent, timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp ) - recentFilesRepository.addRecentFile(itemToUpdate) + remoteMetadataUpdates.add(itemToUpdate) } else { Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}") } } } - Timber.tag("FolderAnnotationSync").d("Phase 1.5: Checking annotation sidecars for existing local books...") - val processedBookIds = mutableSetOf() - val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) + if (remoteMetadataUpdates.isNotEmpty()) { + recentFilesRepository.addRecentFiles(remoteMetadataUpdates) + dbFlushes++ + ReaderPerfLog.d( + "FolderSync applied remote metadata updates count=${remoteMetadataUpdates.size} folder=$folderUriString" + ) + } - for (book in existingFolderBooks) { - processedBookIds.add(book.bookId) - - val sidecarData = preloadedSidecars[book.bookId] - - if (sidecarData != null) { - val (remoteTs, jsonPayload) = sidecarData - - val localFiles = listOf( - File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"), - File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"), - File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"), - File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json") - ) - val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L - - if (remoteTs > (localTs + 1000)) { - Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.") - recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload) - } else { - Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.") - } - } + if (metadataOnly) { + sidecarsImported += importAnnotationSidecarsForBooks( + folderUri = folderUri, + folderUriString = folderUriString, + books = existingFolderBooks, + phase = "metadata-only" + ) } if (!metadataOnly) { @@ -207,7 +252,17 @@ class FolderSyncWorker( val contentResolver = appContext.contentResolver val foundBookIds = mutableSetOf() val newOrUpdatedItems = mutableListOf() - val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap() + 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().apply { addAll(entry.value) } + } val rootDocId = DocumentsContract.getTreeDocumentId(folderUri) val dirQueue = ArrayDeque() @@ -223,7 +278,13 @@ class FolderSyncWorker( 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 { @@ -234,10 +295,17 @@ class FolderSyncWorker( val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) - while (cursor.moveToNext() && !isStopped) { + 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") { @@ -249,13 +317,15 @@ class FolderSyncWorker( val type = getFileType(name, mimeType) if (type != null && type in allowedFileTypes && !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 != docUri.toString()) { + if (existingItem != null && existingItem.uriString != docUriString) { val collidedItem = existingItem val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId) if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) { @@ -273,12 +343,10 @@ class FolderSyncWorker( } if (existingItem == null) { - val oldItem = existingItemsMap.values.find { - it.bookId != stableId && ( - it.uriString == docUri.toString() || - it.bookId.startsWith("local_${name}_") - ) - } + 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") @@ -291,6 +359,7 @@ class FolderSyncWorker( preloadedSidecars = preloadedSidecars, existingItemsMap = existingItemsMap ) + legacyItemsByName[name]?.remove(oldItem) existingItem = existingItemsMap[stableId] } } @@ -324,6 +393,7 @@ class FolderSyncWorker( fileSize = size ) newOrUpdatedItems.add(newItem) + newBooks++ } else { var needsUpdate = false var updatedItem = existingItem @@ -331,7 +401,11 @@ class FolderSyncWorker( if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) { Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).") recentFilesRepository.clearLocalCachesForBook(stableId) - updatedItem = updatedItem.copy(fileSize = size, lastModifiedTimestamp = lastModified) + updatedItem = updatedItem.copy( + fileSize = size, + lastModifiedTimestamp = lastModified, + folderTextMetadataParsed = false + ) needsUpdate = true } @@ -347,32 +421,26 @@ class FolderSyncWorker( if (needsUpdate) { newOrUpdatedItems.add(updatedItem) + updatedBooks++ + } else { + unchangedBooks++ } } - if (newOrUpdatedItems.size >= 50) { + 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() } - if (!processedBookIds.contains(stableId)) { - val sidecarData = preloadedSidecars[stableId] - if (sidecarData != null) { - val (remoteTs, jsonPayload) = sidecarData - val localFiles = listOf( - File(appContext.filesDir, "annotations/annotation_$stableId.json"), - File(appContext.filesDir, "pdf_rich_text/text_$stableId.json"), - File(appContext.filesDir, "page_layouts/layout_$stableId.json"), - File(appContext.filesDir, "pdf_text_boxes/boxes_$stableId.json") - ) - val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L - - if (remoteTs > (localTs + 1000)) { - Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for new book $stableId. Importing.") - recentFilesRepository.importAnnotationBundle(stableId, jsonPayload) - } - } - } } } } @@ -380,16 +448,19 @@ class FolderSyncWorker( } catch (e: Exception) { Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId") } + + if (stoppedForUnlinkedFolder) break } - if (newOrUpdatedItems.isNotEmpty()) { + if (!stoppedForUnlinkedFolder && newOrUpdatedItems.isNotEmpty()) { recentFilesRepository.addRecentFiles(newOrUpdatedItems) + dbFlushes++ + scanDbFlushes++ newOrUpdatedItems.clear() } - if (!isStopped) { - val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) - val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId } + 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.") @@ -398,16 +469,50 @@ class FolderSyncWorker( } } - if (!isStopped) { - Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.") - val metaRequest = OneTimeWorkRequestBuilder().build() - WorkManager.getInstance(appContext).enqueueUniqueWork( - MetadataExtractionWorker.WORK_NAME, - ExistingWorkPolicy.APPEND_OR_REPLACE, - metaRequest + if (!metadataOnly && !isStopped && !stoppedForUnlinkedFolder) { + val booksForAnnotationSync = 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" ) } + 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 " + + "dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " + + "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString" + ) + + if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) { + if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) { + ReaderPerfLog.i("FolderSync enqueue text metadata extraction folder=$folderUriString") + val metaRequest = OneTimeWorkRequestBuilder() + .setInputData( + androidx.work.Data.Builder() + .putString(MetadataExtractionWorker.KEY_SOURCE_FOLDER_URI, folderUriString) + .build() + ) + .build() + WorkManager.getInstance(appContext).enqueueUniqueWork( + MetadataExtractionWorker.WORK_NAME, + ExistingWorkPolicy.REPLACE, + metaRequest + ) + } else { + ReaderPerfLog.d("FolderSync text metadata extraction skipped: no pending books folder=$folderUriString") + } + } + return true } catch (e: Exception) { @@ -416,23 +521,87 @@ class FolderSyncWorker( } } + private suspend fun importAnnotationSidecarsForBooks( + folderUri: android.net.Uri, + folderUriString: String, + books: List, + phase: String + ): Int { + if (books.isEmpty()) { + ReaderPerfLog.d("FolderSync phase annotation-sidecars skipped phase=$phase reason=no-books folder=$folderUriString") + return 0 + } + + val preloadedSidecars = ReaderPerfLog.measureSuspend( + name = "FolderSync phase annotation-sidecars", + minLogMs = 25L, + details = { "phase=$phase" } + ) { + LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri) + } + + ReaderPerfLog.d( + "FolderSync annotation-sidecars records=${preloadedSidecars.size} books=${books.size} phase=$phase folder=$folderUriString" + ) + + if (preloadedSidecars.isEmpty()) return 0 + + var imported = 0 + Timber.tag("FolderAnnotationSync").d("Checking annotation sidecars phase=$phase for ${books.size} books...") + for (book in books) { + if (isStopped || !isFolderStillLinked(folderUriString)) break + + val sidecarData = preloadedSidecars[book.bookId] ?: continue + val (remoteTs, jsonPayload) = sidecarData + + val localFiles = listOf( + File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"), + File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"), + File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"), + File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json") + ) + val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L + + if (remoteTs > (localTs + 1000)) { + Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.") + recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload) + imported++ + } else { + Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.") + } + } + + ReaderPerfLog.i( + "FolderSync annotation-sidecars imported=$imported records=${preloadedSidecars.size} phase=$phase folder=$folderUriString" + ) + return imported + } + + 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) + if (jsonString != null) { + return try { + val array = org.json.JSONArray(jsonString) + (0 until array.length()).any { index -> + array.getJSONObject(index).optString("uri") == folderUriString + } + } catch (_: Exception) { + false + } + } + return prefs.getString("synced_folder_uri", null) == folderUriString + } + private fun getFileType(name: String, mimeType: String?): FileType? { - val lowerName = name.lowercase() - return when { - mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF - mimeType == "application/epub+zip" || lowerName.endsWith(".epub") -> FileType.EPUB - mimeType == "application/vnd.oasis.opendocument.text" || lowerName.endsWith(".odt") -> FileType.ODT - mimeType == "application/x-vnd.oasis.opendocument.text-flat-xml" || lowerName.endsWith(".fodt") -> FileType.FODT - mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || lowerName.endsWith(".docx") -> FileType.DOCX - lowerName.endsWith(".mobi") || lowerName.endsWith(".azw3") || lowerName.endsWith(".prc") -> FileType.MOBI - lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2 - lowerName.endsWith(".cbz") -> FileType.CBZ - lowerName.endsWith(".cbr") -> FileType.CBR - lowerName.endsWith(".cb7") -> FileType.CB7 - lowerName.endsWith(".md") || lowerName.endsWith(".markdown") -> FileType.MD - lowerName.endsWith(".txt") -> FileType.TXT - mimeType == "text/html" || lowerName.endsWith(".html") || lowerName.endsWith(".xhtml") || lowerName.endsWith(".htm") -> FileType.HTML - else -> null + return when (mimeType) { + "application/pdf" -> FileType.PDF + "application/epub+zip" -> FileType.EPUB + "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 + "text/html", "application/xhtml+xml" -> FileType.HTML + else -> resolveFileTypeFromName(name) } } diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index e7d4e39..5d431c4 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -73,6 +73,7 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox @@ -134,6 +135,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.os.LocaleListCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi import androidx.navigation.NavHostController import coil.compose.AsyncImage import coil.request.ImageRequest @@ -151,11 +153,17 @@ internal fun Context.findActivity(): Activity? = when (this) { else -> null } +@UnstableApi +@androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( viewModel: MainViewModel, windowSizeClass: WindowSizeClass, navController: NavHostController ) { + val compStart = remember { System.currentTimeMillis() } + LaunchedEffect(Unit) { + ReaderPerfLog.d("HomeScreen initial composition ${System.currentTimeMillis() - compStart}ms") + } val context = LocalContext.current val customTabUriHandler = remember { CustomTabUriHandler(context) } var showCloseAllTabsDialog by remember { mutableStateOf(false) } @@ -163,14 +171,15 @@ fun HomeScreen( CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val recentFilesForHome = uiState.recentFiles.filter { it.isRecent } - val openTabs = uiState.openTabs - val selectedContextItems = uiState.contextualActionItems - val isContextualModeActive = selectedContextItems.isNotEmpty() + val screenModel = remember(uiState) { uiState.toHomeScreenModel() } + val recentFilesForHome = screenModel.recentFiles + val openTabs = screenModel.openTabs + val selectedContextItems = screenModel.selectedItems + val isContextualModeActive = screenModel.isContextualModeActive val scope = rememberCoroutineScope() val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val snackbarHostState = remember { SnackbarHostState() } - val deviceLimitState = uiState.deviceLimitState + val deviceLimitState = screenModel.deviceLimitState var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showClearCloudDataDialog by remember { mutableStateOf(false) } @@ -304,6 +313,12 @@ fun HomeScreen( navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE) } }, + onAiSettingsClick = { + scope.launch { + drawerState.close() + navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE) + } + }, navController = navController, onFolderSyncToggle = viewModel::setFolderSyncEnabled ) @@ -341,7 +356,10 @@ fun HomeScreen( onTestPanelDetectionClick = { viewModel.testPanelDetection(context) }, onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) }, onLanguageClick = { showLanguageDialog = true }, - onExportLogsClick = { viewModel.exportLogsToFile(context) } + onExportLogsClick = { viewModel.exportLogsToFile(context) }, + onToggleHideReaderAi = { + saveHideReaderAiFeatures(context, !loadHideReaderAiFeatures(context)) + } ) } else { ContextualTopAppBar( @@ -367,8 +385,8 @@ fun HomeScreen( .fillMaxSize() .padding(paddingValues) ) { - if (recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())) { - if (uiState.recentFiles.isEmpty()) { + if (screenModel.isEmpty) { + if (screenModel.isLibraryEmpty) { EmptyState( title = stringResource(R.string.your_library_empty), message = stringResource(R.string.your_library_empty_desc), @@ -528,7 +546,8 @@ fun HomeScreen( uiState = uiState, onThemeModeChanged = viewModel::setAppThemeMode, onContrastOptionChanged = viewModel::setAppContrastOption, - onTextDimFactorChanged = viewModel::setAppTextDimFactor, + onTextDimFactorLightChanged = viewModel::setAppTextDimFactorLight, + onTextDimFactorDarkChanged = viewModel::setAppTextDimFactorDark, onSeedColorChanged = viewModel::setAppSeedColor, onCustomThemeAdded = viewModel::addCustomAppTheme, onCustomThemeDeleted = viewModel::deleteCustomAppTheme, @@ -596,6 +615,9 @@ private fun RecentFilesContent( hasSyncedFolder: Boolean ) { val canRefresh = isSyncEnabled || hasSyncedFolder + val selectedItemUris = remember(selectedContextItems) { + selectedContextItems.mapNotNullTo(mutableSetOf()) { it.uriString } + } val content = @Composable { Box(modifier = Modifier.fillMaxSize()) { @@ -608,7 +630,7 @@ private fun RecentFilesContent( isTabsEnabled = isTabsEnabled, onTabCloseClick = onTabCloseClick, onCloseAllTabsClick = onCloseAllTabsClick, - selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(), + selectedItemUris = selectedItemUris, pinnedHomeBookIds = pinnedHomeBookIds, onItemClick = onItemClick, onItemLongClick = onItemLongClick, @@ -996,10 +1018,13 @@ fun DefaultTopAppBar( onTestPanelDetectionClick: () -> Unit, onTestSpeechBubbleDetectionClick: () -> Unit, onLanguageClick: () -> Unit, - onExportLogsClick: () -> Unit + onExportLogsClick: () -> Unit, + onToggleHideReaderAi: () -> Unit ) { var showOptionsMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) } + val context = LocalContext.current + var hideReaderAiFeatures by remember { mutableStateOf(loadHideReaderAiFeatures(context)) } CustomTopAppBar(title = { }, navigationIcon = { IconButton(onClick = onDrawerClick) { @@ -1086,6 +1111,20 @@ fun DefaultTopAppBar( showOptionsMenu = false }) + DropdownMenuItem( + text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") }, + onClick = { + onToggleHideReaderAi() + hideReaderAiFeatures = !hideReaderAiFeatures + showOptionsMenu = false + }, + trailingIcon = { + if (hideReaderAiFeatures) { + Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) + } + } + ) + HorizontalDivider() DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = { onClearCache() @@ -1141,6 +1180,7 @@ private fun AppDrawerContent( onUpgradeClick: () -> Unit, onSyncUpsellClick: () -> Unit, onFontsClick: () -> Unit, + onAiSettingsClick: () -> Unit, navController: NavHostController, onFolderSyncToggle: (Boolean) -> Unit ) { @@ -1311,6 +1351,26 @@ private fun AppDrawerContent( modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) ) + if (isOss && !BuildConfig.IS_OFFLINE) { + NavigationDrawerItem( + icon = { Icon(painterResource(id = R.drawable.ai), contentDescription = null) }, + label = { Text("AI keys and models") }, + selected = false, + onClick = onAiSettingsClick, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + + if (isOss) { + NavigationDrawerItem( + icon = { Icon(Icons.Outlined.FavoriteBorder, contentDescription = null) }, + label = { Text(stringResource(R.string.drawer_support_project)) }, + selected = false, + onClick = { navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + NavigationDrawerItem( icon = { Icon(painterResource(id = R.drawable.feedback), contentDescription = null) }, label = { Text(stringResource(R.string.drawer_help_feedback)) }, @@ -1692,7 +1752,8 @@ fun AppThemeBottomSheet( uiState: ReaderScreenState, onThemeModeChanged: (AppThemeMode) -> Unit, onContrastOptionChanged: (AppContrastOption) -> Unit, - onTextDimFactorChanged: (Float) -> Unit, + onTextDimFactorLightChanged: (Float) -> Unit, + onTextDimFactorDarkChanged: (Float) -> Unit, onSeedColorChanged: (Color?) -> Unit, onCustomThemeAdded: (CustomAppTheme) -> Unit, onCustomThemeDeleted: (String) -> Unit, @@ -1756,24 +1817,68 @@ fun AppThemeBottomSheet( Spacer(Modifier.height(24.dp)) - Text(stringResource(R.string.app_theme_text_brightness), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) - androidx.compose.material3.Slider( - value = uiState.appTextDimFactor, - onValueChange = onTextDimFactorChanged, - valueRange = 0.3f..1.0f, - modifier = Modifier.weight(1f).padding(horizontal = 16.dp) - ) - Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f)) + if (uiState.appThemeMode == AppThemeMode.SYSTEM) { + Text("${stringResource(R.string.app_theme_text_brightness)} (Light)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + androidx.compose.material3.Slider( + value = uiState.appTextDimFactorLight, + onValueChange = onTextDimFactorLightChanged, + valueRange = 0.3f..1.0f, + modifier = Modifier.weight(1f).padding(horizontal = 16.dp) + ) + Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f)) + } + + Spacer(Modifier.height(16.dp)) + + Text("${stringResource(R.string.app_theme_text_brightness)} (Dark)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + androidx.compose.material3.Slider( + value = uiState.appTextDimFactorDark, + onValueChange = onTextDimFactorDarkChanged, + valueRange = 0.3f..1.0f, + modifier = Modifier.weight(1f).padding(horizontal = 16.dp) + ) + Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f)) + } + } else { + Text(stringResource(R.string.app_theme_text_brightness), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp)) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + androidx.compose.material3.Slider( + value = if (uiState.appThemeMode == AppThemeMode.DARK) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight, + onValueChange = if (uiState.appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged, + valueRange = 0.3f..1.0f, + modifier = Modifier.weight(1f).padding(horizontal = 16.dp) + ) + Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f)) + } } Spacer(Modifier.height(24.dp)) diff --git a/app/src/main/java/com/aryan/reader/LibraryModels.kt b/app/src/main/java/com/aryan/reader/LibraryModels.kt new file mode 100644 index 0000000..f2bd2b4 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/LibraryModels.kt @@ -0,0 +1,86 @@ +package com.aryan.reader + +import com.aryan.reader.data.RecentFileItem + +enum class AddBooksSource { + UNSHELVED, + ALL_BOOKS +} + +enum class FileType { + PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT +} + +internal val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7) + +internal val EPUB_READER_FILE_TYPES = setOf( + FileType.EPUB, + FileType.MOBI, + FileType.MD, + FileType.TXT, + FileType.HTML, + FileType.FB2, + FileType.DOCX, + FileType.ODT, + FileType.FODT +) + +enum class RenderMode { + VERTICAL_SCROLL, PAGINATED +} + +data class SyncedFolder( + val uriString: String, + val name: String, + val lastScanTime: Long, + val allowedFileTypes: Set = FileType.entries.toSet() +) + +enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER } + +data class Shelf( + val id: String, + val name: String, + val type: ShelfType, + val books: List, + val directBooks: List = books, + val parentShelfId: String? = null, + val childShelfIds: List = emptyList(), + val depth: Int = 0, + val sortKey: String = name.lowercase() +) { + val bookCount: Int get() = books.size + val topBook: RecentFileItem? by lazy(LazyThreadSafetyMode.NONE) { books.maxByOrNull { it.timestamp } } + val directBookCount: Int get() = directBooks.size + val childShelfCount: Int get() = childShelfIds.size +} + +enum class SortOrder { + RECENT, + TITLE_ASC, + AUTHOR_ASC, + PERCENT_ASC, + PERCENT_DESC, + SIZE_ASC, + SIZE_DESC +} + +enum class ReadStatusFilter { + ALL, + UNREAD, + IN_PROGRESS, + COMPLETED +} + +data class LibraryFilters( + val fileTypes: Set = emptySet(), + val sourceFolders: Set = emptySet(), + val readStatus: ReadStatusFilter = ReadStatusFilter.ALL, + val tagIds: Set = emptySet() +) { + val isActive: Boolean + get() = fileTypes.isNotEmpty() || + sourceFolders.isNotEmpty() || + readStatus != ReadStatusFilter.ALL || + tagIds.isNotEmpty() +} diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 7d10a33..619b37e 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -131,6 +132,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.util.UnstableApi import coil.compose.AsyncImage import coil.request.ImageRequest import com.aryan.reader.data.RecentFileItem @@ -141,6 +143,8 @@ import com.aryan.reader.opds.OpdsEntry import com.aryan.reader.opds.OpdsViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.jsoup.Jsoup import timber.log.Timber @@ -154,20 +158,26 @@ private fun getBookCountString(count: Int): String { return pluralStringResource(id = R.plurals.book_count, count, count) } +@UnstableApi @SuppressLint("LocalContextGetResourceValueCall") @Composable fun LibraryScreen( viewModel: MainViewModel, ) { + val compStart = remember { System.currentTimeMillis() } + LaunchedEffect(Unit) { + ReaderPerfLog.d("LibraryScreen initial composition ${System.currentTimeMillis() - compStart}ms") + } val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val selectedItems = uiState.contextualActionItems - val isContextualModeActive = selectedItems.isNotEmpty() - val selectedShelves = uiState.contextualActionShelfIds - val isShelfContextualModeActive = selectedShelves.isNotEmpty() - val sortOrder = uiState.sortOrder - val shelves = uiState.shelves - val rawLibraryFiles = uiState.rawLibraryFiles + val screenModel = remember(uiState) { uiState.toLibraryScreenModel() } + val selectedItems = screenModel.selectedItems + val isContextualModeActive = screenModel.isContextualModeActive + val selectedShelves = screenModel.selectedShelves + val isShelfContextualModeActive = screenModel.isShelfContextualModeActive + val sortOrder = screenModel.sortOrder + val shelves = screenModel.shelves + val rawLibraryFiles = screenModel.rawLibraryFiles val tabTitles = remember { buildList { add(context.getString(R.string.tab_all_books)) @@ -183,21 +193,13 @@ fun LibraryScreen( pageCount = { tabTitles.size } ) - val containsFolderItems = remember(selectedItems) { - selectedItems.any { it.sourceFolderUri != null } - } - - LaunchedEffect(uiState.libraryScreenStartPage) { - if (pagerState.currentPage != uiState.libraryScreenStartPage) { - pagerState.animateScrollToPage(uiState.libraryScreenStartPage) - } - } + val containsFolderItems = screenModel.containsFolderItemsInSelection val scope = rememberCoroutineScope() var showFilterSheet by remember { mutableStateOf(false) } - val isSearchActive = uiState.isSearchActive - val searchQuery = uiState.searchQuery + val isSearchActive = screenModel.isSearchActive + val searchQuery = screenModel.searchQuery val pickFolderLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocumentTree() @@ -250,6 +252,8 @@ fun LibraryScreen( LaunchedEffect(pagerState) { androidx.compose.runtime.snapshotFlow { pagerState.settledPage } + .drop(1) + .distinctUntilChanged() .collect { page -> viewModel.setLibraryScreenPage(page) } @@ -401,6 +405,7 @@ fun LibraryScreen( } } +@UnstableApi @Composable fun ShelfScreen( viewModel: MainViewModel, @@ -577,6 +582,7 @@ fun LibraryScreenContent( val isShelfContextualModeActive = selectedShelves.isNotEmpty() var showSortMenu by remember { mutableStateOf(false) } val searchFocusRequester = remember { FocusRequester() } + val selectedBookIds = remember(selectedItems) { selectedItems.mapTo(mutableSetOf()) { it.bookId } } var textFieldValue by remember(isSearchActive) { mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length))) @@ -712,7 +718,16 @@ fun LibraryScreenContent( Tab( selected = pagerState.currentPage == index, onClick = { - scope.launch { pagerState.animateScrollToPage(index) } + ReaderPerfLog.d("LibraryPager click page=$index title=$title") + if (pagerState.currentPage != index) { + scope.launch { + val start = ReaderPerfLog.nowNanos() + pagerState.animateScrollToPage(index) + ReaderPerfLog.d( + "LibraryPager settled page=$index elapsed=${ReaderPerfLog.elapsedMs(start)}ms" + ) + } + } }, text = { Text(title) } ) @@ -798,6 +813,11 @@ fun LibraryScreenContent( modifier = Modifier .fillMaxSize() .padding(paddingValues), + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + snapPositionalThreshold = 0.25f + ), + beyondViewportPageCount = 0, key = { it } ) { page -> when (page) { @@ -822,7 +842,7 @@ fun LibraryScreenContent( items(recentFiles, key = { it.bookId }) { item -> LibraryListItem( item = item, - isSelected = selectedItems.any { it.bookId == item.bookId }, + isSelected = item.bookId in selectedBookIds, isPinned = item.bookId in pinnedLibraryBookIds, onItemClick = { onItemClick(item) }, onItemLongClick = { onItemLongClick(item) }, @@ -1876,6 +1896,18 @@ private fun FolderSyncScreen( isLoading: Boolean ) { var editingFolder by remember { mutableStateOf(null) } + val folderStatsByUri = remember(allRecentFiles) { + allRecentFiles + .asSequence() + .filter { it.sourceFolderUri != null } + .groupBy { it.sourceFolderUri!! } + .mapValues { (_, files) -> + FolderFileStats( + totalBooks = files.size, + countsByType = files.groupingBy { it.type }.eachCount() + ) + } + } Scaffold( floatingActionButton = { @@ -1943,7 +1975,7 @@ private fun FolderSyncScreen( items(syncedFolders, key = { it.uriString }) { folder -> FolderCard( folder = folder, - allRecentFiles = allRecentFiles, + stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty, onRemoveClick = onRemoveFolderClick, onEditFiltersClick = { editingFolder = folder } ) @@ -1952,11 +1984,11 @@ private fun FolderSyncScreen( } } - if (editingFolder != null) { + editingFolder?.let { folder -> EditFolderFiltersDialog( - folder = editingFolder!!, + folder = folder, onConfirm = { newFilters -> - onEditFolderFiltersClick(editingFolder!!, newFilters) + onEditFolderFiltersClick(folder, newFilters) editingFolder = null }, onDismiss = { editingFolder = null } @@ -1964,11 +1996,20 @@ private fun FolderSyncScreen( } } +private data class FolderFileStats( + val totalBooks: Int, + val countsByType: Map +) { + companion object { + val Empty = FolderFileStats(totalBooks = 0, countsByType = emptyMap()) + } +} + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable private fun FolderCard( folder: SyncedFolder, - allRecentFiles: List, + stats: FolderFileStats, onRemoveClick: (SyncedFolder) -> Unit, onEditFiltersClick: (SyncedFolder) -> Unit ) { @@ -1976,14 +2017,6 @@ private fun FolderCard( val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) } val lastScanText = if (folder.lastScanTime == 0L) stringResource(R.string.never) else dateFormat.format(Date(folder.lastScanTime)) - val folderFiles = remember(allRecentFiles, folder.uriString) { - allRecentFiles.filter { it.sourceFolderUri == folder.uriString } - } - val totalBooks = folderFiles.size - val countsByType = remember(folderFiles) { - folderFiles.groupBy { it.type }.mapValues { it.value.size } - } - androidx.compose.material3.ElevatedCard( modifier = Modifier.fillMaxWidth(), colors = androidx.compose.material3.CardDefaults.elevatedCardColors( @@ -2058,18 +2091,18 @@ private fun FolderCard( color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Bold ) - Text(text = totalBooks.toString(), style = MaterialTheme.typography.bodyMedium) + Text(text = stats.totalBooks.toString(), style = MaterialTheme.typography.bodyMedium) } } - if (countsByType.isNotEmpty()) { + if (stats.countsByType.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) androidx.compose.foundation.layout.FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth() ) { - countsByType.forEach { (type, count) -> + stats.countsByType.forEach { (type, count) -> AssistChip( onClick = { }, label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) } diff --git a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt new file mode 100644 index 0000000..3277879 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt @@ -0,0 +1,435 @@ +package com.aryan.reader + +import com.aryan.reader.data.BookShelfCrossRef +import com.aryan.reader.data.BookTagCrossRef +import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.data.ShelfEntity +import com.aryan.reader.data.SmartCollectionEngine +import com.aryan.reader.data.TagEntity + +fun interface FolderPathResolver { + fun relativeFolderSegments(item: RecentFileItem): List +} + +object EmptyFolderPathResolver : FolderPathResolver { + override fun relativeFolderSegments(item: RecentFileItem): List = emptyList() +} + +data class LibraryProjectionInput( + val state: ReaderScreenState, + val recentFilesFromDb: List, + val dbShelves: List, + val shelfRefs: List, + val dbTags: List, + val tagRefs: List +) + +class LibraryStateProjector( + private val folderPathResolver: FolderPathResolver = EmptyFolderPathResolver +) { + private var cachedProjection: CachedProjection? = null + + fun project(input: LibraryProjectionInput): ReaderScreenState { + val start = ReaderPerfLog.nowNanos() + val internalState = input.state + val cacheKey = ProjectionCacheKey( + recentFilesFromDb = input.recentFilesFromDb, + dbShelves = input.dbShelves, + shelfRefs = input.shelfRefs, + dbTags = input.dbTags, + tagRefs = input.tagRefs, + folderKeys = internalState.syncedFolders.map { SyncedFolderProjectionKey(it.uriString, it.name) }, + sortOrder = internalState.sortOrder, + searchQuery = internalState.searchQuery, + libraryFilters = internalState.libraryFilters, + recentFilesLimit = internalState.recentFilesLimit + ) + + cachedProjection?.takeIf { it.key == cacheKey }?.let { cache -> + val result = buildStateFromCache(internalState, cache) + val elapsed = ReaderPerfLog.elapsedMs(start) + if (elapsed >= 8L) { + ReaderPerfLog.d( + "LibraryProject cache-hit took ${elapsed}ms books=${cache.allLibraryFiles.size} shelves=${cache.shelfProjection.shelves.size}" + ) + } + return result + } + + val tagsById = input.dbTags.associateBy { it.id } + val bookTagsMap = input.tagRefs.groupBy { it.bookId }.mapValues { entry -> + entry.value.mapNotNull { tagsById[it.tagId] } + } + + val allLibraryFiles = input.recentFilesFromDb + .filterNot { it.bookId.endsWith("_reflow") } + .map { item -> + item.copy(tags = bookTagsMap[item.bookId] ?: emptyList()) + } + val allLibraryFilesById = allLibraryFiles.associateBy { it.bookId } + + val rawFilteredByQuery = filterBySearch(allLibraryFiles, internalState.searchQuery) + val libraryFiltered = applyLibraryFilters(rawFilteredByQuery, internalState.libraryFilters) + val sortedLibraryFiles = if (internalState.sortOrder == SortOrder.RECENT) { + libraryFiltered + } else { + sortFiles(libraryFiltered, internalState.sortOrder) + } + val recentLimit = if (internalState.recentFilesLimit > 0) internalState.recentFilesLimit else Int.MAX_VALUE + val visibleRecentFiles = if (internalState.sortOrder == SortOrder.RECENT) { + allLibraryFiles + .asSequence() + .filter { it.isRecent } + .take(recentLimit) + .toList() + } else { + sortFiles( + allLibraryFiles.filter { it.isRecent }, + internalState.sortOrder + ).take(recentLimit) + } + + val shelfProjection = buildShelves( + allLibraryFiles = allLibraryFiles, + dbShelves = input.dbShelves, + shelfRefs = input.shelfRefs, + dbTags = input.dbTags, + sortOrder = internalState.sortOrder, + syncedFolders = internalState.syncedFolders + ) + + val cache = CachedProjection( + key = cacheKey, + allLibraryFiles = allLibraryFiles, + allLibraryFilesById = allLibraryFilesById, + sortedLibraryFiles = sortedLibraryFiles, + visibleRecentFiles = visibleRecentFiles, + shelfProjection = shelfProjection, + validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id }, + dbTags = input.dbTags + ) + cachedProjection = cache + + val elapsed = ReaderPerfLog.elapsedMs(start) + if (elapsed >= 16L || allLibraryFiles.size >= 500) { + ReaderPerfLog.d( + "LibraryProject recompute took ${elapsed}ms books=${allLibraryFiles.size} " + + "visible=${sortedLibraryFiles.size} shelves=${shelfProjection.shelves.size} " + + "tags=${input.dbTags.size} shelfRefs=${input.shelfRefs.size} tagRefs=${input.tagRefs.size}" + ) + } + + return buildStateFromCache(internalState, cache) + } + + private fun buildStateFromCache( + internalState: ReaderScreenState, + cache: CachedProjection + ): ReaderScreenState { + val viewingShelfId = internalState.viewingShelfId?.takeIf { it in cache.validShelfIds } + val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in cache.validShelfIds } + val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) { + val currentShelfBookIds = cache.shelfProjection.shelves + .find { it.id == viewingShelfId } + ?.books + ?.mapTo(mutableSetOf()) { it.bookId } + ?: emptySet() + when (internalState.addBooksSource) { + AddBooksSource.UNSHELVED -> cache.shelfProjection.unshelvedBooks + AddBooksSource.ALL_BOOKS -> cache.allLibraryFiles.filter { it.bookId !in currentShelfBookIds } + } + } else { + emptyList() + } + + return internalState.copy( + recentFiles = cache.visibleRecentFiles, + allRecentFiles = cache.sortedLibraryFiles, + rawLibraryFiles = cache.allLibraryFiles, + viewingShelfId = viewingShelfId, + isAddingBooksToShelf = internalState.isAddingBooksToShelf && viewingShelfId != null, + contextualActionShelfIds = selectedShelfIds, + contextualActionItems = internalState.contextualActionItems + .mapNotNull { ctx -> cache.allLibraryFilesById[ctx.bookId] } + .toSet(), + shelves = cache.shelfProjection.shelves, + openTabs = internalState.openTabIds.mapNotNull { tabId -> cache.allLibraryFilesById[tabId] }, + booksAvailableForAdding = booksAvailableForAdding, + allTags = cache.dbTags + ) + } + + private fun buildShelves( + allLibraryFiles: List, + dbShelves: List, + shelfRefs: List, + dbTags: List, + sortOrder: SortOrder, + syncedFolders: List + ): ShelfProjection { + val allShelves = mutableListOf() + val shelvedBookIds = mutableSetOf() + val baseFilesMap = allLibraryFiles.associateBy { it.bookId } + val shelfRefsByShelfId = shelfRefs.groupBy { it.shelfId } + val taggedBookIdsByTagId = mutableMapOf>() + + allLibraryFiles.forEach { item -> + item.tags.forEach { tag -> + taggedBookIdsByTagId.getOrPut(tag.id) { mutableListOf() }.add(item.bookId) + } + } + + dbShelves.forEach { shelfEntity -> + if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) { + val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson) + if (rules != null) { + val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it, rules) } + allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder))) + shelvedBookIds.addAll(matchingBooks.map { it.bookId }) + } + } else { + val bookIdsInShelf = shelfRefsByShelfId[shelfEntity.id].orEmpty() + .sortedBy { it.addedAt } + .map { it.bookId } + val booksInShelf = bookIdsInShelf.mapNotNull { baseFilesMap[it] } + allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.MANUAL, sortFiles(booksInShelf, sortOrder))) + shelvedBookIds.addAll(bookIdsInShelf) + } + } + + val tagShelves = dbTags.mapNotNull { tag -> + val taggedBooks = taggedBookIdsByTagId[tag.id].orEmpty().mapNotNull { baseFilesMap[it] } + if (taggedBooks.isEmpty()) { + null + } else { + Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortFiles(taggedBooks, sortOrder)) + } + } + allShelves.addAll(tagShelves) + + val seriesShelves = allLibraryFiles + .filter { !it.seriesName.isNullOrBlank() } + .groupBy { it.seriesName!! } + .filter { it.value.size >= 2 } + .map { (series, books) -> + val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 } + shelvedBookIds.addAll(books.map { it.bookId }) + Shelf("series_$series", series, ShelfType.SERIES, sortedSeries) + } + allShelves.addAll(seriesShelves) + + val folderShelves = buildFolderShelves( + allLibraryFiles = allLibraryFiles, + syncedFolders = syncedFolders, + sortOrder = sortOrder + ).also { shelves -> + shelves.forEach { shelf -> + shelvedBookIds.addAll(shelf.books.map { it.bookId }) + } + } + allShelves.addAll(folderShelves) + + val unshelvedBooks = allLibraryFiles.filter { it.bookId !in shelvedBookIds } + allShelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortFiles(unshelvedBooks, sortOrder))) + + allShelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey })) + return ShelfProjection(shelves = allShelves, unshelvedBooks = unshelvedBooks) + } + + private fun buildFolderShelves( + allLibraryFiles: List, + syncedFolders: List, + sortOrder: SortOrder + ): List { + val folderNamesByUri = syncedFolders.associate { it.uriString to it.name } + val folderSegmentsByBookId = allLibraryFiles + .asSequence() + .filter { it.sourceFolderUri != null } + .associate { it.bookId to folderPathResolver.relativeFolderSegments(it) } + + return allLibraryFiles + .filter { it.sourceFolderUri != null } + .groupBy { it.sourceFolderUri!! } + .flatMap { (folderUri, books) -> + val rootName = folderNamesByUri[folderUri] ?: "Local Folder" + val rootShelfId = "folder_$folderUri" + val rootAccumulator = FolderShelfAccumulator( + id = rootShelfId, + name = rootName, + depth = 0, + parentShelfId = null, + sortPath = "" + ) + val rootShelf = Shelf( + id = rootShelfId, + name = rootName, + type = ShelfType.FOLDER, + books = sortFiles(books, sortOrder), + directBooks = emptyList(), + childShelfIds = emptyList(), + depth = 0, + sortKey = "folder:${rootName.lowercase()}:" + ) + + val nestedShelves = linkedMapOf() + val nestedShelvesById = mutableMapOf() + books.forEach { book -> + rootAccumulator.books.add(book) + val segments = folderSegmentsByBookId[book.bookId].orEmpty() + if (segments.isEmpty()) { + rootAccumulator.directBooks.add(book) + } + var currentPath = "" + var parentShelfId = rootShelfId + segments.forEachIndexed { index, segment -> + currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment" + val shelfId = "folder_$folderUri::$currentPath" + val accumulator = nestedShelves.getOrPut(currentPath) { + val newShelf = FolderShelfAccumulator( + id = shelfId, + name = segment, + depth = index + 1, + parentShelfId = parentShelfId, + sortPath = currentPath.lowercase() + ) + if (parentShelfId == rootShelfId) { + rootAccumulator.childShelfIds.add(shelfId) + } else { + nestedShelvesById[parentShelfId]?.childShelfIds?.add(shelfId) + } + nestedShelvesById[shelfId] = newShelf + newShelf + } + accumulator.books.add(book) + if (index == segments.lastIndex) { + accumulator.directBooks.add(book) + } + parentShelfId = shelfId + } + } + + val sortedNestedShelves = nestedShelves + .values + .sortedBy { it.sortPath } + .map { shelf -> + Shelf( + id = shelf.id, + name = shelf.name, + type = ShelfType.FOLDER, + books = sortFiles(shelf.books, sortOrder), + directBooks = sortFiles(shelf.directBooks, sortOrder), + parentShelfId = shelf.parentShelfId, + childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() }, + depth = shelf.depth, + sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}" + ) + } + + listOf( + rootShelf.copy( + directBooks = sortFiles(rootAccumulator.directBooks, sortOrder), + childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() } + ) + ) + sortedNestedShelves + } + } + + private data class FolderShelfAccumulator( + val id: String, + val name: String, + val depth: Int, + val parentShelfId: String?, + val sortPath: String, + val books: MutableList = mutableListOf(), + val directBooks: MutableList = mutableListOf(), + val childShelfIds: MutableList = mutableListOf() + ) + + private data class ShelfProjection( + val shelves: List, + val unshelvedBooks: List + ) + + private data class ProjectionCacheKey( + val recentFilesFromDb: List, + val dbShelves: List, + val shelfRefs: List, + val dbTags: List, + val tagRefs: List, + val folderKeys: List, + val sortOrder: SortOrder, + val searchQuery: String, + val libraryFilters: LibraryFilters, + val recentFilesLimit: Int + ) + + private data class SyncedFolderProjectionKey( + val uriString: String, + val name: String + ) + + private data class CachedProjection( + val key: ProjectionCacheKey, + val allLibraryFiles: List, + val allLibraryFilesById: Map, + val sortedLibraryFiles: List, + val visibleRecentFiles: List, + val shelfProjection: ShelfProjection, + val validShelfIds: Set, + val dbTags: List + ) +} + +fun filterBySearch(files: List, searchQuery: String): List { + val query = searchQuery.trim() + return if (query.isBlank()) { + files + } else { + files.filter { item -> + item.displayName.contains(query, ignoreCase = true) || + item.title?.contains(query, ignoreCase = true) == true || + item.author?.contains(query, ignoreCase = true) == true || + item.tags.any { tag -> tag.name.contains(query, ignoreCase = true) } + } + } +} + +fun applyLibraryFilters(files: List, filters: LibraryFilters): List { + return files.filter { item -> + val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true + val matchFolder = if (filters.sourceFolders.isNotEmpty()) { + val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") && + item.sourceFolderUri == null && + item.uriString?.startsWith("opds-pse") != true + val matchesSynced = item.sourceFolderUri in filters.sourceFolders + matchesInApp || matchesSynced + } else { + true + } + val progress = item.progressPercentage ?: 0f + val matchStatus = when (filters.readStatus) { + ReadStatusFilter.ALL -> true + ReadStatusFilter.UNREAD -> progress == 0f + ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f + ReadStatusFilter.COMPLETED -> progress >= 100f + } + val matchTags = if (filters.tagIds.isNotEmpty()) { + item.tags.any { it.id in filters.tagIds } + } else { + true + } + matchType && matchFolder && matchStatus && matchTags + } +} + +fun sortFiles(files: List, sortOrder: SortOrder): List { + return when (sortOrder) { + SortOrder.RECENT -> files.sortedByDescending { it.timestamp } + SortOrder.TITLE_ASC -> files.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() } + SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() }) + SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f } + SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f } + SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize } + SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize } + } +} diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/aryan/reader/MainActivity.kt index 1e325c4..8df59dd 100644 --- a/app/src/main/java/com/aryan/reader/MainActivity.kt +++ b/app/src/main/java/com/aryan/reader/MainActivity.kt @@ -45,7 +45,9 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi +@UnstableApi class MainActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels() @@ -91,12 +93,14 @@ class MainActivity : AppCompatActivity() { AppThemeMode.SYSTEM -> isSystemInDarkTheme() } + val textDimFactor = if (darkTheme) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight + AppTheme( darkTheme = darkTheme, dynamicColor = uiState.appSeedColor == null, seedColor = uiState.appSeedColor, contrastLevel = uiState.appContrastOption.value, - textDimFactor = uiState.appTextDimFactor + textDimFactor = textDimFactor ) { Surface( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/aryan/reader/MainPreferenceKeys.kt b/app/src/main/java/com/aryan/reader/MainPreferenceKeys.kt new file mode 100644 index 0000000..e12fd41 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/MainPreferenceKeys.kt @@ -0,0 +1,14 @@ +package com.aryan.reader + +internal const val KEY_RENDER_MODE = "render_mode" +internal const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled" +internal const val KEY_MAIN_SCREEN_START_PAGE = "main_screen_start_page" +internal const val KEY_LIBRARY_SCREEN_START_PAGE = "library_screen_start_page" +internal const val KEY_LAST_VIEWING_SHELF_ID = "last_viewing_shelf_id" +internal const val KEY_LAST_ADDING_BOOKS_TO_SHELF = "last_adding_books_to_shelf" + +internal const val KEY_FILTER_FILE_TYPES = "filter_file_types" +internal const val KEY_FILTER_FOLDERS = "filter_folders" +internal const val KEY_FILTER_READ_STATUS = "filter_read_status" +internal const val KEY_FILTER_TAG_IDS = "filter_tag_ids" +internal const val KEY_DEFAULT_TAGS_SEEDED = "default_tags_seeded" diff --git a/app/src/main/java/com/aryan/reader/MainScreen.kt b/app/src/main/java/com/aryan/reader/MainScreen.kt index 07884d1..7e5a4ff 100644 --- a/app/src/main/java/com/aryan/reader/MainScreen.kt +++ b/app/src/main/java/com/aryan/reader/MainScreen.kt @@ -22,10 +22,9 @@ package com.aryan.reader import androidx.activity.ComponentActivity import androidx.activity.enableEdgeToEdge +import androidx.annotation.OptIn import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem @@ -33,17 +32,15 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi import androidx.navigation.NavHostController -import kotlinx.coroutines.launch sealed class BottomBarScreen(val route: String, val stringResId: Int, val iconResId: Int) { object Home : BottomBarScreen("home", R.string.nav_home, R.drawable.home) @@ -55,6 +52,7 @@ private val bottomBarItems = listOf( BottomBarScreen.Library, ) +@OptIn(UnstableApi::class) @Composable fun MainScreen( viewModel: MainViewModel, @@ -75,21 +73,7 @@ fun MainScreen( if (viewingShelfName != null) { ShelfScreen(viewModel = viewModel) } else { - val pagerState = rememberPagerState( - initialPage = uiState.mainScreenStartPage, - pageCount = { bottomBarItems.size } - ) - val scope = rememberCoroutineScope() - - LaunchedEffect(uiState.mainScreenStartPage) { - if (pagerState.currentPage != uiState.mainScreenStartPage) { - pagerState.animateScrollToPage(uiState.mainScreenStartPage) - } - } - - LaunchedEffect(pagerState.currentPage) { - viewModel.setMainScreenPage(pagerState.currentPage) - } + val selectedPage = uiState.mainScreenStartPage.coerceIn(0, bottomBarItems.lastIndex) Scaffold( contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0), @@ -99,23 +83,28 @@ fun MainScreen( NavigationBarItem( icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) }, label = { Text(stringResource(screen.stringResId)) }, - selected = pagerState.currentPage == index, - onClick = { scope.launch { pagerState.animateScrollToPage(index) } } + selected = selectedPage == index, + onClick = { + ReaderPerfLog.d("MainPager click page=$index route=${screen.route}") + if (selectedPage != index) { + val animStart = ReaderPerfLog.nowNanos() + viewModel.setMainScreenPage(index) + ReaderPerfLog.d( + "MainPager settled page=$index elapsed=${ReaderPerfLog.elapsedMs(animStart)}ms" + ) + } + } ) } } } ) { innerPadding -> - HorizontalPager( - state = pagerState, + androidx.compose.foundation.layout.Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding), - key = { bottomBarItems[it].route }, - beyondViewportPageCount = 1, - userScrollEnabled = false - ) { page -> - when (page) { + .padding(innerPadding) + ) { + when (selectedPage) { 0 -> HomeScreen( viewModel = viewModel, windowSizeClass = windowSizeClass, diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 1f1bdd5..3703770 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -38,7 +38,7 @@ import kotlinx.serialization.protobuf.ProtoBuf import com.aryan.reader.paginatedreader.semanticBlockModule import android.provider.DocumentsContract import android.provider.OpenableColumns -import androidx.annotation.StringRes +import androidx.annotation.OptIn import androidx.compose.ui.graphics.toArgb import androidx.core.content.edit import androidx.core.graphics.createBitmap @@ -48,6 +48,7 @@ import androidx.credentials.exceptions.NoCredentialException import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import androidx.media3.common.util.UnstableApi import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo @@ -64,8 +65,8 @@ import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.data.RemoteConfigRepository import com.aryan.reader.data.ShelfMetadata -import com.aryan.reader.data.SmartCollectionEngine import com.aryan.reader.data.TagEntity +import com.aryan.reader.data.getUri import com.aryan.reader.data.toBookMetadata import com.aryan.reader.data.toRecentFileItem import com.aryan.reader.epub.CalibreBundleExtractor @@ -75,6 +76,7 @@ import com.aryan.reader.epub.EpubParser import com.aryan.reader.epub.ImportedFileCache import com.aryan.reader.epub.MobiParser import com.aryan.reader.epub.SingleFileImporter +import com.aryan.reader.epub.hasReadableExtractedContent import com.aryan.reader.ml.ISpeechBubbleDetector import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.paginatedreader.Locator @@ -120,60 +122,18 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.ExperimentalSerializationApi import org.json.JSONArray import org.json.JSONObject import timber.log.Timber import java.io.File import java.io.FileOutputStream -import java.util.Date import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CancellationException import java.util.concurrent.Executors.newSingleThreadExecutor import java.util.concurrent.TimeUnit -private const val KEY_RENDER_MODE = "render_mode" -private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled" -private const val KEY_MAIN_SCREEN_START_PAGE = "main_screen_start_page" -private const val KEY_LIBRARY_SCREEN_START_PAGE = "library_screen_start_page" -private const val KEY_LAST_VIEWING_SHELF_ID = "last_viewing_shelf_id" -private const val KEY_LAST_ADDING_BOOKS_TO_SHELF = "last_adding_books_to_shelf" - -private const val KEY_FILTER_FILE_TYPES = "filter_file_types" -private const val KEY_FILTER_FOLDERS = "filter_folders" -private const val KEY_FILTER_READ_STATUS = "filter_read_status" -private const val KEY_FILTER_TAG_IDS = "filter_tag_ids" -private const val KEY_DEFAULT_TAGS_SEEDED = "default_tags_seeded" -private val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7) -private val EPUB_READER_FILE_TYPES = setOf( - FileType.EPUB, - FileType.MOBI, - FileType.MD, - FileType.TXT, - FileType.HTML, - FileType.FB2, - FileType.DOCX, - FileType.ODT, - FileType.FODT -) - -data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false) - -data class ImportResult( - val internalUri: Uri, - val bookId: String, - val type: FileType, - val bundleResult: CalibreBundleResult? = null -) - -data class UserData( - val uid: String, val displayName: String?, val photoUrl: String?, val email: String? -) - -data class NavigationEvent( - val route: String, val bookId: String? = null, val uri: Uri? = null -) - private data class SpeechBubbleCacheKey( val documentId: String, val pageIndex: Int @@ -187,166 +147,8 @@ private data class CachedSpeechBubble( val maskBitmap: Bitmap? ) -enum class AddBooksSource(@StringRes val labelRes: Int) { - UNSHELVED(R.string.add_books_source_unshelved), - ALL_BOOKS(R.string.add_books_source_all_books) -} - -enum class AppThemeMode(@StringRes val labelRes: Int) { - SYSTEM(R.string.app_theme_mode_system), - LIGHT(R.string.app_theme_mode_light), - DARK(R.string.app_theme_mode_dark) -} - -enum class AppContrastOption(@StringRes val labelRes: Int, val value: Double) { - STANDARD(R.string.app_contrast_standard, 0.0), - MEDIUM(R.string.app_contrast_medium, 0.5), - HIGH(R.string.app_contrast_high, 1.0) -} - -data class CustomAppTheme( - val id: String, - val name: String, - val seedColor: androidx.compose.ui.graphics.Color -) - -enum class FileType { - PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT -} - -enum class RenderMode { - VERTICAL_SCROLL, PAGINATED -} - -data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?) - -data class DeviceLimitReachedState( - val isLimitReached: Boolean = false, val registeredDevices: List = emptyList() -) - -data class SyncedFolder( - val uriString: String, val name: String, val lastScanTime: Long, val allowedFileTypes: Set = FileType.entries.toSet() -) - -enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER } - -data class Shelf( - val id: String, - val name: String, - val type: ShelfType, - val books: List, - val directBooks: List = books, - val parentShelfId: String? = null, - val childShelfIds: List = emptyList(), - val depth: Int = 0, - val sortKey: String = name.lowercase() -) { - val bookCount: Int get() = books.size - val topBook: RecentFileItem? get() = books.maxByOrNull { it.timestamp } - val directBookCount: Int get() = directBooks.size - val childShelfCount: Int get() = childShelfIds.size -} - -enum class SortOrder(@StringRes val labelRes: Int) { - RECENT(R.string.sort_recent), - TITLE_ASC(R.string.sort_title_az), - AUTHOR_ASC(R.string.sort_author_az), - PERCENT_ASC(R.string.sort_percent_asc), - PERCENT_DESC(R.string.sort_percent_desc), - SIZE_ASC(R.string.sort_size_smallest), - SIZE_DESC(R.string.sort_size_biggest) -} - -enum class ReadStatusFilter(@StringRes val labelRes: Int) { - ALL(R.string.read_status_all), - UNREAD(R.string.read_status_unread), - IN_PROGRESS(R.string.read_status_in_progress), - COMPLETED(R.string.read_status_completed) -} - -data class LibraryFilters( - val fileTypes: Set = emptySet(), - val sourceFolders: Set = emptySet(), - val readStatus: ReadStatusFilter = ReadStatusFilter.ALL, - val tagIds: Set = emptySet() -) { - val isActive: Boolean - get() = fileTypes.isNotEmpty() || - sourceFolders.isNotEmpty() || - readStatus != ReadStatusFilter.ALL || - tagIds.isNotEmpty() -} - -data class ReaderScreenState( - val selectedPdfUri: Uri? = null, - val selectedBookId: String? = null, - val selectedEpubBook: EpubBook? = null, - val selectedEpubUri: Uri? = null, - val selectedFileType: FileType? = null, - val isLoading: Boolean = false, - val errorMessage: String? = null, - val contextualActionItems: Set = emptySet(), - val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL, - val sortOrder: SortOrder = SortOrder.RECENT, - val initialLocator: Locator? = null, - val initialCfi: String? = null, - val initialBookmarksJson: String? = null, - val initialHighlightsJson: String? = null, - val initialPageInBook: Int? = null, - val shelves: List = emptyList(), - val viewingShelfId: String? = null, - val isAddingBooksToShelf: Boolean = false, - val showCreateShelfDialog: Boolean = false, - val mainScreenStartPage: Int = 0, - val libraryScreenStartPage: Int = 0, - val showRenameShelfDialogFor: String? = null, - val showDeleteShelfDialogFor: String? = null, - val addBooksSource: AddBooksSource = AddBooksSource.UNSHELVED, - val booksSelectedForAdding: Set = emptySet(), - val booksAvailableForAdding: List = emptyList(), - val contextualActionShelfIds: Set = emptySet(), - val currentUser: UserData? = null, - val isAuthMenuExpanded: Boolean = false, - val isProUser: Boolean = false, - val credits: Int = 0, - val isSyncEnabled: Boolean = false, - val isFolderSyncEnabled: Boolean = false, - val bannerMessage: BannerMessage? = null, - val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(), - val isReplacingDevice: Boolean = false, - val isRequestingDrivePermission: Boolean = false, - val downloadingBookIds: Set = emptySet(), - val uploadingBookIds: Set = emptySet(), - val syncedFolders: List = emptyList(), - val lastFolderScanTime: Long? = null, - val hasUnreadFeedback: Boolean = false, - val searchQuery: String = "", - val isSearchActive: Boolean = false, - val isRefreshing: Boolean = false, - val reflowProgress: Float? = null, - val recentFiles: List = emptyList(), - val allRecentFiles: List = emptyList(), - val rawLibraryFiles: List = emptyList(), - val pinnedHomeBookIds: Set = emptySet(), - val pinnedLibraryBookIds: Set = emptySet(), - val libraryFilters: LibraryFilters = LibraryFilters(), - val recentFilesLimit: Int = 0, - val isTabsEnabled: Boolean = false, - val openTabIds: List = emptyList(), - val openTabs: List = emptyList(), - val activeTabBookId: String? = null, - val showExternalFileSavePromptFor: String? = null, - val externalFileBehavior: String = "ASK", - val useStrictFileFilter: Boolean = false, - val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM, - val appContrastOption: AppContrastOption = AppContrastOption.STANDARD, - val appTextDimFactor: Float = 1.0f, - val appSeedColor: androidx.compose.ui.graphics.Color? = null, - val customAppThemes: List = emptyList(), - val allTags: List = emptyList(), - val showTagSelectionDialogFor: Set = emptySet(), -) - +@kotlin.OptIn(ExperimentalSerializationApi::class) +@UnstableApi open class MainViewModel(application: Application) : AndroidViewModel(application) { private val appContext: Context = application.applicationContext private val authRepository = AuthRepository(appContext) @@ -380,6 +182,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private val _prefsUpdateFlow = MutableStateFlow(0L) private val prefsListener: SharedPreferences.OnSharedPreferenceChangeListener private val feedbackRepository = FeedbackRepository(appContext) + private val libraryStateProjector = LibraryStateProjector(AndroidFolderPathResolver()) private var feedbackListener: Any? = null private val importMutex = Mutex() private val epubRecoveryMutex = Mutex() @@ -611,7 +414,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val totalFileLength = if (contentLength != -1L) downloadedBytes + contentLength else -1L val input = connection.inputStream - val output = java.io.FileOutputStream(tempFile, isPartial) + val output = FileOutputStream(tempFile, isPartial) val data = ByteArray(16 * 1024) var count: Int @@ -744,7 +547,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio appContrastOption = try { AppContrastOption.valueOf(prefs.getString(KEY_APP_CONTRAST_OPTION, AppContrastOption.STANDARD.name) ?: AppContrastOption.STANDARD.name) } catch (_: Exception) { AppContrastOption.STANDARD }, - appTextDimFactor = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f), + 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, customAppThemes = loadCustomAppThemes(prefs) ) @@ -808,299 +612,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio open val uiState: StateFlow = combine( _internalState, libraryFlow, tagFlow ) { internalState, (recentFilesFromDb, dbShelves, shelfRefs), (dbTags, tagRefs) -> - val tagsById = dbTags.associateBy { it.id } - val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry -> - entry.value.mapNotNull { tagsById[it.tagId] } + withContext(Dispatchers.Default) { + libraryStateProjector.project( + LibraryProjectionInput( + state = internalState, + recentFilesFromDb = recentFilesFromDb, + dbShelves = dbShelves, + shelfRefs = shelfRefs, + dbTags = dbTags, + tagRefs = tagRefs + ) + ) } - - val allLibraryFiles = recentFilesFromDb - .filterNot { it.bookId.endsWith("_reflow") } - .map { item -> - item.copy(tags = bookTagsMap[item.bookId] ?: emptyList()) - } - - val query = internalState.searchQuery.trim() - val rawFilteredByQuery = if (query.isBlank()) { - allLibraryFiles - } else { - allLibraryFiles.filter { item -> - item.displayName.contains(query, ignoreCase = true) || - item.title?.contains(query, ignoreCase = true) == true || - item.author?.contains(query, ignoreCase = true) == true || - item.tags.any { tag -> tag.name.contains(query, ignoreCase = true) } - } - } - - val filters = internalState.libraryFilters - val libraryFiltered = rawFilteredByQuery.filter { item -> - val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true - val matchFolder = if (filters.sourceFolders.isNotEmpty()) { - val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") && item.sourceFolderUri == null && item.uriString?.startsWith("opds-pse") != true - val matchesSynced = item.sourceFolderUri in filters.sourceFolders - matchesInApp || matchesSynced - } else true - val progress = item.progressPercentage ?: 0f - val matchStatus = when (filters.readStatus) { - ReadStatusFilter.ALL -> true - ReadStatusFilter.UNREAD -> progress == 0f - ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f - ReadStatusFilter.COMPLETED -> progress >= 100f - } - val matchTags = if (filters.tagIds.isNotEmpty()) { - item.tags.any { it.id in filters.tagIds } - } else true - matchType && matchFolder && matchStatus && matchTags - } - - fun sortFiles(files: List): List { - return when (internalState.sortOrder) { - SortOrder.RECENT -> files.sortedByDescending { it.timestamp } - SortOrder.TITLE_ASC -> files.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() } - SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() }) - SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f } - SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f } - SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize } - SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize } - } - } - - val sortedLibraryFiles = sortFiles(libraryFiltered) - val visibleRecentFiles = sortFiles(allLibraryFiles.filter { it.isRecent }).take( - if (internalState.recentFilesLimit > 0) internalState.recentFilesLimit else Int.MAX_VALUE - ) - val openTabsList = internalState.openTabIds.mapNotNull { tabId -> allLibraryFiles.find { it.bookId == tabId } } - val allShelves = mutableListOf() - val shelvedBookIds = mutableSetOf() - val baseFilesMap = allLibraryFiles.associateBy { it.bookId } - - dbShelves.forEach { shelfEntity -> - if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) { - val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson) - if (rules != null) { - val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it, rules) } - allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks))) - shelvedBookIds.addAll(matchingBooks.map { it.bookId }) - } - } else { - val bookIdsInShelf = shelfRefs.filter { it.shelfId == shelfEntity.id }.sortedBy { it.addedAt }.map { it.bookId } - val booksInShelf = bookIdsInShelf.mapNotNull { baseFilesMap[it] } - allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.MANUAL, sortFiles(booksInShelf))) - shelvedBookIds.addAll(bookIdsInShelf) - } - } - - val tagShelves = dbTags.mapNotNull { tag -> - val taggedBooks = allLibraryFiles.filter { item -> item.tags.any { it.id == tag.id } } - if (taggedBooks.isEmpty()) { - null - } else { - Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortFiles(taggedBooks)) - } - } - allShelves.addAll(tagShelves) - - val seriesShelves = allLibraryFiles - .filter { !it.seriesName.isNullOrBlank() } - .groupBy { it.seriesName!! } - .filter { it.value.size >= 2 } - .map { (series, books) -> - val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 } - shelvedBookIds.addAll(books.map { it.bookId }) - Shelf("series_$series", series, ShelfType.SERIES, sortedSeries) - } - allShelves.addAll(seriesShelves) - - val folderShelves = buildFolderShelves( - allLibraryFiles = allLibraryFiles, - syncedFolders = internalState.syncedFolders, - sortFiles = ::sortFiles - ).also { shelves -> - shelves.forEach { shelf -> - shelvedBookIds.addAll(shelf.books.map { it.bookId }) - } - } - allShelves.addAll(folderShelves) - - val unshelvedBooks = allLibraryFiles.filter { it.bookId !in shelvedBookIds } - allShelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortFiles(unshelvedBooks))) - - allShelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey })) - - val validShelfIds = allShelves.mapTo(mutableSetOf()) { it.id } - val viewingShelfId = internalState.viewingShelfId?.takeIf { it in validShelfIds } - val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in validShelfIds } - - val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) { - val currentShelfBookIds = allShelves - .find { it.id == viewingShelfId } - ?.books - ?.map { it.bookId } - ?.toSet() - ?: emptySet() - when (internalState.addBooksSource) { - AddBooksSource.UNSHELVED -> unshelvedBooks - AddBooksSource.ALL_BOOKS -> allLibraryFiles.filter { it.bookId !in currentShelfBookIds } - } - } else emptyList() - - internalState.copy( - recentFiles = visibleRecentFiles, - allRecentFiles = sortedLibraryFiles, - rawLibraryFiles = allLibraryFiles, - viewingShelfId = viewingShelfId, - isAddingBooksToShelf = internalState.isAddingBooksToShelf && viewingShelfId != null, - contextualActionShelfIds = selectedShelfIds, - contextualActionItems = internalState.contextualActionItems.mapNotNull { ctx -> allLibraryFiles.find { it.bookId == ctx.bookId } }.toSet(), - shelves = allShelves, - openTabs = openTabsList, - booksAvailableForAdding = booksAvailableForAdding, - allTags = dbTags - ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = _internalState.value ) - private data class FolderShelfAccumulator( - val id: String, - val name: String, - val depth: Int, - val parentShelfId: String?, - val sortPath: String, - val books: MutableList = mutableListOf(), - val directBooks: MutableList = mutableListOf(), - val childShelfIds: MutableList = mutableListOf() - ) - - private fun buildFolderShelves( - allLibraryFiles: List, - syncedFolders: List, - sortFiles: (List) -> List - ): List { - val folderNamesByUri = syncedFolders.associate { it.uriString to it.name } - - return allLibraryFiles - .filter { it.sourceFolderUri != null } - .groupBy { it.sourceFolderUri!! } - .flatMap { (folderUri, books) -> - val rootName = folderNamesByUri[folderUri] ?: "Local Folder" - val rootShelfId = "folder_$folderUri" - val rootAccumulator = FolderShelfAccumulator( - id = rootShelfId, - name = rootName, - depth = 0, - parentShelfId = null, - sortPath = "" - ) - val rootShelf = Shelf( - id = rootShelfId, - name = rootName, - type = ShelfType.FOLDER, - books = sortFiles(books), - directBooks = mutableListOf().also { direct -> - direct.addAll(books.filter { getRelativeFolderSegments(it).isEmpty() }) - }, - childShelfIds = emptyList(), - depth = 0, - sortKey = "folder:${rootName.lowercase()}:" - ) - - val nestedShelves = linkedMapOf() - books.forEach { book -> - rootAccumulator.books.add(book) - val segments = getRelativeFolderSegments(book) - if (segments.isEmpty()) { - rootAccumulator.directBooks.add(book) - } - var currentPath = "" - var parentShelfId = rootShelfId - segments.forEachIndexed { index, segment -> - currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment" - val shelfId = "folder_$folderUri::$currentPath" - val accumulator = nestedShelves.getOrPut(currentPath) { - val newShelf = FolderShelfAccumulator( - id = shelfId, - name = segment, - depth = index + 1, - parentShelfId = parentShelfId, - sortPath = currentPath.lowercase() - ) - if (parentShelfId == rootShelfId) { - rootAccumulator.childShelfIds.add(shelfId) - } else { - nestedShelves.values.find { it.id == parentShelfId }?.childShelfIds?.add(shelfId) - } - newShelf - } - accumulator.books.add(book) - if (index == segments.lastIndex) { - accumulator.directBooks.add(book) - } - parentShelfId = shelfId - } - } - - val sortedNestedShelves = nestedShelves - .values - .sortedBy { it.sortPath } - .map { shelf -> - Shelf( - id = shelf.id, - name = shelf.name, - type = ShelfType.FOLDER, - books = sortFiles(shelf.books), - directBooks = sortFiles(shelf.directBooks), - parentShelfId = shelf.parentShelfId, - childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() }, - depth = shelf.depth, - sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}" - ) - } - - listOf( - rootShelf.copy( - directBooks = sortFiles(rootAccumulator.directBooks), - childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() } - ) - ) + sortedNestedShelves - } - } - - private fun getRelativeFolderSegments(item: RecentFileItem): List { - val documentUriString = item.uriString ?: return emptyList() - val rootFolderUriString = item.sourceFolderUri ?: return emptyList() - - return try { - val documentUri = documentUriString.toUri() - val rootFolderUri = rootFolderUriString.toUri() - val rootDocId = DocumentsContract.getTreeDocumentId(rootFolderUri) - val documentId = when { - DocumentsContract.isDocumentUri(appContext, documentUri) -> DocumentsContract.getDocumentId(documentUri) - DocumentsContract.isTreeUri(documentUri) -> DocumentsContract.getTreeDocumentId(documentUri) - else -> return emptyList() - } - - val rootPath = rootDocId.substringAfter(':', "") - val documentPath = documentId.substringAfter(':', "") - val relativeDocumentPath = when { - rootPath.isBlank() -> documentPath - documentPath == rootPath -> "" - documentPath.startsWith("$rootPath/") -> documentPath.removePrefix("$rootPath/") - else -> documentPath - } - - relativeDocumentPath - .substringBeforeLast('/', "") - .split('/') - .map { Uri.decode(it).trim() } - .filter { it.isNotEmpty() } - } catch (e: Exception) { - Timber.tag("FolderShelves").w(e, "Failed to derive relative folder path for ${item.displayName}") - emptyList() - } - } - fun setTabsEnabled(enabled: Boolean) { prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) } _internalState.update { it.copy(isTabsEnabled = enabled) } @@ -1529,7 +1058,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.d("ViewModel instance created.") WorkManager.getInstance(application).cancelUniqueWork(FolderSyncWorker.WORK_NAME) - // --- ADD THIS BLOCK --- val locatorConverter = LocatorConverter( bookCacheDao, ProtoBuf { serializersModule = semanticBlockModule }, @@ -1814,7 +1342,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio inputStream = inputStream, bookId = bookId, originalBookNameHint = displayName - ) ?: throw Exception("MobiParser returned null. The file might be DRM-protected or invalid.") + ) ?: throw Exception( + if (MobiParser.isNativeParserAvailable) { + "MobiParser returned null. The file might be DRM-protected or invalid." + } else { + MobiParser.nativeParserUnavailableMessage() + } + ) FileType.FB2 -> fb2Parser.createFb2Book( inputStream = inputStream, @@ -1860,10 +1394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (latestState.selectedBookId != bookId || latestState.selectedEpubUri != uri) { return@withLock } - if (latestState.selectedEpubBook?.extractionBasePath?.let { path -> - path.isNotBlank() && File(path).exists() - } == true - ) { + if (latestState.selectedEpubBook?.hasReadableExtractedContent() == true) { return@withLock } @@ -2822,7 +2353,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } - scanSyncedFolder() + triggerFolderSyncWorker( + metadataOnly = false, + showFeedback = true, + targetFolderUriString = newFolder.uriString + ) showBanner(appContext.getString(R.string.banner_folder_added, name)) @@ -2835,6 +2370,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun removeSyncedFolder(folder: SyncedFolder) { viewModelScope.launch { + val workManager = WorkManager.getInstance(appContext) + ReaderPerfLog.d("FolderRemove request folder=${folder.uriString}") + workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME) + workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME) + val currentFolders = _internalState.value.syncedFolders.toMutableList() currentFolders.removeAll { it.uriString == folder.uriString } @@ -2842,9 +2382,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(syncedFolders = currentFolders) } val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString) - filesToRemove.forEach { cleanupBookDataLocally(it.bookId) } - recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) + filesToRemove.forEach { cleanupBookDataLocally(it.bookId) } try { appContext.contentResolver.releasePersistableUriPermission( folder.uriString.toUri(), @@ -2855,7 +2394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } if (currentFolders.isEmpty()) { - WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME) + workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME) } showBanner(appContext.getString(R.string.banner_folder_removed)) @@ -2870,16 +2409,33 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio triggerFolderSyncWorker(metadataOnly = false, showFeedback = true) } - private fun triggerFolderSyncWorker(metadataOnly: Boolean, showFeedback: Boolean) { + private fun triggerFolderSyncWorker( + metadataOnly: Boolean, + showFeedback: Boolean, + targetFolderUriString: String? = null + ) { val folders = _internalState.value.syncedFolders if (folders.isEmpty()) return - Timber.tag("FolderSync") - .d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly, feedback=$showFeedback)") + val targetFolderName = targetFolderUriString + ?.let { target -> folders.firstOrNull { it.uriString == target }?.name ?: target } + ReaderPerfLog.d( + "FolderSync request folders=${folders.size} target=${targetFolderName ?: "ALL"} " + + "metadataOnly=$metadataOnly feedback=$showFeedback" + ) val workManager = WorkManager.getInstance(appContext) + if (!metadataOnly) { + workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME) + } val data = androidx.work.Data.Builder() - .putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly).build() + .putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly) + .apply { + if (!targetFolderUriString.isNullOrBlank()) { + putString(FolderSyncWorker.KEY_TARGET_FOLDER_URI, targetFolderUriString) + } + } + .build() val request = OneTimeWorkRequestBuilder().setInputData(data).build() @@ -2958,7 +2514,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } withContext(Dispatchers.Main) { - scanSyncedFolder() + triggerFolderSyncWorker( + metadataOnly = false, + showFeedback = true, + targetFolderUriString = folder.uriString + ) } } } @@ -2966,12 +2526,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun disconnectAllSyncedFolders() { viewModelScope.launch { + val workManager = WorkManager.getInstance(appContext) + ReaderPerfLog.d("FolderRemove disconnect all folders=${_internalState.value.syncedFolders.size}") + workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME) + workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME) + workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME) + val folders = _internalState.value.syncedFolders folders.forEach { folder -> val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString) - filesToRemove.forEach { cleanupBookDataLocally(it.bookId) } - recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) + filesToRemove.forEach { cleanupBookDataLocally(it.bookId) } try { appContext.contentResolver.releasePersistableUriPermission( folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION @@ -2985,8 +2550,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio remove(KEY_SYNCED_FOLDER_URI) } _internalState.update { it.copy(syncedFolders = emptyList()) } - - WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME) } } @@ -3869,6 +3432,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1) val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet() val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet() + val validActiveBookCacheDirs = allDbIds.mapTo(mutableSetOf()) { + ImportedFileCache.activeBookDirName(it) + } ImportedFileCache.deleteStaleTemporaryBookDirs(appContext, TimeUnit.HOURS.toMillis(1)) cacheDir.listFiles()?.forEach { file -> @@ -3879,10 +3445,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (deleted) Timber.d("Sweeper cleaned old temp file: $name") } } else if (ImportedFileCache.isActiveBookDir(name)) { - val bookId = name.removePrefix("imported_file_") - if (bookId !in allDbIds) { + val legacyBookId = name.removePrefix("imported_file_") + if (name !in validActiveBookCacheDirs && legacyBookId !in allDbIds && file.lastModified() < oneHourAgo) { val deleted = file.deleteRecursively() - if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache for: $bookId") + if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache: $name") } } else if (name.startsWith("opds_stream_")) { val bookIdHash = name.removePrefix("opds_stream_") @@ -4274,6 +3840,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null ) { val openBookStartTime = System.currentTimeMillis() + ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type") Timber.tag("FileOpenPerf") .d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName") @@ -4341,12 +3908,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio viewModelScope.launch { val recentItem = recentFilesRepository.getFileByBookId(bookId) - if (recentItem?.sourceFolderUri != null) { - launch(Dispatchers.IO) { - recentFilesRepository.syncLocalMetadataToFolder(bookId) - } - } - Timber.tag("FileOpenPerf") .d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") _internalState.update { @@ -4357,6 +3918,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isLoading = false ) } + ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms") persistReaderSession(bookId, type) addFileToRecent( uri, @@ -4378,11 +3940,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } 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) { viewModelScope.launch { val recentItem = recentFilesRepository.getFileByBookId(bookId) - if (recentItem?.sourceFolderUri != null) { - launch(Dispatchers.IO) { - recentFilesRepository.syncLocalMetadataToFolder(bookId) - } - } Timber.tag("FileOpenPerf") .d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") val locator = @@ -4405,6 +3962,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio initialHighlightsJson = recentItem?.highlightsJson, ) } + ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms") persistReaderSession(bookId, type) if (!suppressNavigation) { @@ -4617,87 +4175,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio "text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML "text/plain" -> { - if (fileName?.endsWith(".md", ignoreCase = true) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true) { - FileType.MD - } else if (fileName?.let { - it.endsWith(".csv", ignoreCase = true) || it.endsWith(".tsv", ignoreCase = true) || - it.endsWith(".json", ignoreCase = true) || it.endsWith(".xml", ignoreCase = true) || - it.endsWith(".log", ignoreCase = true) || it.endsWith(".java", ignoreCase = true) || - it.endsWith(".kt", ignoreCase = true) || it.endsWith(".py", ignoreCase = true) || - it.endsWith(".js", ignoreCase = true) || it.endsWith(".cpp", ignoreCase = true) || - it.endsWith(".c", ignoreCase = true) || it.endsWith(".cs", ignoreCase = true) || - it.endsWith(".rb", ignoreCase = true) || it.endsWith(".go", ignoreCase = true) - } == true) { - FileType.HTML - } else { - FileType.TXT - } + resolveFileTypeFromName(fileName) ?: FileType.TXT } else -> { - when { - fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ - fileName?.endsWith(".cbr", ignoreCase = true) == true -> FileType.CBR - fileName?.endsWith(".cb7", ignoreCase = true) == true -> FileType.CB7 - fileName?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF - fileName?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB - fileName?.endsWith( - ".mobi", - ignoreCase = true - ) == true || fileName?.endsWith( - ".azw3", - ignoreCase = true - ) == true || fileName?.endsWith( - ".prc", - ignoreCase = true - ) == true -> FileType.MOBI - - fileName?.endsWith( - ".md", - ignoreCase = true - ) == true || fileName?.endsWith( - ".markdown", - ignoreCase = true - ) == true -> FileType.MD - - fileName?.endsWith(".txt", ignoreCase = true) == true -> FileType.TXT - fileName?.endsWith( - ".fb2", - ignoreCase = true - ) == true || fileName?.endsWith( - ".fb2.zip", - ignoreCase = true - ) == true -> FileType.FB2 - fileName?.endsWith( - ".html", - ignoreCase = true - ) == true || fileName?.endsWith( - ".xhtml", - ignoreCase = true - ) == true || fileName?.endsWith( - ".htm", - ignoreCase = true - ) == true -> FileType.HTML - fileName?.endsWith(".docx", ignoreCase = true) == true -> FileType.DOCX - fileName?.endsWith(".odt", ignoreCase = true) == true -> FileType.ODT - fileName?.endsWith(".fodt", ignoreCase = true) == true -> FileType.FODT - fileName?.endsWith(".csv", ignoreCase = true) == true || - fileName?.endsWith(".tsv", ignoreCase = true) == true || - fileName?.endsWith(".json", ignoreCase = true) == true || - fileName?.endsWith(".xml", ignoreCase = true) == true || - fileName?.endsWith(".log", ignoreCase = true) == true || - fileName?.endsWith(".java", ignoreCase = true) == true || - fileName?.endsWith(".kt", ignoreCase = true) == true || - fileName?.endsWith(".py", ignoreCase = true) == true || - fileName?.endsWith(".js", ignoreCase = true) == true || - fileName?.endsWith(".cpp", ignoreCase = true) == true || - fileName?.endsWith(".c", ignoreCase = true) == true || - fileName?.endsWith(".cs", ignoreCase = true) == true || - fileName?.endsWith(".rb", ignoreCase = true) == true || - fileName?.endsWith(".go", ignoreCase = true) == true -> FileType.HTML - - else -> null - } + resolveFileTypeFromName(fileName) } } } @@ -4739,7 +4221,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } else { throw Exception( - "MobiParser returned null. The file might be DRM-protected or invalid." + if (MobiParser.isNativeParserAvailable) { + "MobiParser returned null. The file might be DRM-protected or invalid." + } else { + MobiParser.nativeParserUnavailableMessage() + } ) } } catch (e: Exception) { @@ -4945,6 +4431,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun onRecentFileClicked(item: RecentFileItem) { + ReaderPerfLog.d("FileOpen click bookId=${item.bookId} name=${item.displayName}") val currentSelection = _internalState.value.contextualActionItems if (currentSelection.isNotEmpty()) { Timber.d("Toggling selection for: ${item.displayName}") @@ -5125,6 +4612,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun setMainScreenPage(page: Int) { val sanitizedPage = page.coerceIn(0, 1) + if (_internalState.value.mainScreenStartPage == sanitizedPage) return _internalState.update { it.copy(mainScreenStartPage = sanitizedPage) } persistLibraryLandingState() } @@ -5132,6 +4620,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun setLibraryScreenPage(page: Int) { val maxLibraryPage = if (BuildConfig.IS_OFFLINE) 2 else 3 val sanitizedPage = page.coerceIn(0, maxLibraryPage) + if (_internalState.value.libraryScreenStartPage == sanitizedPage) return _internalState.update { it.copy(libraryScreenStartPage = sanitizedPage) } @@ -5701,9 +5190,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) } } - fun setAppTextDimFactor(factor: Float) { - _internalState.update { it.copy(appTextDimFactor = factor) } - prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR, factor) } + fun setAppTextDimFactorLight(factor: Float) { + _internalState.update { it.copy(appTextDimFactorLight = factor) } + prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, factor) } + } + + fun setAppTextDimFactorDark(factor: Float) { + _internalState.update { it.copy(appTextDimFactorDark = factor) } + prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, factor) } } fun setAppSeedColor(color: androidx.compose.ui.graphics.Color?) { @@ -5948,6 +5442,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option" private const val KEY_APP_SEED_COLOR = "app_seed_color" 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_CUSTOM_APP_THEMES = "custom_app_themes" val SUPPORTED_MIME_TYPES = arrayOf( diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt index 7f3be16..c30ca88 100644 --- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -2,19 +2,20 @@ package com.aryan.reader import android.content.Context +import android.provider.OpenableColumns +import android.util.Xml import androidx.core.net.toUri import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFilesRepository -import com.aryan.reader.epub.EpubParser -import com.aryan.reader.epub.ImportedFileCache -import com.aryan.reader.epub.MobiParser -import com.aryan.reader.pdf.PdfCoverGenerator import io.legere.pdfiumandroid.PdfiumCore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.xmlpull.v1.XmlPullParser import timber.log.Timber import java.io.File +import java.util.zip.ZipInputStream class MetadataExtractionWorker( private val appContext: Context, @@ -22,179 +23,271 @@ class MetadataExtractionWorker( ) : CoroutineWorker(appContext, workerParams) { private val recentFilesRepository = RecentFilesRepository(appContext) - private val epubParser = EpubParser(appContext) - private val mobiParser = MobiParser(appContext) - private val pdfCoverGenerator = PdfCoverGenerator(appContext) - private val odtParser = com.aryan.reader.epub.OdtParser(appContext) companion object { const val WORK_NAME = "MetadataExtractionWorker" + const val KEY_SOURCE_FOLDER_URI = "key_source_folder_uri" + private const val METADATA_DB_BATCH_SIZE = 100 + private const val METADATA_PROGRESS_LOG_EVERY = 250 } override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val workerStart = ReaderPerfLog.nowNanos() + val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val hasLegacy = prefs.contains("synced_folder_uri") val hasNew = prefs.contains("synced_folders_list_json") if (!hasLegacy && !hasNew) { - Timber.tag("MetadataWorker").w("No folders linked. Stopping.") + ReaderPerfLog.d("MetadataWorker skipped: no linked folders") return@withContext Result.success() } try { - val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers() + val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri) if (filesToProcess.isEmpty()) { + ReaderPerfLog.d("MetadataWorker skipped: no text metadata pending folder=${sourceFolderUri ?: "ALL"}") return@withContext Result.success() } - Timber.tag("MetadataWorker").i("Starting background metadata extraction for ${filesToProcess.size} books.") + ReaderPerfLog.i( + "MetadataWorker start mode=text-only books=${filesToProcess.size} folder=${sourceFolderUri ?: "ALL"}" + ) + + val pendingUpdates = mutableListOf() + var processed = 0 + var updated = 0 + var failed = 0 + + suspend fun flushUpdates() { + if (pendingUpdates.isEmpty()) return + val flushStart = ReaderPerfLog.nowNanos() + recentFilesRepository.updateExtractedMetadata(pendingUpdates) + ReaderPerfLog.d( + "MetadataWorker DB flush rows=${pendingUpdates.size} elapsed=${ReaderPerfLog.elapsedMs(flushStart)}ms" + ) + pendingUpdates.clear() + } filesToProcess.forEach { item -> if (isStopped) return@forEach if (item.sourceFolderUri == null) return@forEach - val tempExtractionDir = - if (item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.ODT || item.type == FileType.FODT) { - ImportedFileCache.createTemporaryBookDir(appContext, item.bookId, "metadata") - } else { - null - } - try { val uri = item.uriString?.toUri() ?: return@forEach - val type = item.type - - var coverPath: String? = null - var title: String? = null - var author: String? = null - - val fileSize = try { - if (uri.scheme == "file") { - uri.path?.let { File(it).length() } ?: 0L - } else { - appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) - if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L - } else 0L - } ?: 0L - } - } catch (e: Exception) { - Timber.tag("MetadataWorker").e(e, "Failed to get file size for ${item.displayName}") - 0L + val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri) + val metadata = when (item.type) { + FileType.EPUB -> parseEpubTextMetadata(uri) + FileType.PDF -> parsePdfTextMetadata(uri) + FileType.ODT -> parseZipTextMetadata(uri, "meta.xml") + FileType.FODT -> parseFlatXmlTextMetadata(uri) + FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml") + else -> TextMetadata() } - appContext.contentResolver.openInputStream(uri)?.use { inputStream -> - when (type) { - FileType.EPUB -> { - val book = epubParser.createEpubBook( - inputStream = inputStream, - bookId = item.bookId, - originalBookNameHint = item.displayName, - parseContent = false, - extractionDirOverride = tempExtractionDir - ) - title = book.title.takeIf { it.isNotBlank() && it != "content" } - author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } - book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) } - } - FileType.MOBI -> { - val book = mobiParser.createMobiBook( - inputStream = inputStream, - bookId = item.bookId, - originalBookNameHint = item.displayName, - parseContent = false, - extractionDirOverride = tempExtractionDir - ) - book?.let { - title = it.title.takeIf { t -> t.isNotBlank() && t != "content" } - author = it.author.takeIf { a -> a.isNotBlank() && !a.equals("Unknown", ignoreCase = true) } - it.coverImage?.let { img -> coverPath = recentFilesRepository.saveCoverToCache(img, uri) } - } - } - FileType.PDF -> { - pdfCoverGenerator.generateCover(uri)?.let { - coverPath = recentFilesRepository.saveCoverToCache(it, uri) - } - title = item.displayName + val title = sanitizeTitle(metadata.title) + val author = sanitizeAuthor(metadata.author) + val sizeChanged = fileSize > 0L && fileSize != item.fileSize + val titleChanged = title != null && title != item.title + val authorChanged = author != null && author != item.author - try { - val pdfiumCore = PdfiumCore(appContext) - appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> - val pdfDocument = pdfiumCore.newDocument(pfd) - val meta = pdfiumCore.getDocumentMeta(pdfDocument) - - val extractedTitle = meta.title - if (!extractedTitle.isNullOrBlank()) { - title = extractedTitle - } - - val extractedAuthor = meta.author - if (!extractedAuthor.isNullOrBlank()) { - author = extractedAuthor - } - - pdfiumCore.closeDocument(pdfDocument) - } - } catch (e: Exception) { - Timber.tag("MetadataWorker").e(e, "Failed to extract PDF metadata using PdfiumCore") - } - } - FileType.ODT, FileType.FODT -> { - val book = odtParser.createOdtBook( - inputStream = inputStream, - bookId = item.bookId, - originalBookNameHint = item.displayName, - isFlat = type == FileType.FODT, - parseContent = false, - extractionDirOverride = tempExtractionDir - ) - title = book.title.takeIf { it.isNotBlank() && it != "content" } - author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } - book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) } - } - else -> { - title = item.displayName - } - } - } - - if (coverPath != null || title != null || author != null || fileSize > 0L) { - val updatedItem = item.copy( - coverImagePath = coverPath ?: item.coverImagePath, - title = title ?: item.title ?: item.displayName, - author = author ?: item.author, - fileSize = if (fileSize > 0L) fileSize else item.fileSize + if (!item.folderTextMetadataParsed || sizeChanged || titleChanged || authorChanged) { + pendingUpdates.add( + item.copy( + title = title ?: item.title ?: item.displayName, + author = author ?: item.author, + fileSize = if (fileSize > 0L) fileSize else item.fileSize, + folderTextMetadataParsed = true + ) ) - recentFilesRepository.addRecentFile(updatedItem) - Timber.tag("MetadataWorker").d("Updated local metadata/size for: ${item.displayName} ($fileSize bytes)") + if (sizeChanged || titleChanged || authorChanged) { + updated++ + } + if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) { + flushUpdates() + } } - } catch (e: Exception) { - Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}") - } finally { - try { - if (tempExtractionDir?.exists() == true) { - val deleted = tempExtractionDir.deleteRecursively() - if (deleted) { - Timber.tag("MetadataWorker") - .d("Cleaned up temporary extraction cache for ${item.bookId}") - } - } - } catch (e: Exception) { - Timber.tag("MetadataWorker") - .e(e, "Failed to clean up temporary extraction cache for ${item.bookId}") + processed++ + if (processed % METADATA_PROGRESS_LOG_EVERY == 0) { + ReaderPerfLog.d( + "MetadataWorker progress mode=text-only processed=$processed updated=$updated failed=$failed" + ) } + } catch (e: Exception) { + failed++ + Timber.tag("MetadataWorker").e(e, "Failed text metadata extraction for ${item.displayName}") } } + flushUpdates() + + ReaderPerfLog.i( + "MetadataWorker finished mode=text-only processed=$processed updated=$updated failed=$failed " + + "elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}" + ) + return@withContext Result.success() } catch (e: Exception) { - Timber.tag("MetadataWorker").e(e, "Metadata extraction failed") + Timber.tag("MetadataWorker").e(e, "Text metadata extraction failed") return@withContext Result.failure() } } + + private fun queryFileSize(uri: android.net.Uri): Long { + return try { + if (uri.scheme == "file") { + uri.path?.let { File(it).length() } ?: 0L + } else { + appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else 0L + } else { + 0L + } + } ?: 0L + } + } catch (e: Exception) { + Timber.tag("MetadataWorker").e(e, "Failed to query file size for $uri") + 0L + } + } + + private fun parseEpubTextMetadata(uri: android.net.Uri): TextMetadata { + val opfEntries = linkedMapOf() + var containerXml: String? = null + + appContext.contentResolver.openInputStream(uri)?.use { input -> + ZipInputStream(input.buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + if (entry.isDirectory) continue + val name = entry.name + when { + name == "META-INF/container.xml" -> containerXml = zip.readTextEntry() + name.endsWith(".opf", ignoreCase = true) -> opfEntries[name] = zip.readTextEntry() + } + zip.closeEntry() + } + } + } + + val opfPath = containerXml?.let { parseEpubRootfilePath(it) } + val opfXml = opfPath?.let { opfEntries[it] } ?: opfEntries.values.firstOrNull() + return opfXml?.let { parseXmlTextMetadata(it) } ?: TextMetadata() + } + + private fun parseZipTextMetadata(uri: android.net.Uri, targetEntryName: String): TextMetadata { + appContext.contentResolver.openInputStream(uri)?.use { input -> + ZipInputStream(input.buffered()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + if (!entry.isDirectory && entry.name == targetEntryName) { + val xml = zip.readTextEntry() + return parseXmlTextMetadata(xml) + } + zip.closeEntry() + } + } + } + return TextMetadata() + } + + private fun parseFlatXmlTextMetadata(uri: android.net.Uri): TextMetadata { + val xml = appContext.contentResolver.openInputStream(uri)?.use { input -> + input.bufferedReader(Charsets.UTF_8).use { it.readText() } + } ?: return TextMetadata() + return parseXmlTextMetadata(xml) + } + + private 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) + } + } ?: TextMetadata() + } catch (e: Exception) { + Timber.tag("MetadataWorker").e(e, "Failed to extract PDF text metadata") + TextMetadata() + } + } + + private fun parseEpubRootfilePath(containerXml: String): String? { + val parser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) + parser.setInput(containerXml.reader()) + + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name.equals("rootfile", ignoreCase = true)) { + return parser.getAttributeValue(null, "full-path")?.takeIf { it.isNotBlank() } + } + event = parser.next() + } + return null + } + + private fun parseXmlTextMetadata(xml: String): TextMetadata { + val parser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) + parser.setInput(xml.reader()) + + var title: String? = null + var author: String? = null + var event = parser.eventType + + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG) { + val name = parser.name.substringAfter(':').lowercase() + when { + title == null && name == "title" -> title = parser.nextTextOrNull() + author == null && (name == "creator" || name == "initial-creator") -> { + author = parser.nextTextOrNull() + } + } + } + event = parser.next() + } + + return TextMetadata(title = title, author = author) + } + + private fun XmlPullParser.nextTextOrNull(): String? { + return try { + nextText()?.trim()?.takeIf { it.isNotBlank() } + } catch (_: Exception) { + null + } + } + + private fun ZipInputStream.readTextEntry(): String { + return String(readBytes(), Charsets.UTF_8) + } + + private fun sanitizeTitle(value: String?): String? { + return value + ?.trim() + ?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) } + } + + private fun sanitizeAuthor(value: String?): String? { + return value + ?.trim() + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + } + + private data class TextMetadata( + val title: String? = null, + val author: String? = null + ) } diff --git a/app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt b/app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt new file mode 100644 index 0000000..116870e --- /dev/null +++ b/app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt @@ -0,0 +1,54 @@ +package com.aryan.reader + +import com.aryan.reader.data.RecentFileItem + +data class HomeScreenModel( + val recentFiles: List, + val openTabs: List, + val selectedItems: Set, + val isContextualModeActive: Boolean, + val deviceLimitState: DeviceLimitReachedState, + val isEmpty: Boolean, + val isLibraryEmpty: Boolean +) + +fun ReaderScreenState.toHomeScreenModel(): HomeScreenModel { + val homeRecentFiles = recentFiles + return HomeScreenModel( + recentFiles = homeRecentFiles, + openTabs = openTabs, + selectedItems = contextualActionItems, + isContextualModeActive = contextualActionItems.isNotEmpty(), + deviceLimitState = deviceLimitState, + isEmpty = homeRecentFiles.isEmpty() && (!isTabsEnabled || openTabs.isEmpty()), + isLibraryEmpty = recentFiles.isEmpty() + ) +} + +data class LibraryScreenModel( + val selectedItems: Set, + val isContextualModeActive: Boolean, + val selectedShelves: Set, + val isShelfContextualModeActive: Boolean, + val sortOrder: SortOrder, + val shelves: List, + val rawLibraryFiles: List, + val containsFolderItemsInSelection: Boolean, + val isSearchActive: Boolean, + val searchQuery: String +) + +fun ReaderScreenState.toLibraryScreenModel(): LibraryScreenModel { + return LibraryScreenModel( + selectedItems = contextualActionItems, + isContextualModeActive = contextualActionItems.isNotEmpty(), + selectedShelves = contextualActionShelfIds, + isShelfContextualModeActive = contextualActionShelfIds.isNotEmpty(), + sortOrder = sortOrder, + shelves = shelves, + rawLibraryFiles = rawLibraryFiles, + containsFolderItemsInSelection = contextualActionItems.any { it.sourceFolderUri != null }, + isSearchActive = isSearchActive, + searchQuery = searchQuery + ) +} diff --git a/app/src/main/java/com/aryan/reader/ReaderPerfLog.kt b/app/src/main/java/com/aryan/reader/ReaderPerfLog.kt new file mode 100644 index 0000000..da2db2f --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ReaderPerfLog.kt @@ -0,0 +1,59 @@ +package com.aryan.reader + +import timber.log.Timber + +object ReaderPerfLog { + const val TAG = "ReaderPerf" + + 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) + } + + inline fun measure( + name: String, + minLogMs: Long = 16L, + details: () -> String = { "" }, + block: () -> T + ): T { + val start = nowNanos() + try { + return block() + } finally { + val elapsed = elapsedMs(start) + if (elapsed >= minLogMs) { + val extra = details().takeIf { it.isNotBlank() }?.let { " $it" }.orEmpty() + d("$name took ${elapsed}ms$extra") + } + } + } + + suspend inline fun measureSuspend( + name: String, + minLogMs: Long = 16L, + details: () -> String = { "" }, + crossinline block: suspend () -> T + ): T { + val start = nowNanos() + try { + return block() + } finally { + val elapsed = elapsedMs(start) + if (elapsed >= minLogMs) { + val extra = details().takeIf { it.isNotBlank() }?.let { " $it" }.orEmpty() + d("$name took ${elapsed}ms$extra") + } + } + } +} diff --git a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt new file mode 100644 index 0000000..c397738 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt @@ -0,0 +1,220 @@ +package com.aryan.reader + +import com.aryan.reader.data.BookShelfCrossRef +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.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.SharedLibraryProjectionInput +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.ShelfRecord +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 RecentFileItem.toSharedBookItem(): SharedBookItem { + return SharedBookItem( + id = bookId, + path = uriString, + type = type.toSharedFileType(), + displayName = customName ?: displayName, + timestamp = timestamp, + title = title, + author = author, + progressPercentage = progressPercentage, + isRecent = isRecent, + fileSize = fileSize, + sourceFolder = sourceFolderUri, + seriesName = seriesName, + seriesIndex = seriesIndex, + tags = tags.map { it.toSharedTag() } + ) +} + +fun TagEntity.toSharedTag(): SharedTag { + return SharedTag( + id = id, + name = name, + color = color + ) +} + +fun ShelfEntity.toSharedShelfRecord(): ShelfRecord { + return ShelfRecord( + id = id, + name = name, + isSmart = isSmart, + smartRulesJson = smartRulesJson + ) +} + +fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef { + return SharedBookShelfRef( + bookId = bookId, + shelfId = shelfId, + addedAt = addedAt + ) +} + +fun ReaderScreenState.toSharedReaderScreenState( + rawBooks: List = rawLibraryFiles, + dbTags: List = allTags +): SharedReaderScreenState { + return SharedReaderScreenState( + selectedBookId = selectedBookId, + selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(), + selectedFileType = selectedFileType?.toSharedFileType(), + isLoading = isLoading, + errorMessage = errorMessage, + renderMode = renderMode.toSharedRenderMode(), + sortOrder = sortOrder.toSharedSortOrder(), + viewingShelfId = viewingShelfId, + isAddingBooksToShelf = isAddingBooksToShelf, + showCreateShelfDialog = showCreateShelfDialog, + mainScreenStartPage = mainScreenStartPage, + libraryScreenStartPage = libraryScreenStartPage, + showRenameShelfDialogFor = showRenameShelfDialogFor, + showDeleteShelfDialogFor = showDeleteShelfDialogFor, + addBooksSource = addBooksSource.toSharedAddBooksSource(), + booksSelectedForAdding = booksSelectedForAdding, + selectedBookIds = contextualActionItems.mapTo(mutableSetOf()) { it.bookId }, + selectedShelfIds = contextualActionShelfIds, + isProUser = isProUser, + credits = credits, + isSyncEnabled = isSyncEnabled, + isFolderSyncEnabled = isFolderSyncEnabled, + bannerMessage = bannerMessage?.toSharedBannerMessage(), + downloadingBookIds = downloadingBookIds, + uploadingBookIds = uploadingBookIds, + syncedFolders = syncedFolders.map { it.toSharedSyncedFolder() }, + lastFolderScanTime = lastFolderScanTime, + hasUnreadFeedback = hasUnreadFeedback, + searchQuery = searchQuery, + isSearchActive = isSearchActive, + isRefreshing = isRefreshing, + reflowProgress = reflowProgress, + recentBooks = recentFiles.map { it.toSharedBookItem() }, + libraryBooks = allRecentFiles.map { it.toSharedBookItem() }, + rawLibraryBooks = rawBooks.map { it.toSharedBookItem() }, + pinnedHomeBookIds = pinnedHomeBookIds, + pinnedLibraryBookIds = pinnedLibraryBookIds, + libraryFilters = libraryFilters.toSharedLibraryFilters(), + recentFilesLimit = recentFilesLimit, + isTabsEnabled = isTabsEnabled, + openTabIds = openTabIds, + openTabs = openTabs.map { it.toSharedBookItem() }, + activeTabBookId = activeTabBookId, + showExternalFileSavePromptFor = showExternalFileSavePromptFor, + externalFileBehavior = externalFileBehavior, + useStrictFileFilter = useStrictFileFilter, + appThemeMode = appThemeMode.toSharedAppThemeMode(), + appContrastOption = appContrastOption.toSharedAppContrastOption(), + appTextDimFactorLight = appTextDimFactorLight, + appTextDimFactorDark = appTextDimFactorDark, + appSeedColor = appSeedColor, + customAppThemes = customAppThemes.map { it.toSharedCustomAppTheme() }, + allTags = dbTags.map { it.toSharedTag() }, + showTagSelectionDialogFor = showTagSelectionDialogFor + ) +} + +fun ReaderScreenState.toSharedLibraryProjectionInput( + recentFilesFromDb: List, + dbShelves: List, + shelfRefs: List, + dbTags: List, + tagRefs: List +): SharedLibraryProjectionInput { + val tagsById = dbTags.associateBy { it.id } + val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry -> + entry.value.mapNotNull { tagsById[it.tagId] } + } + val taggedBooks = recentFilesFromDb.map { item -> + item.copy(tags = bookTagsMap[item.bookId].orEmpty()) + } + return SharedLibraryProjectionInput( + state = toSharedReaderScreenState( + rawBooks = taggedBooks, + dbTags = dbTags + ), + booksFromStore = taggedBooks + .filterNot { it.bookId.endsWith("_reflow") } + .map { it.toSharedBookItem() }, + shelfRecords = dbShelves.map { it.toSharedShelfRecord() }, + shelfRefs = shelfRefs.map { it.toSharedBookShelfRef() }, + tags = dbTags.map { it.toSharedTag() } + ) +} + +fun FileType.toSharedFileType(): SharedFileType { + return runCatching { SharedFileType.valueOf(name) }.getOrDefault(SharedFileType.UNKNOWN) +} + +private fun RenderMode.toSharedRenderMode(): SharedRenderMode { + return SharedRenderMode.valueOf(name) +} + +private fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource { + return SharedAddBooksSource.valueOf(name) +} + +private fun SortOrder.toSharedSortOrder(): SharedSortOrder { + return SharedSortOrder.valueOf(name) +} + +private fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter { + return SharedReadStatusFilter.valueOf(name) +} + +private fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters { + return SharedLibraryFilters( + fileTypes = fileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() }, + sourceFolders = sourceFolders, + readStatus = readStatus.toSharedReadStatusFilter(), + tagIds = tagIds + ) +} + +private fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder { + return SharedSyncedFolder( + uriString = uriString, + name = name, + lastScanTime = lastScanTime, + allowedFileTypes = allowedFileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() } + ) +} + +private fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage { + return SharedBannerMessage( + message = message, + isError = isError, + isPersistent = isPersistent + ) +} + +private fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode { + return SharedAppThemeMode.valueOf(name) +} + +private fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption { + return SharedAppContrastOption.valueOf(name) +} + +private fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme { + return SharedCustomAppTheme( + id = id, + name = name, + seedColor = seedColor + ) +} diff --git a/app/src/main/java/com/aryan/reader/UiLabelResources.kt b/app/src/main/java/com/aryan/reader/UiLabelResources.kt new file mode 100644 index 0000000..ccbb0ff --- /dev/null +++ b/app/src/main/java/com/aryan/reader/UiLabelResources.kt @@ -0,0 +1,42 @@ +package com.aryan.reader + +import androidx.annotation.StringRes + +val AddBooksSource.labelRes: Int + @StringRes get() = when (this) { + AddBooksSource.UNSHELVED -> R.string.add_books_source_unshelved + AddBooksSource.ALL_BOOKS -> R.string.add_books_source_all_books + } + +val AppThemeMode.labelRes: Int + @StringRes get() = when (this) { + AppThemeMode.SYSTEM -> R.string.app_theme_mode_system + AppThemeMode.LIGHT -> R.string.app_theme_mode_light + AppThemeMode.DARK -> R.string.app_theme_mode_dark + } + +val AppContrastOption.labelRes: Int + @StringRes get() = when (this) { + AppContrastOption.STANDARD -> R.string.app_contrast_standard + AppContrastOption.MEDIUM -> R.string.app_contrast_medium + AppContrastOption.HIGH -> R.string.app_contrast_high + } + +val SortOrder.labelRes: Int + @StringRes get() = when (this) { + SortOrder.RECENT -> R.string.sort_recent + SortOrder.TITLE_ASC -> R.string.sort_title_az + SortOrder.AUTHOR_ASC -> R.string.sort_author_az + SortOrder.PERCENT_ASC -> R.string.sort_percent_asc + SortOrder.PERCENT_DESC -> R.string.sort_percent_desc + SortOrder.SIZE_ASC -> R.string.sort_size_smallest + SortOrder.SIZE_DESC -> R.string.sort_size_biggest + } + +val ReadStatusFilter.labelRes: Int + @StringRes get() = when (this) { + ReadStatusFilter.ALL -> R.string.read_status_all + ReadStatusFilter.UNREAD -> R.string.read_status_unread + ReadStatusFilter.IN_PROGRESS -> R.string.read_status_in_progress + ReadStatusFilter.COMPLETED -> R.string.read_status_completed + } diff --git a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt index 50794f2..3f608b9 100644 --- a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt +++ b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt @@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase TagEntity::class, BookTagCrossRef::class ], - version = 18, + version = 19, exportSchema = false ) @TypeConverters(FileTypeConverter::class) @@ -251,6 +251,12 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_18_19 = object : Migration(18, 19) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE recent_files ADD COLUMN folderTextMetadataParsed INTEGER NOT NULL DEFAULT 0") + } + } + fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( @@ -263,7 +269,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, - MIGRATION_16_17, MIGRATION_17_18 + MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19 ) .fallbackToDestructiveMigration(false) .build() @@ -272,4 +278,4 @@ abstract class AppDatabase : RoomDatabase() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt index dc809cf..559a902 100644 --- a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt +++ b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.os.Environment import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile +import com.aryan.reader.ReaderPerfLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -16,6 +17,116 @@ object LocalSyncUtils { private const val ANNOTATION_SUFFIX = "_annotations" private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData" + private data class SyncFileEntry( + val name: String, + val uri: Uri + ) + + private fun syncSubfolderDocId(rootDocId: String): String { + return if (rootDocId.endsWith("/$SYNC_SUBFOLDER_NAME")) { + rootDocId + } else if (rootDocId.endsWith(":")) { + rootDocId + SYNC_SUBFOLDER_NAME + } else { + "$rootDocId/$SYNC_SUBFOLDER_NAME" + } + } + + private fun querySyncSubfolderFiles(context: Context, sourceFolderUri: Uri): List { + val start = ReaderPerfLog.nowNanos() + val resolver = context.contentResolver + val rootDocId = try { + DocumentsContract.getTreeDocumentId(sourceFolderUri) + } catch (_: Exception) { + ReaderPerfLog.w("LocalSync direct query skipped: invalid tree uri=$sourceFolderUri") + return emptyList() + } + val syncDocId = syncSubfolderDocId(rootDocId) + val syncDirUri = DocumentsContract.buildDocumentUriUsingTree(sourceFolderUri, syncDocId) + val documentProjection = arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE) + val isSyncDir = try { + resolver.query(syncDirUri, documentProjection, null, null, null)?.use { cursor -> + cursor.moveToFirst() && + cursor.getString(0) == DocumentsContract.Document.MIME_TYPE_DIR + } == true + } catch (_: Exception) { + false + } + + if (!isSyncDir) { + ReaderPerfLog.d( + "LocalSync direct query sync dir missing rootDocId=$rootDocId syncDocId=$syncDocId" + ) + return querySyncSubfolderFilesFallback(context, sourceFolderUri, "missing-direct-sync-dir") + } + + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(sourceFolderUri, syncDocId) + val projection = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + val entries = mutableListOf() + try { + resolver.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) + while (cursor.moveToNext()) { + val mimeType = cursor.getString(mimeCol) + if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) continue + val name = cursor.getString(nameCol) ?: continue + val docId = cursor.getString(idCol) ?: continue + entries.add( + SyncFileEntry( + name = name, + uri = DocumentsContract.buildDocumentUriUsingTree(sourceFolderUri, docId) + ) + ) + } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to query sync subfolder directly") + return querySyncSubfolderFilesFallback(context, sourceFolderUri, "direct-query-error") + } + ReaderPerfLog.d( + "LocalSync direct query files=${entries.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms syncDocId=$syncDocId" + ) + return entries + } + + private fun querySyncSubfolderFilesFallback( + context: Context, + sourceFolderUri: Uri, + reason: String + ): List { + val start = ReaderPerfLog.nowNanos() + return try { + val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) + val syncDir = rootTree?.findFile(SYNC_SUBFOLDER_NAME) + if (syncDir == null || !syncDir.isDirectory) { + ReaderPerfLog.w("LocalSync fallback query found no sync dir reason=$reason uri=$sourceFolderUri") + emptyList() + } else { + val entries = syncDir.listFiles() + .asSequence() + .filter { it.isFile } + .mapNotNull { file -> + val name = file.name ?: return@mapNotNull null + SyncFileEntry(name = name, uri = file.uri) + } + .toList() + ReaderPerfLog.d( + "LocalSync fallback query files=${entries.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms reason=$reason" + ) + entries + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to query sync subfolder fallback") + emptyList() + } + } + private fun getOrCreateSyncDir(rootTree: DocumentFile): DocumentFile? { val existing = rootTree.findFile(SYNC_SUBFOLDER_NAME) if (existing != null && existing.isDirectory) return existing @@ -209,22 +320,22 @@ object LocalSyncUtils { suspend fun preloadAnnotationSidecars( context: Context, - rootTree: DocumentFile + sourceFolderUri: Uri ): Map> = withContext(Dispatchers.IO) { val results = mutableMapOf>() try { - val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) - if (syncDir == null || !syncDir.isDirectory) return@withContext results - val bookIds = syncDir.listFiles() - .mapNotNull { extractAnnotationBookId(it.name) } - .toSet() - - for (bookId in bookIds) { - val best = resolveAndCleanAnnotationConflicts(context, syncDir, bookId) - if (best != null) { - results[bookId] = best + val groupedFiles = querySyncSubfolderFiles(context, sourceFolderUri) + .filter { file -> + val name = file.name + extractAnnotationBookId(name) != null && + !name.contains(".syncthing.") } + .groupBy { file -> extractAnnotationBookId(file.name).orEmpty() } + + for ((bookId, files) in groupedFiles) { + val best = resolveAnnotationConflictsReadOnly(context, bookId, files) + if (best != null) results[bookId] = best } } catch (e: Exception) { Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars") @@ -233,6 +344,44 @@ object LocalSyncUtils { return@withContext results } + private fun resolveAnnotationConflictsReadOnly( + context: Context, + bookId: String, + files: List + ): Pair? { + val basePattern = ".${bookId}${ANNOTATION_SUFFIX}" + val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}" + var bestTs = -1L + var bestData: String? = null + + for (file in files) { + val name = file.name + if (!((name.startsWith(basePattern) || name.startsWith(legacyPattern)) && + name.endsWith(".json") && + !name.endsWith(".tmp") && + !name.contains(".syncthing.")) + ) { + continue + } + try { + val content = context.contentResolver.openInputStream(file.uri)?.use { + it.bufferedReader().readText() + } ?: continue + val json = JSONObject(content) + val ts = json.optLong("timestamp", 0L) + val data = json.optJSONObject("data")?.toString() + if (data != null && ts > bestTs) { + bestTs = ts + bestData = data + } + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").e(e, "Error parsing annotation sidecar: $name") + } + } + + return bestData?.let { bestTs to it } + } + suspend fun getAnnotationSidecar( context: Context, sourceFolderUri: Uri, @@ -253,12 +402,13 @@ object LocalSyncUtils { private fun resolveAndCleanAnnotationConflicts( context: Context, syncDir: DocumentFile, - bookId: String + bookId: String, + knownFiles: List? = null ): Pair? { val basePattern = ".${bookId}${ANNOTATION_SUFFIX}" val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}" - val allFiles = syncDir.listFiles() + val allFiles = knownFiles ?: syncDir.listFiles().asList() val candidates = allFiles.filter { file -> val name = file.name ?: "" @@ -475,21 +625,17 @@ object LocalSyncUtils { val finalResults = mutableMapOf() try { - val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults - val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) - if (syncDir == null || !syncDir.isDirectory) return@withContext finalResults - val allFiles = syncDir.listFiles() - + val allFiles = querySyncSubfolderFiles(context, sourceFolderUri) val groupedFiles = allFiles .filter { - val name = it.name ?: "" + val name = it.name (name.endsWith(".json") || name.contains(".sync-conflict")) && !name.contains(ANNOTATION_SUFFIX) && !name.endsWith(".tmp") && !name.contains(".syncthing.") } .groupBy { file -> - var name = file.name ?: "" + var name = file.name if (name.startsWith(".")) name = name.substring(1) if (name.contains(".sync-conflict")) { name.substringBefore(".sync-conflict") @@ -499,17 +645,47 @@ object LocalSyncUtils { } groupedFiles.forEach { (bookId, files) -> - val winner = resolveAndCleanConflicts(context, files, bookId) + val winner = resolveMetadataConflictsReadOnly(context, files, bookId) if (winner != null) { finalResults[bookId] = winner } } - Timber.tag(TAG).d("getAllFolderMetadata: Consolidated ${groupedFiles.size} book records from root.") + Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedFiles.size} book records from sync data.") + ReaderPerfLog.d( + "LocalSync metadata read files=${allFiles.size} groups=${groupedFiles.size} records=${finalResults.size}" + ) } catch (e: Exception) { - Timber.tag(TAG).e(e, "Error scanning root folder for metadata") + Timber.tag(TAG).e(e, "Error scanning sync data folder for metadata") + ReaderPerfLog.w("LocalSync metadata read failed uri=$sourceFolderUri") } return@withContext finalResults } + + private fun resolveMetadataConflictsReadOnly( + context: Context, + files: List, + bookId: String + ): FolderBookMetadata? { + var bestMeta: FolderBookMetadata? = null + for (file in files) { + try { + val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input -> + input.bufferedReader().use { it.readText() } + } + if (jsonString != null) { + val meta = FolderBookMetadata.fromJsonString(jsonString) + if (meta.bookId == bookId && + (bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp) + ) { + bestMeta = meta + } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}") + } + } + return bestMeta + } } diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt index 4af1a91..1db6c5a 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt @@ -90,8 +90,59 @@ interface RecentFileDao { @Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)") suspend fun markAsNotRecent(bookIds: List, timestamp: Long) - @Query("SELECT * FROM recent_files WHERE sourceFolderUri IS NOT NULL AND coverImagePath IS NULL AND isDeleted = 0") - suspend fun getFolderBooksWithoutCovers(): List + @Query(""" + SELECT * FROM recent_files + WHERE sourceFolderUri IS NOT NULL + AND isDeleted = 0 + AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') + AND folderTextMetadataParsed = 0 + """) + suspend fun getFolderBooksNeedingTextMetadata(): List + + @Query(""" + SELECT * FROM recent_files + WHERE sourceFolderUri = :sourceFolderUri + AND isDeleted = 0 + AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') + AND folderTextMetadataParsed = 0 + """) + suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String): List + + @Query(""" + SELECT COUNT(*) FROM recent_files + WHERE sourceFolderUri IS NOT NULL + AND isDeleted = 0 + AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') + AND folderTextMetadataParsed = 0 + """) + suspend fun countFolderBooksNeedingTextMetadata(): Int + + @Query(""" + SELECT COUNT(*) FROM recent_files + WHERE sourceFolderUri = :sourceFolderUri + AND isDeleted = 0 + AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX') + AND folderTextMetadataParsed = 0 + """) + suspend fun countFolderBooksNeedingTextMetadata(sourceFolderUri: String): Int + + @Query(""" + UPDATE recent_files + SET + coverImagePath = COALESCE(:coverImagePath, coverImagePath), + title = COALESCE(:title, title), + author = COALESCE(:author, author), + fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END, + folderTextMetadataParsed = 1 + WHERE bookId = :bookId + """) + suspend fun updateExtractedMetadata( + bookId: String, + coverImagePath: String?, + title: String?, + author: String?, + fileSize: Long + ) @Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL") suspend fun detachAllFolderBooks() diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt index 4269ebe..4dbbb0b 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt @@ -54,7 +54,8 @@ data class RecentFileEntity( @ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long, @ColumnInfo(defaultValue = "NULL") val seriesName: String?, @ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?, - @ColumnInfo(defaultValue = "NULL") val description: String? + @ColumnInfo(defaultValue = "NULL") val description: String?, + @ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean ) data class RecentFileSummary( @@ -83,4 +84,4 @@ data class RecentFileSummary( @ColumnInfo(defaultValue = "NULL") val seriesName: String?, @ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?, @ColumnInfo(defaultValue = "NULL") val description: String? -) \ No newline at end of file +) diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt index bba7345..4eb094a 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt @@ -19,9 +19,7 @@ */ package com.aryan.reader.data -import android.net.Uri import com.aryan.reader.FileType -import androidx.core.net.toUri data class RecentFileItem( val bookId: String, @@ -51,10 +49,9 @@ data class RecentFileItem( val seriesName: String? = null, val seriesIndex: Double? = null, val description: String? = null, + val folderTextMetadataParsed: Boolean = false, val tags: List = emptyList() -) { - fun getUri(): Uri? = uriString?.toUri() -} +) fun RecentFileEntity.toRecentFileItem(): RecentFileItem { return RecentFileItem( @@ -84,7 +81,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem { fileSize = this.fileSize, seriesName = this.seriesName, seriesIndex = this.seriesIndex, - description = this.description + description = this.description, + folderTextMetadataParsed = this.folderTextMetadataParsed ) } @@ -116,7 +114,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity { fileSize = this.fileSize, seriesName = this.seriesName, seriesIndex = this.seriesIndex, - description = this.description + description = this.description, + folderTextMetadataParsed = this.folderTextMetadataParsed ) } @@ -199,4 +198,4 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem { seriesIndex = this.seriesIndex, description = this.description ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileItemAndroid.kt b/app/src/main/java/com/aryan/reader/data/RecentFileItemAndroid.kt new file mode 100644 index 0000000..76d5aae --- /dev/null +++ b/app/src/main/java/com/aryan/reader/data/RecentFileItemAndroid.kt @@ -0,0 +1,6 @@ +package com.aryan.reader.data + +import android.net.Uri +import androidx.core.net.toUri + +fun RecentFileItem.getUri(): Uri? = uriString?.toUri() diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index 7bfd37f..3cca485 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -24,6 +24,7 @@ import android.content.Context import android.graphics.Bitmap import android.net.Uri import androidx.core.net.toUri +import com.aryan.reader.ReaderPerfLog import timber.log.Timber import com.aryan.reader.BookImporter import com.aryan.reader.paginatedreader.Locator @@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import androidx.room.withTransaction import java.io.File import java.io.FileOutputStream import com.aryan.reader.pdf.data.PdfAnnotationRepository @@ -47,7 +49,8 @@ private const val COVER_CACHE_DIR = "cover_cache" class RecentFilesRepository(private val context: Context) { - private val recentFileDao = AppDatabase.getDatabase(context).recentFileDao() + private val database = AppDatabase.getDatabase(context) + private val recentFileDao = database.recentFileDao() private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR) private val bookImporter = BookImporter(context) @@ -57,10 +60,10 @@ class RecentFilesRepository(private val context: Context) { private val pdfTextBoxRepository = PdfTextBoxRepository(context) private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context) - val activeShelvesFlow = AppDatabase.getDatabase(context).shelfDao().getAllActiveShelves() - val shelfCrossRefsFlow = AppDatabase.getDatabase(context).shelfDao().getAllBookShelfCrossRefs() - val tagsFlow = AppDatabase.getDatabase(context).tagDao().getAllTags() - val tagCrossRefsFlow = AppDatabase.getDatabase(context).tagDao().getAllBookTagCrossRefs() + val activeShelvesFlow = database.shelfDao().getAllActiveShelves() + val shelfCrossRefsFlow = database.shelfDao().getAllBookShelfCrossRefs() + val tagsFlow = database.tagDao().getAllTags() + val tagCrossRefsFlow = database.tagDao().getAllBookTagCrossRefs() init { if (!coverCacheDir.exists()) { @@ -184,7 +187,8 @@ class RecentFilesRepository(private val context: Context) { fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize, seriesName = item.seriesName ?: existingItem.seriesName, seriesIndex = item.seriesIndex ?: existingItem.seriesIndex, - description = item.description ?: existingItem.description + description = item.description ?: existingItem.description, + folderTextMetadataParsed = item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed ) } else { item.toRecentFileEntity() @@ -355,12 +359,25 @@ class RecentFilesRepository(private val context: Context) { } suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) { - val filesToRemove = getFilesBySourceFolder(folderUriString) - if (filesToRemove.isNotEmpty()) { - Timber.d("DeleteDebug: Cascading deletion for ${filesToRemove.size} files from folder.") - deleteFilePermanently(filesToRemove.map { it.bookId }) - } else { - recentFileDao.deleteFilesBySourceFolder(folderUriString) + val start = ReaderPerfLog.nowNanos() + val filesToRemove = recentFileDao.getFilesBySourceFolder(folderUriString) + recentFileDao.deleteFilesBySourceFolder(folderUriString) + ReaderPerfLog.i( + "FolderRemove db delete books=${filesToRemove.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms folder=$folderUriString" + ) + + filesToRemove.forEach { item -> + item.coverImagePath?.let { deleteCachedCover(it) } + try { + pdfAnnotationRepository.getAnnotationFileForSync(item.bookId)?.delete() + pdfRichTextRepository.getFileForSync(item.bookId).delete() + pageLayoutRepository.getLayoutFile(item.bookId).delete() + pdfTextBoxRepository.getFileForSync(item.bookId).delete() + pdfHighlightRepository.getFileForSync(item.bookId).delete() + ImportedFileCache.clearBookCache(context, item.bookId) + } catch (e: Exception) { + Timber.e(e, "Error during local cleanup for detached folder book ${item.bookId}") + } } } @@ -381,8 +398,40 @@ class RecentFilesRepository(private val context: Context) { } } - suspend fun getFolderBooksWithoutCovers(): List = withContext(Dispatchers.IO) { - return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() } + suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): List = withContext(Dispatchers.IO) { + val entities = if (sourceFolderUri.isNullOrBlank()) { + recentFileDao.getFolderBooksNeedingTextMetadata() + } else { + recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri) + } + return@withContext entities.map { it.toRecentFileItem() } + } + + suspend fun hasFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): Boolean = withContext(Dispatchers.IO) { + val count = if (sourceFolderUri.isNullOrBlank()) { + recentFileDao.countFolderBooksNeedingTextMetadata() + } else { + recentFileDao.countFolderBooksNeedingTextMetadata(sourceFolderUri) + } + return@withContext count > 0 + } + + suspend fun updateExtractedMetadata(items: List) = withContext(Dispatchers.IO) { + if (items.isEmpty()) return@withContext + items.chunked(300).forEach { chunk -> + database.withTransaction { + chunk.forEach { item -> + recentFileDao.updateExtractedMetadata( + bookId = item.bookId, + coverImagePath = item.coverImagePath, + title = item.title, + author = item.author, + fileSize = item.fileSize + ) + } + } + } + Timber.tag(ReaderPerfLog.TAG).d("Metadata extraction batch updated ${items.size} rows.") } suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/aryan/reader/epub/EpubBook.kt b/app/src/main/java/com/aryan/reader/epub/EpubBook.kt index 7e32bb5..3b5be54 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubBook.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubBook.kt @@ -23,6 +23,7 @@ import android.graphics.Bitmap import com.aryan.reader.epub.EpubParser.EpubPageTarget import kotlinx.serialization.Serializable import kotlinx.serialization.Transient +import java.io.File @Serializable data class EpubTocEntry( @@ -50,4 +51,15 @@ data class EpubBook( val seriesName: String? = null, val seriesIndex: Double? = null, val description: String? = null, -) \ No newline at end of file +) + +fun EpubBook.hasReadableExtractedContent(): Boolean { + if (extractionBasePath.isBlank()) return false + val extractionDir = File(extractionBasePath) + if (!extractionDir.isDirectory) return false + if (chapters.isEmpty()) return extractionDir.list()?.isNotEmpty() == true + + return chapters.all { chapter -> + File(extractionDir, chapter.htmlFilePath).isFile + } +} diff --git a/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt b/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt index c3fdb28..aaecd62 100644 --- a/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt +++ b/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt @@ -9,8 +9,12 @@ object ImportedFileCache { private const val TEMP_PREFIX = "imported_file_tmp_" private val invalidSegmentChars = Regex("[^A-Za-z0-9._-]+") + fun activeBookDirName(bookId: String): String { + return "$ACTIVE_PREFIX${bookMarker(bookId)}" + } + fun activeBookDir(context: Context, bookId: String): File { - return File(context.cacheDir, "$ACTIVE_PREFIX$bookId") + return File(context.cacheDir, activeBookDirName(bookId)) } fun prepareActiveBookDir(context: Context, bookId: String): File { @@ -39,6 +43,7 @@ object ImportedFileCache { fun clearBookCache(context: Context, bookId: String) { activeBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively() + legacyActiveBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively() clearTemporaryBookDirs(context, bookId) } @@ -69,6 +74,10 @@ object ImportedFileCache { return name.startsWith(ACTIVE_PREFIX) && !isTemporaryBookDir(name) } + private fun legacyActiveBookDir(context: Context, bookId: String): File { + return File(context.cacheDir, "$ACTIVE_PREFIX$bookId") + } + private fun bookMarker(bookId: String): String { val normalized = bookId.toCacheSegment().ifBlank { "book" }.take(40) val hash = bookId.hashCode().toLong() and 0xffffffffL diff --git a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt index e52c239..ef46c8f 100644 --- a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt @@ -109,10 +109,20 @@ class MobiParser(private val context: Context) { private external fun parseMobiFile(filePath: String): ParsedMobiData? companion object { - init { + private val nativeLoadError: Throwable? = try { System.loadLibrary("mobi") System.loadLibrary("native-lib") + null + } catch (t: Throwable) { + Timber.e(t, "MOBI native parser is unavailable on this device.") + t } + + val isNativeParserAvailable: Boolean + get() = nativeLoadError == null + + fun nativeParserUnavailableMessage(): String = + nativeLoadError?.message ?: "MOBI native parser is unavailable on this device." } suspend fun createMobiBook( @@ -122,6 +132,11 @@ class MobiParser(private val context: Context) { parseContent: Boolean = true, extractionDirOverride: File? = null ): EpubBook? = withContext(Dispatchers.IO) { + if (!isNativeParserAvailable) { + Timber.e("Skipping MOBI parsing: ${nativeParserUnavailableMessage()}") + return@withContext null + } + val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir) try { tempFile.outputStream().use { output -> diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt index 8f8caea..001b5e0 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -35,6 +35,8 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.safety.Safelist import org.zwobble.mammoth.DocumentConverter import timber.log.Timber import java.io.File @@ -52,6 +54,15 @@ class SingleFileImporter(private val context: Context) { private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L } + private val htmlSafelist = Safelist.relaxed() + .addTags("article", "aside", "details", "div", "figcaption", "figure", "footer", "header", "main", "section", "summary") + .addAttributes(":all", "class", "dir", "id", "lang", "title") + .addAttributes("a", "name", "target") + .addProtocols("a", "href", "http", "https", "mailto", "tel", "#") + .addProtocols("img", "src", "http", "https", "data", "file", "content") + + private val htmlOutputSettings = Document.OutputSettings().prettyPrint(false) + suspend fun importSingleFile( inputStream: InputStream, type: FileType, @@ -61,8 +72,9 @@ class SingleFileImporter(private val context: Context) { ): EpubBook { val lowerHint = originalBookNameHint.lowercase() - val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv") - val isCodeOrData = listOf(".json", ".xml", ".log", ".java", ".kt", ".py", ".js", ".cpp", ".c", ".cs", ".rb", ".go").any { lowerHint.endsWith(it) } + val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv") || + lowerHint.endsWith(".csv.txt") || lowerHint.endsWith(".tsv.txt") + val isCodeOrData = com.aryan.reader.isCodeOrDataFileName(originalBookNameHint) if (type == FileType.HTML && (isCsv || isCodeOrData)) { return parseDynamicContentToHtml(inputStream, originalBookNameHint, bookId, parseContent, isCsv) @@ -99,7 +111,7 @@ class SingleFileImporter(private val context: Context) { writer.write("\n\n
\n")
                 }
 
-                val delimiter = if (originalBookNameHint.lowercase().endsWith(".tsv")) '\t' else ','
+                val delimiter = if (originalBookNameHint.lowercase().let { it.endsWith(".tsv") || it.endsWith(".tsv.txt") }) '\t' else ','
 
                 inputStream.bufferedReader().use { reader ->
                     var line = reader.readLine()
@@ -177,11 +189,7 @@ class SingleFileImporter(private val context: Context) {
             )
         }
 
-        File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
-
-        val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
-            if (!exists()) mkdirs()
-        }
+        val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
         val metadataFile = File(extractionDir, "book_metadata.json")
 
         if (metadataFile.exists()) {
@@ -245,7 +253,7 @@ class SingleFileImporter(private val context: Context) {
                 val chapterTitle = "Page $pageNum"
 
                 val document = parser.parse(rawText)
-                val htmlBody = renderer.render(document)
+                val htmlBody = sanitizeHtmlFragment(renderer.render(document))
 
                 val fileName = "page_$pageNum.html"
                 val file = File(extractionDir, fileName)
@@ -315,11 +323,7 @@ class SingleFileImporter(private val context: Context) {
             )
         }
 
-        File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
-
-        val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
-            if (!exists()) mkdirs()
-        }
+        val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
         val metadataFile = File(extractionDir, "book_metadata.json")
 
         if (metadataFile.exists()) {
@@ -480,11 +484,7 @@ class SingleFileImporter(private val context: Context) {
             )
         }
 
-        File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
-
-        val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
-            if (!exists()) mkdirs()
-        }
+        val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
         val metadataFile = File(extractionDir, "book_metadata.json")
 
         if (metadataFile.exists()) {
@@ -507,6 +507,7 @@ class SingleFileImporter(private val context: Context) {
         val chapters = mutableListOf()
 
         inputStream.bufferedReader().use { reader ->
+            var inScript = false
             var inStyle = false
             var inBody = false
             var pageNum = 1
@@ -516,6 +517,19 @@ class SingleFileImporter(private val context: Context) {
             while (reader.readLine().also { line = it } != null) {
                 val trimmed = line!!.trim()
 
+                if (inScript) {
+                    if (trimmed.contains("").substringBefore("")
@@ -632,6 +646,10 @@ class SingleFileImporter(private val context: Context) {
         return@withContext book
     }
 
+    private fun sanitizeHtmlFragment(html: String): String {
+        return Jsoup.clean(html, "", htmlSafelist, htmlOutputSettings)
+    }
+
     private suspend fun parseDocx(
         inputStream: InputStream,
         originalBookNameHint: String,
@@ -654,11 +672,7 @@ class SingleFileImporter(private val context: Context) {
             )
         }
 
-        File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
-
-        val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
-            if (!exists()) mkdirs()
-        }
+        val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
         val metadataFile = File(extractionDir, "book_metadata.json")
 
         if (metadataFile.exists()) {
@@ -754,8 +768,9 @@ class SingleFileImporter(private val context: Context) {
         val chapterTitle = if (pageNum > 1 || bodyContent.contains("\n\n${title.replace("\"", """)}\n\n\n\n${bodyContent.trim()}\n\n"
+        val fullHtml = "\n\n\n${title.replace("\"", """)}\n\n\n\n${sanitizedBodyContent.trim()}\n\n"
 
         file.writeText(fullHtml)
 
diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
index cdf3312..714749a 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
@@ -26,10 +26,8 @@ import android.content.ClipData
 import android.content.ClipboardManager
 import android.content.Context
 import android.content.Intent
-import android.graphics.BitmapFactory
 import android.graphics.Color
 import android.graphics.Rect
-import android.util.Base64
 import android.webkit.JavascriptInterface
 import android.webkit.WebResourceRequest
 import android.webkit.WebSettings
@@ -83,13 +81,12 @@ import androidx.compose.ui.window.Popup
 import androidx.compose.ui.window.PopupPositionProvider
 import androidx.core.net.toUri
 import com.aryan.reader.R
-import com.aryan.reader.ReaderTexture
+import com.aryan.reader.getReaderTextureDataUri
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.launch
 import org.json.JSONObject
 import timber.log.Timber
 import java.io.BufferedReader
-import java.io.ByteArrayOutputStream
 import java.io.InputStreamReader
 
 private const val TAG_LINK_NAV = "LINK_NAV"
@@ -351,6 +348,7 @@ fun ChapterWebView(
     currentParagraphGap: Float,
     currentImageSize: Float,
     currentHorizontalMargin: Float,
+    currentVerticalMargin: Float,
     onChapterInitiallyScrolled: () -> Unit,
     modifier: Modifier = Modifier,
     onTap: () -> Unit,
@@ -386,7 +384,8 @@ fun ChapterWebView(
     activeHighlightPalette: List,
     onUpdatePalette: (Int, HighlightColor) -> Unit,
     onInternalLinkClick: (String) -> Unit,
-    activeTextureId: String? = null
+    activeTextureId: String? = null,
+    activeTextureAlpha: Float = 0.55f
 ) {
     Timber.d(
         "RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget"
@@ -406,17 +405,8 @@ fun ChapterWebView(
 
     val textureBase64 by remember(activeTextureId) {
         mutableStateOf(
-            activeTextureId?.let { id ->
-                ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
-                    val bmp = BitmapFactory.decodeResource(context.resources, resId)
-                    val out = ByteArrayOutputStream()
-                    bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
-                    "data:image/png;base64," + Base64.encodeToString(
-                        out.toByteArray(),
-                        Base64.NO_WRAP
-                    )
-                }
-            })
+            getReaderTextureDataUri(context, activeTextureId)
+        )
     }
 
     val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady)
@@ -476,10 +466,10 @@ fun ChapterWebView(
 
     Box(modifier = modifier.fillMaxSize()) {
 
-        LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64) {
+        LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64, activeTextureAlpha) {
             val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
             val textHex = String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
-            localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});", null)
+            localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});", null)
         }
 
         key(
@@ -489,6 +479,7 @@ fun ChapterWebView(
             currentParagraphGap,
             currentImageSize,
             currentHorizontalMargin,
+            currentVerticalMargin,
             currentFontFamily,
             currentTextAlign
         ) {
@@ -737,7 +728,7 @@ fun ChapterWebView(
                             val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
                             val textHex =
                                 String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
-                            view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});",
+                            view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});",
                                 null)
 
                             val fragmentsJson = org.json.JSONArray(tocFragments).toString()
@@ -782,7 +773,7 @@ fun ChapterWebView(
                             }
 
                             view?.evaluateJavascript(
-                                "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
+                                "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
                                 null
                             )
 
@@ -946,7 +937,7 @@ fun ChapterWebView(
                 )
 
                 webView.evaluateJavascript(
-                    "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
+                    "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
                     null
                 )
 
diff --git a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt
index dde0201..0aab63d 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt
@@ -41,8 +41,8 @@ import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.window.Dialog
 import androidx.core.graphics.drawable.toBitmap
-import com.aryan.reader.BuildConfig
 import com.aryan.reader.R
+import com.aryan.reader.areReaderAiFeaturesEnabled
 
 @Suppress("KotlinConstantConditions")
 @OptIn(ExperimentalMaterial3Api::class)
@@ -92,7 +92,7 @@ fun DictionarySettingsDialog(
                 )
 
                 // ── Dictionary ──
-                if (BuildConfig.FLAVOR != "oss") {
+                if (areReaderAiFeaturesEnabled(context)) {
                     Surface(
                         shape = RoundedCornerShape(16.dp),
                         color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -342,4 +342,4 @@ private fun AppSelectionDropdown(
             }
         }
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
index 620eeca..c8e776d 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
@@ -32,11 +32,14 @@ import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextAlign
 import com.aryan.reader.AiDefinitionPopup
+import com.aryan.reader.AiFeature
 import com.aryan.reader.AiDefinitionResult
 import com.aryan.reader.AiHubBottomSheet
+import com.aryan.reader.BuildConfig
 import com.aryan.reader.R
 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.fetchRecap
 import com.aryan.reader.paginatedreader.IPaginator
@@ -55,6 +58,7 @@ import java.net.URL
  */
 suspend fun summarizeBookContent(
     content: String,
+    context: Context,
     authToken: String?,
     onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> },
     onUpdate: (String) -> Unit,
@@ -68,6 +72,27 @@ suspend fun summarizeBookContent(
     }
     Timber.d("Starting summarization for content of length: ${content.length}")
 
+    @Suppress("KotlinConstantConditions")
+    if (BuildConfig.FLAVOR == "oss") {
+        if (BuildConfig.IS_OFFLINE) {
+            onError("AI features are unavailable in the offline OSS build.")
+            onFinish()
+            return
+        }
+        callByokTextAi(
+            context = context,
+            feature = AiFeature.SUMMARIZE,
+            systemInstruction = "You are an expert in analyzing written content. Provide a concise, easy-to-read summary of the provided chapter. Identify the main ideas, plot points, and themes. Do not add a preamble like 'Here is the summary:'",
+            userPrompt = content,
+            temperature = 0.2,
+            maxTokens = 8192,
+            onUpdate = onUpdate,
+            onError = onError
+        )
+        onFinish()
+        return
+    }
+
     withContext(Dispatchers.IO) {
         var connection: HttpURLConnection? = null
         try {
@@ -196,6 +221,7 @@ suspend fun executeRecapLogic(
 
                 summarizeBookContent(
                     content = textToSummarize,
+                    context = context,
                     authToken = authToken,
                     onUsageReceived = { cost, _ ->
                         Timber.i("[AI-Billing] Background past chapter summary cost: $cost credits")
@@ -387,4 +413,4 @@ fun EpubReaderAiOverlays(
             }
         )
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
index 0e82295..f5014e6 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
@@ -26,6 +26,7 @@ import android.graphics.Canvas
 import android.os.Build
 import android.webkit.WebView
 import androidx.annotation.RequiresApi
+import androidx.compose.foundation.lazy.LazyListState
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.animateContentSize
@@ -39,11 +40,13 @@ import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.Image
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGestures
 import androidx.compose.foundation.interaction.MutableInteractionSource
 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
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.WindowInsets
@@ -64,6 +67,7 @@ import androidx.compose.foundation.layout.widthIn
 import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
@@ -97,10 +101,8 @@ import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.IconButtonDefaults
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
 import androidx.compose.material3.Slider
 import androidx.compose.material3.Surface
-import androidx.compose.material3.Switch
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
@@ -112,9 +114,12 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.asImageBitmap
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
@@ -122,6 +127,9 @@ import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.zIndex
 import androidx.core.graphics.createBitmap
 import androidx.media3.common.util.UnstableApi
 import com.aryan.reader.BuildConfig
@@ -130,6 +138,7 @@ import com.aryan.reader.RenderMode
 import com.aryan.reader.SearchState
 import com.aryan.reader.SearchTopBar
 import com.aryan.reader.TooltipIconButton
+import com.aryan.reader.areReaderAiFeaturesEnabled
 import com.aryan.reader.epub.EpubChapter
 import com.aryan.reader.loadNativeVoice
 import com.aryan.reader.paginatedreader.BookPaginator
@@ -161,6 +170,96 @@ enum class ReaderTool(val title: String, val category: String) {
     TTS_SETTINGS("TTS Voice Settings", "Overflow Menu")
 }
 
+enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
+
+data class FlatToolItem(
+    val id: String,
+    val type: FlatItemType,
+    val tool: ReaderTool? = null,
+    val section: ToolbarSection? = null,
+    val title: String? = null
+)
+
+fun sanitizePlaceholders(list: List): List {
+    val result = mutableListOf()
+    val sectionMap = mutableMapOf>()
+    ToolbarSection.entries.forEach { sectionMap[it] = mutableListOf() }
+
+    list.forEach { item ->
+        if (item.type == FlatItemType.TOOL) {
+            item.section?.let { sectionMap[it]?.add(item) }
+        }
+    }
+
+    ToolbarSection.entries.forEach { section ->
+        result.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title))
+
+        val tools = sectionMap[section] ?: emptyList()
+        if (tools.isEmpty()) {
+            result.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
+        } else {
+            result.addAll(tools)
+        }
+    }
+
+    // Maintain More menu items
+    list.filter { it.type == FlatItemType.MORE_HEADER || it.type == FlatItemType.MORE_TOOL }.forEach {
+        result.add(it)
+    }
+
+    return result
+}
+
+class DragDropState(
+    val lazyListState: LazyListState,
+    val onMove: (String, String) -> Unit
+) {
+    var draggedItemId by mutableStateOf(null)
+    var dragOffset by mutableStateOf(Offset.Zero)
+
+    fun onDragStart(id: String) {
+        draggedItemId = id
+        dragOffset = Offset.Zero
+    }
+
+    fun onDrag(delta: Offset) {
+        val draggedId = draggedItemId ?: return
+        dragOffset += delta
+
+        val visibleItems = lazyListState.layoutInfo.visibleItemsInfo
+        val currentItem = visibleItems.find { it.key == draggedId } ?: return
+
+        val startY = currentItem.offset + dragOffset.y
+        val center = startY + currentItem.size / 2f
+
+        val targetItem = visibleItems.find {
+            it.key != draggedId && center >= it.offset && center <= (it.offset + it.size)
+        }
+
+        if (targetItem != null) {
+            onMove(draggedId, targetItem.key.toString())
+            // Adjust visual offset to prevent snapping when items swap in the layout
+            dragOffset = dragOffset.copy(y = dragOffset.y - (targetItem.offset - currentItem.offset))
+        }
+    }
+
+    fun onDragEnd() {
+        draggedItemId = null
+        dragOffset = Offset.Zero
+    }
+}
+
+private val epubToolbarTools = setOf(
+    ReaderTool.DICTIONARY,
+    ReaderTool.THEME,
+    ReaderTool.SLIDER,
+    ReaderTool.TOC,
+    ReaderTool.FORMAT,
+    ReaderTool.SEARCH,
+    ReaderTool.AI_FEATURES,
+    ReaderTool.TTS_CONTROLS
+)
+
 @Composable
 fun EpubReaderTopBar(
     isVisible: Boolean,
@@ -186,8 +285,16 @@ fun EpubReaderTopBar(
     onOpenDictionarySettings: () -> Unit,
     onOpenThemeSettings: () -> Unit,
     onOpenVisualOptions: () -> Unit,
+    onOpenSlider: () -> Unit,
+    onOpenDrawer: () -> Unit,
+    onToggleFormat: () -> Unit,
+    onToggleSearch: () -> Unit,
+    onOpenAiHub: () -> Unit,
+    onToggleTts: () -> Unit,
     searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
     hiddenTools: Set,
+    toolOrder: List,
+    bottomTools: Set,
     onCustomizeTools: () -> Unit,
     modifier: Modifier = Modifier,
     onToggleReflow: (() -> Unit)? = null,
@@ -235,41 +342,101 @@ fun EpubReaderTopBar(
                         overflow = TextOverflow.Ellipsis,
                         modifier = Modifier.weight(1f)
                     )
-                    if (!hiddenTools.contains(ReaderTool.DICTIONARY.name)) {
-                        TooltipIconButton(
-                            text = stringResource(R.string.tooltip_dictionary),
-                            description = stringResource(R.string.tooltip_dictionary_desc),
-                            onClick = onOpenDictionarySettings
-                        ) {
-                            Icon(
-                                painter = painterResource(id = R.drawable.dictionary),
-                                contentDescription = stringResource(R.string.content_desc_dictionary_settings)
-                            )
+                    toolOrder
+                        .filter { it in epubToolbarTools && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
+                        .forEach { tool ->
+                            when (tool) {
+                                ReaderTool.DICTIONARY -> TooltipIconButton(
+                                    text = stringResource(R.string.tooltip_dictionary),
+                                    description = stringResource(R.string.tooltip_dictionary_desc),
+                                    onClick = onOpenDictionarySettings
+                                ) {
+                                    Icon(
+                                        painter = painterResource(id = R.drawable.dictionary),
+                                        contentDescription = stringResource(R.string.content_desc_dictionary_settings)
+                                    )
+                                }
+                                ReaderTool.THEME -> TooltipIconButton(
+                                    text = stringResource(R.string.tooltip_theme),
+                                    description = stringResource(R.string.tooltip_theme_desc),
+                                    onClick = onOpenThemeSettings
+                                ) {
+                                    Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
+                                }
+                                ReaderTool.SLIDER -> TooltipIconButton(
+                                    text = stringResource(R.string.tooltip_slider),
+                                    description = stringResource(R.string.tooltip_slider_desc),
+                                    onClick = onOpenSlider,
+                                    enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
+                                ) {
+                                    Icon(painter = painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
+                                }
+                                ReaderTool.TOC -> TooltipIconButton(
+                                    text = stringResource(R.string.tooltip_toc),
+                                    description = stringResource(R.string.tooltip_toc_desc),
+                                    onClick = onOpenDrawer
+                                ) {
+                                    Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_chapters_menu))
+                                }
+                                ReaderTool.FORMAT -> TooltipIconButton(
+                                    text = stringResource(R.string.tooltip_format),
+                                    description = stringResource(R.string.tooltip_format_desc),
+                                    onClick = onToggleFormat
+                                ) {
+                                    Icon(painter = painterResource(id = R.drawable.format_size), contentDescription = stringResource(R.string.content_desc_text_formatting))
+                                }
+                                ReaderTool.SEARCH -> TooltipIconButton(
+                                    text = stringResource(R.string.tooltip_search),
+                                    description = stringResource(R.string.tooltip_search_desc),
+                                    onClick = onToggleSearch
+                                ) {
+                                    Icon(Icons.Default.Search, contentDescription = stringResource(R.string.tooltip_search))
+                                }
+                                ReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
+                                    TooltipIconButton(
+                                        text = stringResource(R.string.tooltip_ai),
+                                        description = stringResource(R.string.tooltip_ai_desc),
+                                        onClick = onOpenAiHub
+                                    ) {
+                                        Icon(painter = painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.ai_features_title))
+                                    }
+                                }
+                                ReaderTool.TTS_CONTROLS -> TooltipIconButton(
+                                    text = if (isTtsActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
+                                    description = if (isTtsActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
+                                    onClick = onToggleTts
+                                ) {
+                                    Icon(
+                                        painter = if (isTtsActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech),
+                                        contentDescription = if (isTtsActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts),
+                                        tint = if (isTtsActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+                                    )
+                                }
+                                else -> Unit
+                            }
                         }
-                    }
-                    if (!hiddenTools.contains(ReaderTool.THEME.name)) {
-                        TooltipIconButton(
-                            text = stringResource(R.string.tooltip_theme),
-                            description = stringResource(R.string.tooltip_theme_desc),
-                            onClick = onOpenThemeSettings
-                        ) {
-                            Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
-                        }
-                    }
                     Box {
                         var showMoreMenu by remember { mutableStateOf(false) }
+                        var showHiddenToolsExpanded by remember { mutableStateOf(false) }
                         TooltipIconButton(
                             text = stringResource(R.string.tooltip_more_options),
                             description = stringResource(R.string.tooltip_more_options_desc),
-                            onClick = { showMoreMenu = true }
+                            onClick = {
+                                showHiddenToolsExpanded = false
+                                showMoreMenu = true
+                            }
                         ) {
                             Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.content_desc_more_options))
                         }
 
                         DropdownMenu(
                             expanded = showMoreMenu,
-                            onDismissRequest = { showMoreMenu = false }
+                            onDismissRequest = {
+                                showHiddenToolsExpanded = false
+                                showMoreMenu = false
+                            }
                         ) {
+                            val hiddenToolbarTools = toolOrder.filter { it in epubToolbarTools && hiddenTools.contains(it.name) }
                             DropdownMenuItem(
                                 text = { Text(stringResource(R.string.title_customize_toolbar)) },
                                 onClick = {
@@ -282,6 +449,42 @@ fun EpubReaderTopBar(
                             )
                             HorizontalDivider()
 
+                            if (hiddenToolbarTools.isNotEmpty()) {
+                                DropdownMenuItem(
+                                    text = { Text("Hidden tools") },
+                                    onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
+                                    trailingIcon = {
+                                        Icon(
+                                            Icons.Default.ArrowDropDown,
+                                            contentDescription = null,
+                                            modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f)
+                                        )
+                                    }
+                                )
+                                if (showHiddenToolsExpanded) {
+                                    hiddenToolbarTools.forEach { tool ->
+                                        HiddenEpubToolMenuItem(
+                                            tool = tool,
+                                            currentRenderMode = currentRenderMode,
+                                            isTtsActive = isTtsActive,
+                                            showMoreMenu = {
+                                                showHiddenToolsExpanded = false
+                                                showMoreMenu = false
+                                            },
+                                            onOpenDictionarySettings = onOpenDictionarySettings,
+                                            onOpenThemeSettings = onOpenThemeSettings,
+                                            onOpenSlider = onOpenSlider,
+                                            onOpenDrawer = onOpenDrawer,
+                                            onToggleFormat = onToggleFormat,
+                                            onToggleSearch = onToggleSearch,
+                                            onOpenAiHub = onOpenAiHub,
+                                            onToggleTts = onToggleTts
+                                        )
+                                    }
+                                }
+                                HorizontalDivider()
+                            }
+
                             if (onToggleReflow != null) {
                                 DropdownMenuItem(
                                     text = { Text(stringResource(R.string.menu_view_original_pdf)) },
@@ -498,8 +701,12 @@ fun EpubReaderBottomBar(
     onToggleFormat: () -> Unit,
     onToggleSearch: () -> Unit,
     onOpenAiHub: () -> Unit,
+    onOpenDictionarySettings: () -> Unit,
+    onOpenThemeSettings: () -> Unit,
     onToggleTts: () -> Unit,
     hiddenTools: Set,
+    toolOrder: List,
+    bottomTools: Set,
     modifier: Modifier = Modifier
 ) {
     AnimatedVisibility(
@@ -521,93 +728,97 @@ fun EpubReaderBottomBar(
                 verticalAlignment = Alignment.CenterVertically,
                 horizontalArrangement = Arrangement.SpaceAround
             ) {
-                if (!hiddenTools.contains(ReaderTool.SLIDER.name)) {
-                    TooltipIconButton(
-                        text = stringResource(R.string.tooltip_slider),
-                        description = stringResource(R.string.tooltip_slider_desc),
-                        onClick = onOpenSlider,
-                        enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
-                    ) {
-                        Icon(
-                            painter = painterResource(id = R.drawable.slider),
-                            contentDescription = stringResource(R.string.content_desc_navigate_slider)
-                        )
-                    }
-                }
-                if (!hiddenTools.contains(ReaderTool.TOC.name)) {
-                    TooltipIconButton(
-                        text = stringResource(R.string.tooltip_toc),
-                        description = stringResource(R.string.tooltip_toc_desc),
-                        onClick = onOpenDrawer
-                    ) {
-                        Icon(
-                            imageVector = Icons.Default.Menu,
-                            contentDescription = stringResource(R.string.content_desc_chapters_menu)
-                        )
-                    }
-                }
-                if (!hiddenTools.contains(ReaderTool.FORMAT.name)) {
-                    TooltipIconButton(
-                        text = stringResource(R.string.tooltip_format),
-                        description = stringResource(R.string.tooltip_format_desc),
-                        onClick = onToggleFormat
-                    ) {
-                        Icon(
-                            painter = painterResource(id = R.drawable.format_size),
-                            contentDescription = stringResource(R.string.content_desc_text_formatting)
-                        )
-                    }
-                }
-                if (!hiddenTools.contains(ReaderTool.SEARCH.name)) {
-                    TooltipIconButton(
-                        text = stringResource(R.string.tooltip_search),
-                        description = stringResource(R.string.tooltip_search_desc),
-                        onClick = onToggleSearch
-                    ) {
-                        Icon(
-                            imageVector = Icons.Default.Search,
-                            contentDescription = stringResource(R.string.tooltip_search)
-                        )
-                    }
-                }
-
-                if (!hiddenTools.contains(ReaderTool.AI_FEATURES.name)) {
-                    @Suppress(
-                        "KotlinConstantConditions",
-                        "SimplifyBooleanWithConstants"
-                    ) if (BuildConfig.FLAVOR != "oss") {
-                        TooltipIconButton(
-                            text = stringResource(R.string.tooltip_ai),
-                            description = stringResource(R.string.tooltip_ai_desc),
-                            onClick = onOpenAiHub
-                        ) {
-                            Icon(
-                                painter = painterResource(id = R.drawable.ai),
-                                contentDescription = stringResource(R.string.ai_features_title)
-                            )
+                toolOrder
+                    .filter { it in epubToolbarTools && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
+                    .forEach { tool ->
+                        when (tool) {
+                            ReaderTool.DICTIONARY -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_dictionary),
+                                description = stringResource(R.string.tooltip_dictionary_desc),
+                                onClick = onOpenDictionarySettings
+                            ) {
+                                Icon(painter = painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings))
+                            }
+                            ReaderTool.THEME -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_theme),
+                                description = stringResource(R.string.tooltip_theme_desc),
+                                onClick = onOpenThemeSettings
+                            ) {
+                                Icon(painter = painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc))
+                            }
+                            ReaderTool.SLIDER -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_slider),
+                                description = stringResource(R.string.tooltip_slider_desc),
+                                onClick = onOpenSlider,
+                                enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
+                            ) {
+                                Icon(
+                                    painter = painterResource(id = R.drawable.slider),
+                                    contentDescription = stringResource(R.string.content_desc_navigate_slider)
+                                )
+                            }
+                            ReaderTool.TOC -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_toc),
+                                description = stringResource(R.string.tooltip_toc_desc),
+                                onClick = onOpenDrawer
+                            ) {
+                                Icon(
+                                    imageVector = Icons.Default.Menu,
+                                    contentDescription = stringResource(R.string.content_desc_chapters_menu)
+                                )
+                            }
+                            ReaderTool.FORMAT -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_format),
+                                description = stringResource(R.string.tooltip_format_desc),
+                                onClick = onToggleFormat
+                            ) {
+                                Icon(
+                                    painter = painterResource(id = R.drawable.format_size),
+                                    contentDescription = stringResource(R.string.content_desc_text_formatting)
+                                )
+                            }
+                            ReaderTool.SEARCH -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_search),
+                                description = stringResource(R.string.tooltip_search_desc),
+                                onClick = onToggleSearch
+                            ) {
+                                Icon(
+                                    imageVector = Icons.Default.Search,
+                                    contentDescription = stringResource(R.string.tooltip_search)
+                                )
+                            }
+                            ReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
+                                TooltipIconButton(
+                                    text = stringResource(R.string.tooltip_ai),
+                                    description = stringResource(R.string.tooltip_ai_desc),
+                                    onClick = onOpenAiHub
+                                ) {
+                                    Icon(
+                                        painter = painterResource(id = R.drawable.ai),
+                                        contentDescription = stringResource(R.string.ai_features_title)
+                                    )
+                                }
+                            }
+                            ReaderTool.TTS_CONTROLS -> TooltipIconButton(
+                                text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
+                                else stringResource(R.string.tooltip_tts_start),
+                                description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
+                                else stringResource(R.string.tooltip_tts_start_desc),
+                                onClick = onToggleTts
+                            ) {
+                                Icon(
+                                    painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
+                                        id = R.drawable.text_to_speech
+                                    ),
+                                    contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(
+                                        R.string.content_desc_start_tts
+                                    ),
+                                    tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+                                )
+                            }
+                            else -> Unit
                         }
                     }
-                }
-
-                if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) {
-                    TooltipIconButton(
-                        text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
-                        else stringResource(R.string.tooltip_tts_start),
-                        description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
-                        else stringResource(R.string.tooltip_tts_start_desc),
-                        onClick = onToggleTts
-                    ) {
-                        Icon(
-                            painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
-                                id = R.drawable.text_to_speech
-                            ),
-                            contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(
-                                R.string.content_desc_start_tts
-                            ),
-                            tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
-                        )
-                    }
-                }
             }
         }
     }
@@ -1301,66 +1512,222 @@ fun AutoScrollControls(
 @Composable
 fun CustomizeToolsSheet(
     hiddenTools: Set,
+    toolOrder: List,
+    bottomTools: Set,
     onUpdate: (Set) -> Unit,
+    onOrderUpdate: (List) -> Unit,
+    onPlacementUpdate: (Set) -> Unit,
     onDismiss: () -> Unit
 ) {
-    ModalBottomSheet(
-        onDismissRequest = onDismiss,
-        contentWindowInsets = { WindowInsets.navigationBars }
-    ) {
-        Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
-            Text(
-                text = "Customize Toolbar",
-                style = MaterialTheme.typography.headlineSmall,
-                fontWeight = FontWeight.Bold,
-                color = MaterialTheme.colorScheme.onSurface
-            )
-            Spacer(modifier = Modifier.height(8.dp))
-            Text(
-                text = stringResource(R.string.desc_customize_toolbar),
-                style = MaterialTheme.typography.bodyMedium,
-                color = MaterialTheme.colorScheme.onSurfaceVariant
-            )
-            Spacer(modifier = Modifier.height(16.dp))
+    var localHiddenTools by remember { mutableStateOf(hiddenTools) }
 
-            LazyColumn(modifier = Modifier.fillMaxWidth()) {
-                ReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
-                    item {
-                        Text(
-                            text = category,
-                            style = MaterialTheme.typography.labelMedium,
-                            color = MaterialTheme.colorScheme.primary,
-                            modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
-                        )
+    var flatItems by remember {
+        mutableStateOf(
+            run {
+                val toolbarTools = toolOrder.filter { it in epubToolbarTools }
+                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 epubToolbarTools }
+
+                val list = mutableListOf()
+
+                ToolbarSection.entries.forEach { section ->
+                    val tools = when(section) {
+                        ToolbarSection.TOP -> topTools
+                        ToolbarSection.BOTTOM -> bottomToolsList
+                        ToolbarSection.HIDDEN -> hiddenToolsList
                     }
-                    items(tools) { tool ->
-                        Row(
+                    list.add(FlatToolItem("header_${section.name}", FlatItemType.SECTION_HEADER, section = section, title = section.title))
+                    if (tools.isEmpty()) {
+                        list.add(FlatToolItem("empty_${section.name}", FlatItemType.EMPTY_PLACEHOLDER, section = section))
+                    } else {
+                        tools.forEach { tool ->
+                            list.add(FlatToolItem("tool_${tool.name}", FlatItemType.TOOL, tool = tool, section = section))
+                        }
+                    }
+                }
+
+                list.add(FlatToolItem("more_header", FlatItemType.MORE_HEADER, title = "More menu"))
+                moreTools.forEach { tool ->
+                    list.add(FlatToolItem("more_${tool.name}", FlatItemType.MORE_TOOL, tool = tool))
+                }
+                list
+            }
+        )
+    }
+
+    val commitDragDrop = {
+        val newHidden = localHiddenTools.filter { toolName ->
+            toolOrder.find { it.name == toolName } !in epubToolbarTools
+        }.toMutableSet()
+
+        val newBottom = mutableSetOf()
+        val newOrder = mutableListOf()
+
+        flatItems.forEach { item ->
+            if (item.type == FlatItemType.TOOL && item.tool != null) {
+                newOrder.add(item.tool)
+                if (item.section == ToolbarSection.HIDDEN) newHidden.add(item.tool.name)
+                if (item.section == ToolbarSection.BOTTOM) newBottom.add(item.tool.name)
+            }
+        }
+
+        val moreTools = flatItems.filter { it.type == FlatItemType.MORE_TOOL }.mapNotNull { it.tool }
+        newOrder.addAll(moreTools)
+
+        localHiddenTools = newHidden
+        onUpdate(newHidden)
+        onPlacementUpdate(newBottom)
+        onOrderUpdate(newOrder)
+    }
+
+    val lazyListState = rememberLazyListState()
+    val dragDropState = remember {
+        DragDropState(lazyListState) { fromKey, toKey ->
+            val fromIndex = flatItems.indexOfFirst { it.id == fromKey }
+            val toIndex = flatItems.indexOfFirst { it.id == toKey }
+            if (fromIndex == -1 || toIndex == -1 || fromIndex == toIndex) return@DragDropState
+
+            val fromItem = flatItems[fromIndex]
+            if (fromItem.type != FlatItemType.TOOL) return@DragDropState
+
+            val toItem = flatItems[toIndex]
+            if (toItem.type == FlatItemType.MORE_HEADER || toItem.type == FlatItemType.MORE_TOOL) return@DragDropState
+
+            val newList = flatItems.toMutableList()
+            val movedItem = newList.removeAt(fromIndex)
+
+            val newToIndex = newList.indexOfFirst { it.id == toKey }
+            val insertIndex = if (fromIndex < toIndex) newToIndex + 1 else newToIndex
+
+            newList.add(insertIndex, movedItem)
+
+            var actualSection = movedItem.section
+            for (i in insertIndex downTo 0) {
+                val item = newList[i]
+                if (item.type == FlatItemType.SECTION_HEADER) {
+                    actualSection = item.section
+                    break
+                }
+            }
+
+            newList[insertIndex] = movedItem.copy(section = actualSection)
+
+            flatItems = newList
+        }
+    }
+
+    Dialog(
+        onDismissRequest = onDismiss,
+        properties = DialogProperties(usePlatformDefaultWidth = false)
+    ) {
+        Surface(
+            modifier = Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(WindowInsets.navigationBars),
+            color = MaterialTheme.colorScheme.surface
+        ) {
+            Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp)) {
+                Row(
+                    modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
+                    verticalAlignment = Alignment.CenterVertically
+                ) {
+                    Text(
+                        text = "Customize Toolbar",
+                        style = MaterialTheme.typography.headlineSmall,
+                        fontWeight = FontWeight.Bold,
+                        color = MaterialTheme.colorScheme.onSurface,
+                        modifier = Modifier.weight(1f)
+                    )
+                    IconButton(onClick = onDismiss) {
+                        Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
+                    }
+                }
+
+                LazyColumn(
+                    state = lazyListState,
+                    modifier = Modifier.fillMaxSize(),
+                    contentPadding = PaddingValues(bottom = 24.dp)
+                ) {
+                    items(flatItems, key = { it.id }) { item ->
+                        val isDragged = item.id == dragDropState.draggedItemId
+
+                        val zIndex = if (isDragged) 1f else 0f
+                        val elevation = if (isDragged) 8.dp else 0.dp
+                        val scale = if (isDragged) 1.03f else 1f
+                        val translationY = if (isDragged) dragDropState.dragOffset.y else 0f
+
+                        Box(
                             modifier = Modifier
                                 .fillMaxWidth()
-                                .clip(RoundedCornerShape(8.dp))
-                                .clickable {
-                                    val newSet = hiddenTools.toMutableSet()
-                                    if (newSet.contains(tool.name)) newSet.remove(tool.name)
-                                    else newSet.add(tool.name)
-                                    onUpdate(newSet)
+                                .then(if (isDragged) Modifier else Modifier.animateItem())
+                                .zIndex(zIndex)
+                                .graphicsLayer {
+                                    this.translationY = translationY
+                                    this.scaleX = scale
+                                    this.scaleY = scale
+                                    this.shadowElevation = elevation.toPx()
                                 }
-                                .padding(vertical = 12.dp, horizontal = 8.dp),
-                            verticalAlignment = Alignment.CenterVertically,
-                            horizontalArrangement = Arrangement.SpaceBetween
                         ) {
-                            Text(
-                                text = tool.title,
-                                style = MaterialTheme.typography.bodyLarge,
-                                color = MaterialTheme.colorScheme.onSurface
-                            )
-                            Switch(
-                                checked = !hiddenTools.contains(tool.name),
-                                onCheckedChange = { isVisible ->
-                                    val newSet = hiddenTools.toMutableSet()
-                                    if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
-                                    onUpdate(newSet)
+                            when (item.type) {
+                                FlatItemType.SECTION_HEADER -> {
+                                    Text(
+                                        text = item.title ?: "",
+                                        style = MaterialTheme.typography.titleMedium,
+                                        fontWeight = FontWeight.Bold,
+                                        color = MaterialTheme.colorScheme.onSurface,
+                                        modifier = Modifier.padding(top = 16.dp, bottom = 8.dp, start = 4.dp)
+                                    )
                                 }
-                            )
+                                FlatItemType.EMPTY_PLACEHOLDER -> {
+                                    Box(
+                                        modifier = Modifier
+                                            .fillMaxWidth()
+                                            .height(64.dp)
+                                            .padding(vertical = 4.dp)
+                                            .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
+                                        contentAlignment = Alignment.Center
+                                    ) {
+                                        Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant)
+                                    }
+                                }
+                                FlatItemType.TOOL -> {
+                                    ToolbarDragRow(
+                                        tool = item.tool!!,
+                                        isDragging = isDragged,
+                                        onDragStart = { dragDropState.onDragStart(item.id) },
+                                        onDrag = { dragDropState.onDrag(it) },
+                                        onDragEnd = {
+                                            dragDropState.onDragEnd()
+                                            flatItems = sanitizePlaceholders(flatItems).toMutableList()
+                                            commitDragDrop()
+                                        }
+                                    )
+                                }
+                                FlatItemType.MORE_HEADER -> {
+                                    Text(
+                                        text = item.title ?: "More menu",
+                                        style = MaterialTheme.typography.labelMedium,
+                                        color = MaterialTheme.colorScheme.primary,
+                                        modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
+                                    )
+                                }
+                                FlatItemType.MORE_TOOL -> {
+                                    MoreToolVisibilityRow(
+                                        title = item.tool!!.title,
+                                        visible = !localHiddenTools.contains(item.tool.name),
+                                        onToggle = {
+                                            localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
+                                                localHiddenTools - item.tool.name
+                                            } else {
+                                                localHiddenTools + item.tool.name
+                                            }
+                                            onUpdate(localHiddenTools)
+                                        }
+                                    )
+                                }
+                            }
                         }
                     }
                 }
@@ -1369,6 +1736,144 @@ fun CustomizeToolsSheet(
     }
 }
 
+@Composable
+private fun ToolbarDragRow(
+    tool: ReaderTool,
+    isDragging: Boolean,
+    onDragStart: () -> Unit,
+    onDrag: (Offset) -> Unit,
+    onDragEnd: () -> Unit
+) {
+    Surface(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(vertical = 4.dp),
+        shape = RoundedCornerShape(12.dp),
+        color = if (isDragging) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+    ) {
+        Row(
+            modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            ToolPreviewIcon(tool)
+            Spacer(Modifier.width(16.dp))
+            Text(
+                text = tool.title,
+                style = MaterialTheme.typography.bodyLarge,
+                color = MaterialTheme.colorScheme.onSurface,
+                modifier = Modifier.weight(1f)
+            )
+            Icon(
+                Icons.Default.Menu,
+                contentDescription = "Drag to reorder",
+                tint = MaterialTheme.colorScheme.onSurfaceVariant,
+                modifier = Modifier
+                    .size(32.dp)
+                    .padding(6.dp)
+                    .clip(CircleShape)
+                    .pointerInput(tool) {
+                        detectDragGestures(
+                            onDragStart = { onDragStart() },
+                            onDrag = { change, dragAmount ->
+                                change.consume()
+                                onDrag(dragAmount)
+                            },
+                            onDragEnd = onDragEnd,
+                            onDragCancel = onDragEnd
+                        )
+                    }
+            )
+        }
+    }
+}
+
+@Composable
+private fun MoreToolVisibilityRow(
+    title: String,
+    visible: Boolean,
+    onToggle: () -> Unit
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clip(RoundedCornerShape(8.dp))
+            .clickable(onClick = onToggle)
+            .padding(vertical = 12.dp, horizontal = 8.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Text(
+            text = title,
+            style = MaterialTheme.typography.bodyLarge,
+            color = MaterialTheme.colorScheme.onSurface,
+            modifier = Modifier.weight(1f)
+        )
+        if (visible) {
+            Icon(Icons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+        }
+    }
+}
+
+enum class ToolbarSection(val title: String) {
+    TOP("Top Bar"),
+    BOTTOM("Bottom Bar"),
+    HIDDEN("Hidden Tools")
+}
+
+@Composable
+private fun ToolPreviewIcon(tool: ReaderTool) {
+    when (tool) {
+        ReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        ReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        ReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        ReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        ReaderTool.FORMAT -> Icon(painterResource(id = R.drawable.format_size), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
+    }
+}
+
+@Composable
+private fun HiddenEpubToolMenuItem(
+    tool: ReaderTool,
+    currentRenderMode: RenderMode,
+    isTtsActive: Boolean,
+    showMoreMenu: () -> Unit,
+    onOpenDictionarySettings: () -> Unit,
+    onOpenThemeSettings: () -> Unit,
+    onOpenSlider: () -> Unit,
+    onOpenDrawer: () -> Unit,
+    onToggleFormat: () -> Unit,
+    onToggleSearch: () -> Unit,
+    onOpenAiHub: () -> Unit,
+    onToggleTts: () -> Unit
+) {
+    val enabled = when (tool) {
+        ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL
+        else -> true
+    }
+    DropdownMenuItem(
+        text = { Text(tool.title) },
+        enabled = enabled,
+        onClick = {
+            showMoreMenu()
+            when (tool) {
+                ReaderTool.DICTIONARY -> onOpenDictionarySettings()
+                ReaderTool.THEME -> onOpenThemeSettings()
+                ReaderTool.SLIDER -> onOpenSlider()
+                ReaderTool.TOC -> onOpenDrawer()
+                ReaderTool.FORMAT -> onToggleFormat()
+                ReaderTool.SEARCH -> onToggleSearch()
+                ReaderTool.AI_FEATURES -> onOpenAiHub()
+                ReaderTool.TTS_CONTROLS -> onToggleTts()
+                else -> Unit
+            }
+        },
+        leadingIcon = { ToolPreviewIcon(tool) }
+    )
+}
+
 @androidx.annotation.OptIn(UnstableApi::class)
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
@@ -1384,13 +1889,45 @@ fun TtsOverlayControls(
     modifier: Modifier = Modifier,
     credits: Int
 ) {
-    val context = androidx.compose.ui.platform.LocalContext.current
+    val context = LocalContext.current
     var rate by remember { mutableFloatStateOf(loadTtsSpeechRate(context)) }
     var pitch by remember { mutableFloatStateOf(loadTtsPitch(context)) }
     var isDraggingRate by remember { mutableStateOf(false) }
     var isDraggingPitch by remember { mutableStateOf(false) }
 
     val activeMode = try { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.valueOf(ttsState.ttsMode) } catch(_: Exception) { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD }
+    val progressPercent = ttsState.bookProgressPercent
+    val cleanChapterTitle = remember(ttsState.chapterTitle) {
+        ttsState.chapterTitle
+            ?.lineSequence()
+            ?.map { it.trim() }
+            ?.filter { it.isNotBlank() }
+            ?.joinToString(" - ")
+            ?.takeIf { it.isNotBlank() }
+    }
+    val chapterLabel = remember(ttsState.chapterIndex, ttsState.totalChapters, cleanChapterTitle) {
+        val chapterNumber = ttsState.chapterIndex?.plus(1)
+        val totalChapters = ttsState.totalChapters
+        when {
+            chapterNumber != null && totalChapters != null -> buildString {
+                append("Chapter $chapterNumber of $totalChapters")
+                if (!cleanChapterTitle.isNullOrBlank()) append(": $cleanChapterTitle")
+            }
+            chapterNumber != null -> buildString {
+                append("Chapter $chapterNumber")
+                if (!cleanChapterTitle.isNullOrBlank()) append(": $cleanChapterTitle")
+            }
+            !cleanChapterTitle.isNullOrBlank() -> cleanChapterTitle
+            else -> null
+        }
+    }
+    val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
+        if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) {
+            "Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}"
+        } else {
+            null
+        }
+    }
 
     val saveAndApply = {
         saveTtsSpeechRate(context, rate)
@@ -1524,6 +2061,33 @@ fun TtsOverlayControls(
 
                     Spacer(Modifier.height(16.dp))
 
+                    if (chapterLabel != null || progressPercent != null || chunkLabel != null) {
+                        Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+                            Row(
+                                modifier = Modifier.fillMaxWidth(),
+                                horizontalArrangement = Arrangement.SpaceBetween,
+                                verticalAlignment = Alignment.CenterVertically
+                            ) {
+                                Text(
+                                    chapterLabel ?: "Reading",
+                                    style = MaterialTheme.typography.labelLarge,
+                                    color = MaterialTheme.colorScheme.onSurface,
+                                    maxLines = 1,
+                                    overflow = TextOverflow.Ellipsis,
+                                    modifier = Modifier.weight(1f).padding(end = 8.dp)
+                                )
+                                Text(
+                                    listOfNotNull(progressPercent?.let { "$it%" }, chunkLabel).joinToString(" - "),
+                                    style = MaterialTheme.typography.labelSmall,
+                                    color = MaterialTheme.colorScheme.onSurfaceVariant,
+                                    maxLines = 1
+                                )
+                            }
+                        }
+
+                        Spacer(Modifier.height(16.dp))
+                    }
+
                     // Middle Section: Controls
                     Row(
                         modifier = Modifier.fillMaxWidth(),
@@ -1555,41 +2119,151 @@ fun TtsOverlayControls(
                         Spacer(Modifier.width(16.dp))
 
                         // Unified Sliders Block
-                        Column(modifier = Modifier.weight(1f)) {
-                            Row(verticalAlignment = Alignment.CenterVertically) {
-                                Text(stringResource(R.string.tts_speed_short, "%.1f".format(rate)), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp))
-                                Slider(
-                                    value = rate,
-                                    onValueChange = {
-                                        rate = it; if (!isDraggingRate && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
-                                        isDraggingRate = true; ttsController.pause()
+                        Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+                            Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+                                Row(
+                                    modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
+                                    horizontalArrangement = Arrangement.SpaceBetween,
+                                    verticalAlignment = Alignment.CenterVertically
+                                ) {
+                                    Text(
+                                        stringResource(R.string.tts_speed_short, "%.1f".format(rate)),
+                                        style = MaterialTheme.typography.labelMedium
+                                    )
+                                    IconButton(onClick = { rate = 1.0f; saveAndApply() }, modifier = Modifier.size(24.dp)) {
+                                        Icon(Icons.Default.Refresh, stringResource(R.string.content_desc_reset_speed), modifier = Modifier.size(16.dp))
+                                    }
+                                }
+                                Row(verticalAlignment = Alignment.CenterVertically) {
+                                    IconButton(
+                                        onClick = { rate = ((rate * 10f).roundToInt() / 10f - 0.1f).coerceAtLeast(0.5f); saveAndApply() },
+                                        modifier = Modifier.size(32.dp)
+                                    ) {
+                                        Icon(Icons.Default.Remove, contentDescription = null, modifier = Modifier.size(18.dp))
+                                    }
+                                    Slider(
+                                        value = rate,
+                                        onValueChange = {
+                                            rate = it; if (!isDraggingRate && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
+                                            isDraggingRate = true; ttsController.pause()
+                                        }
+                                        },
+                                        onValueChangeFinished = { isDraggingRate = false; saveAndApply() },
+                                        valueRange = 0.5f..3.0f,
+                                        modifier = Modifier.weight(1f).height(20.dp),
+                                        thumb = {
+                                            Box(
+                                                modifier = Modifier
+                                                    .size(14.dp)
+                                                    .background(MaterialTheme.colorScheme.primary, CircleShape)
+                                            )
+                                        },
+                                        track = { sliderState ->
+                                            val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
+                                            val fraction = if (range == 0f) 0f else {
+                                                ((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
+                                            }
+                                            Box(
+                                                modifier = Modifier
+                                                    .fillMaxWidth()
+                                                    .height(2.dp)
+                                                    .background(
+                                                        MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.22f),
+                                                        RoundedCornerShape(1.dp)
+                                                    ),
+                                                contentAlignment = Alignment.CenterStart
+                                            ) {
+                                                Box(
+                                                    modifier = Modifier
+                                                        .fillMaxWidth(fraction)
+                                                        .height(2.dp)
+                                                        .background(
+                                                            MaterialTheme.colorScheme.primary,
+                                                            RoundedCornerShape(1.dp)
+                                                        )
+                                                )
+                                            }
+                                        }
+                                    )
+                                    IconButton(
+                                        onClick = { rate = ((rate * 10f).roundToInt() / 10f + 0.1f).coerceAtMost(3.0f); saveAndApply() },
+                                        modifier = Modifier.size(32.dp)
+                                    ) {
+                                        Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
                                     }
-                                    },
-                                    onValueChangeFinished = { isDraggingRate = false; saveAndApply() },
-                                    valueRange = 0.5f..3.0f,
-                                    steps = 24,
-                                    modifier = Modifier.weight(1f).height(24.dp)
-                                )
-                                IconButton(onClick = { rate = 1.0f; saveAndApply() }, modifier = Modifier.size(32.dp)) {
-                                    Icon(Icons.Default.Refresh, stringResource(R.string.content_desc_reset_speed), modifier = Modifier.size(16.dp))
                                 }
                             }
-                            Row(verticalAlignment = Alignment.CenterVertically) {
-                                Text(stringResource(R.string.tts_pitch_short, "%.1f".format(pitch)), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp))
-                                Slider(
-                                    value = pitch,
-                                    onValueChange = {
-                                        pitch = it; if (!isDraggingPitch && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
-                                        isDraggingPitch = true; ttsController.pause()
+                            Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+                                Row(
+                                    modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
+                                    horizontalArrangement = Arrangement.SpaceBetween,
+                                    verticalAlignment = Alignment.CenterVertically
+                                ) {
+                                    Text(
+                                        stringResource(R.string.tts_pitch_short, "%.1f".format(pitch)),
+                                        style = MaterialTheme.typography.labelMedium
+                                    )
+                                    IconButton(onClick = { pitch = 1.0f; saveAndApply() }, modifier = Modifier.size(24.dp)) {
+                                        Icon(Icons.Default.Refresh, stringResource(R.string.content_desc_reset_pitch), modifier = Modifier.size(16.dp))
+                                    }
+                                }
+                                Row(verticalAlignment = Alignment.CenterVertically) {
+                                    IconButton(
+                                        onClick = { pitch = ((pitch * 10f).roundToInt() / 10f - 0.1f).coerceAtLeast(0.5f); saveAndApply() },
+                                        modifier = Modifier.size(32.dp)
+                                    ) {
+                                        Icon(Icons.Default.Remove, contentDescription = null, modifier = Modifier.size(18.dp))
+                                    }
+                                    Slider(
+                                        value = pitch,
+                                        onValueChange = {
+                                            pitch = it; if (!isDraggingPitch && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
+                                            isDraggingPitch = true; ttsController.pause()
+                                        }
+                                        },
+                                        onValueChangeFinished = { isDraggingPitch = false; saveAndApply() },
+                                        valueRange = 0.5f..2.0f,
+                                        modifier = Modifier.weight(1f).height(20.dp),
+                                        thumb = {
+                                            Box(
+                                                modifier = Modifier
+                                                    .size(14.dp)
+                                                    .background(MaterialTheme.colorScheme.primary, CircleShape)
+                                            )
+                                        },
+                                        track = { sliderState ->
+                                            val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
+                                            val fraction = if (range == 0f) 0f else {
+                                                ((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
+                                            }
+                                            Box(
+                                                modifier = Modifier
+                                                    .fillMaxWidth()
+                                                    .height(2.dp)
+                                                    .background(
+                                                        MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.22f),
+                                                        RoundedCornerShape(1.dp)
+                                                    ),
+                                                contentAlignment = Alignment.CenterStart
+                                            ) {
+                                                Box(
+                                                    modifier = Modifier
+                                                        .fillMaxWidth(fraction)
+                                                        .height(2.dp)
+                                                        .background(
+                                                            MaterialTheme.colorScheme.primary,
+                                                            RoundedCornerShape(1.dp)
+                                                        )
+                                                )
+                                            }
+                                        }
+                                    )
+                                    IconButton(
+                                        onClick = { pitch = ((pitch * 10f).roundToInt() / 10f + 0.1f).coerceAtMost(2.0f); saveAndApply() },
+                                        modifier = Modifier.size(32.dp)
+                                    ) {
+                                        Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
                                     }
-                                    },
-                                    onValueChangeFinished = { isDraggingPitch = false; saveAndApply() },
-                                    valueRange = 0.5f..2.0f,
-                                    steps = 14,
-                                    modifier = Modifier.weight(1f).height(24.dp)
-                                )
-                                IconButton(onClick = { pitch = 1.0f; saveAndApply() }, modifier = Modifier.size(32.dp)) {
-                                    Icon(Icons.Default.Refresh, stringResource(R.string.content_desc_reset_pitch), modifier = Modifier.size(16.dp))
                                 }
                             }
                         }
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt
index b9b7031..ed92a6f 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt
@@ -123,7 +123,9 @@ fun VerticalScrollbar(
 
             if (viewportRatio >= 1f) return@derivedStateOf null
 
-            val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
+            val maxThumbHeight = viewportHeight / 2f
+            val minThumbHeight = minOf(80f, maxThumbHeight)
+            val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
 
             val firstItemIndex = listState.firstVisibleItemIndex
             val firstItemOffset = listState.firstVisibleItemScrollOffset
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
index 8651533..df01d07 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
@@ -19,7 +19,7 @@
  */
 // EpubReaderScreen.kt
 @file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
-    "UnusedVariable", "Unused", "SimplifyBooleanWithConstants"
+    "UnusedVariable", "Unused", "SimplifyBooleanWithConstants", "KotlinConstantConditions"
 )
 
 package com.aryan.reader.epubreader
@@ -35,6 +35,8 @@ import android.graphics.Bitmap
 import android.media.AudioManager
 import android.net.Uri
 import android.os.Build
+import android.view.RoundedCorner
+import android.view.View
 import android.webkit.WebView
 import android.widget.Toast
 import androidx.activity.compose.BackHandler
@@ -119,9 +121,14 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.BiasAlignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.drawBehind
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.BlendMode
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageShader
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.graphics.TileMode
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalContext
@@ -134,6 +141,7 @@ import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.core.content.ContextCompat
 import androidx.core.content.edit
@@ -158,12 +166,17 @@ import com.aryan.reader.SearchResult
 import com.aryan.reader.SummarizationResult
 import com.aryan.reader.SummaryCacheManager
 import com.aryan.reader.TtsSettingsSheet
+import com.aryan.reader.areReaderAiFeaturesEnabled
 import com.aryan.reader.countWords
+import com.aryan.reader.isByokCloudTtsAvailable
 import com.aryan.reader.data.CustomFontEntity
 import com.aryan.reader.epub.EpubBook
+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.loadReaderThemeId
+import com.aryan.reader.loadReaderTextureBitmap
 import com.aryan.reader.paginatedreader.BookPaginator
 import com.aryan.reader.paginatedreader.CfiUtils
 import com.aryan.reader.paginatedreader.HeaderBlock
@@ -180,11 +193,11 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase
 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.saveReaderThemeId
 import com.aryan.reader.tts.SpeakerSamplePlayer
 import com.aryan.reader.tts.TtsPlaybackManager
 import com.aryan.reader.tts.loadTtsMode
-import com.aryan.reader.tts.rememberTtsController
 import com.aryan.reader.tts.splitTextIntoChunks
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
@@ -218,12 +231,50 @@ private const val AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_"
 private const val MUSICIAN_MODE_KEY = "musician_mode_enabled"
 private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled"
 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 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"
 
 private const val TAG_LINK_NAV = "LINK_NAV"
 
+private fun View.bottomRoundedCornerRadiusPx(): Int {
+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
+
+    val insets = rootWindowInsets ?: return 0
+    return max(
+        insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)?.radius ?: 0,
+        insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)?.radius ?: 0
+    )
+}
+
+@Composable
+private fun rememberBottomRoundedCornerPadding(view: View): Dp {
+    val density = LocalDensity.current
+    val configuration = LocalConfiguration.current
+    var radiusPx by remember(view) { mutableIntStateOf(view.bottomRoundedCornerRadiusPx()) }
+
+    DisposableEffect(
+        view,
+        configuration.orientation,
+        configuration.screenWidthDp,
+        configuration.screenHeightDp
+    ) {
+        val listener = View.OnLayoutChangeListener { updatedView, _, _, _, _, _, _, _, _ ->
+            radiusPx = updatedView.bottomRoundedCornerRadiusPx()
+        }
+        view.addOnLayoutChangeListener(listener)
+        radiusPx = view.bottomRoundedCornerRadiusPx()
+
+        onDispose {
+            view.removeOnLayoutChangeListener(listener)
+        }
+    }
+
+    return with(density) { radiusPx.toDp() }
+}
+
 private fun saveHiddenTools(context: Context, hiddenTools: Set) {
     val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
     prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) }
@@ -234,6 +285,34 @@ private fun loadHiddenTools(context: Context): Set {
     return prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
 }
 
+private fun saveToolOrder(context: Context, toolOrder: List) {
+    val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+    prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
+}
+
+private fun loadToolOrder(context: Context): List {
+    val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+    val savedTools = prefs.getString(TOOL_ORDER_KEY, null)
+        ?.split(',')
+        ?.filter { it.isNotBlank() }
+        ?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } }
+        .orEmpty()
+    return (savedTools + ReaderTool.entries.filterNot { it in savedTools }).distinct()
+}
+
+private fun saveBottomTools(context: Context, bottomTools: Set) {
+    val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+    prefs.edit { putStringSet(BOTTOM_TOOLS_KEY, bottomTools) }
+}
+
+private fun loadBottomTools(context: Context): Set {
+    val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+    return prefs.getStringSet(
+        BOTTOM_TOOLS_KEY,
+        ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
+    ) ?: ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
+}
+
 private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
     val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
     prefs.edit { putBoolean(KEEP_SCREEN_ON_KEY, isEnabled) }
@@ -338,7 +417,7 @@ private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
 private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
 
 private fun loadUseOnlineDict(context: Context): Boolean {
-    @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
+    @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) return false
     val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
     return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
 }
@@ -381,6 +460,7 @@ private fun saveExternalSearchPackage(context: Context, packageName: String) {
 const val PREF_READER_THEME = "reader_theme_id"
 const val PREF_CUSTOM_THEMES = "custom_themes_json"
 
+@UnstableApi
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 @Composable
 fun EpubReaderScreen(
@@ -416,8 +496,8 @@ fun EpubReaderScreen(
         }
     } else null
 
-    val hasValidExtractionBasePath = remember(epubBook.extractionBasePath) {
-        epubBook.extractionBasePath.isNotBlank() && File(epubBook.extractionBasePath).exists()
+    val hasValidExtractionBasePath = remember(epubBook.extractionBasePath, epubBook.chapters) {
+        epubBook.hasReadableExtractedContent()
     }
     var requestedContentRecovery by remember(epubBook.extractionBasePath, uiState.selectedBookId) {
         mutableStateOf(false)
@@ -563,6 +643,7 @@ fun EpubReaderHost(
 
     var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
     var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
+    var pageInfoPosition by remember { mutableStateOf(loadPageInfoPosition(context)) }
     var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
     var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) }
     var showVisualOptionsSheet by remember { mutableStateOf(false) }
@@ -582,7 +663,7 @@ fun EpubReaderHost(
     var currentTtsMode by remember {
         mutableStateOf(
             loadTtsMode(context).let {
-                if (BuildConfig.FLAVOR == "oss") TtsPlaybackManager.TtsMode.BASE else it
+                if (BuildConfig.FLAVOR == "oss" && !isByokCloudTtsAvailable(context)) TtsPlaybackManager.TtsMode.BASE else it
             }
         )
     }
@@ -738,18 +819,19 @@ fun EpubReaderHost(
     }
 
     var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) }
+    var toolOrder by remember { mutableStateOf(loadToolOrder(context)) }
+    var bottomTools by remember { mutableStateOf(loadBottomTools(context)) }
     var showCustomizeToolsSheet by remember { mutableStateOf(false) }
 
     var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
     var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
 
     @Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String ->
-        val isOss = BuildConfig.FLAVOR == "oss"
-        val effectiveUseOnline = !isOss && useOnlineDictionary
+        val effectiveUseOnline = areReaderAiFeaturesEnabled(context) && useOnlineDictionary
 
         if (effectiveUseOnline) {
             val wordCount = countWords(word)
-            if (wordCount > 1 && !isProUser) {
+            if (BuildConfig.FLAVOR != "oss" && wordCount > 1 && !isProUser) {
                 showDictionaryUpsellDialog = true
             } else {
                 selectedTextForAi = word
@@ -815,6 +897,8 @@ fun EpubReaderHost(
     var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
 
     val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+    val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
+    val pageInfoCornerBottomPadding = roundedCornerBottomPadding.coerceAtMost(8.dp)
 
     var bookmarks by remember(epubBook.title) {
         mutableStateOf(
@@ -991,6 +1075,7 @@ fun EpubReaderHost(
     var currentParagraphGap by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.paragraphGap) }
     var currentImageSize by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.imageSize) }
     var currentHorizontalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.horizontalMargin) }
+    var currentVerticalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.verticalMargin) }
     var currentTextAlign by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.textAlign) }
     var currentFontFamily by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.font) }
     var currentCustomFontPath by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.customPath) }
@@ -1006,14 +1091,14 @@ fun EpubReaderHost(
     var showFontSelectionSheet by remember { mutableStateOf(false) }
     val fontSheetState = rememberModalBottomSheetState()
 
-    LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
+    LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
         if (isFormatLocal) {
             saveLocalReaderSettings(
-                context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
+                context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
             )
         } else {
             saveReaderSettings(
-                context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
+                context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
             )
         }
     }
@@ -1130,6 +1215,7 @@ fun EpubReaderHost(
 
     var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
     var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
+    var globalTextureTransparency by remember { mutableFloatStateOf(loadGlobalTextureTransparency(context)) }
 
     val activeTheme = remember(currentThemeId, customThemes) {
         BuiltInThemes.find { it.id == currentThemeId }
@@ -1151,6 +1237,19 @@ fun EpubReaderHost(
         } else activeTheme.textColor
     }
     val activeTextureId = activeTheme.textureId
+    val activeTextureAlpha = 1f - globalTextureTransparency
+    val activeTextureBitmap = remember(activeTextureId) {
+        loadReaderTextureBitmap(context, activeTextureId)
+    }
+    val activeTextureModifier = activeTextureBitmap?.let { bitmap ->
+        Modifier.drawBehind {
+            drawRect(
+                brush = ShaderBrush(ImageShader(bitmap, TileMode.Repeated, TileMode.Repeated)),
+                blendMode = BlendMode.SrcOver,
+                alpha = activeTextureAlpha.coerceIn(0f, 1f)
+            )
+        }
+    } ?: Modifier
 
     val infoBarBgColor = remember(effectiveBg, isDarkTheme) {
         val overlayAlpha = if (isDarkTheme) 0.08f else 0.06f
@@ -1401,7 +1500,7 @@ fun EpubReaderHost(
     }
 
     fun startTts() {
-        if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
+        if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
             showInsufficientCreditsDialog = true
             return
         }
@@ -1441,6 +1540,7 @@ fun EpubReaderHost(
                                 chapterTitle = chapterTitle,
                                 coverImageUri = coverUriString,
                                 chapterIndex = chapterIndex,
+                                totalChapters = chapters.size,
                                 ttsMode = currentTtsMode,
                                 playbackSource = "READER",
                                 authToken = token
@@ -1460,7 +1560,7 @@ fun EpubReaderHost(
     )
 
     fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
-        if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
+        if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
             showInsufficientCreditsDialog = true
             return
         }
@@ -1504,6 +1604,7 @@ fun EpubReaderHost(
                             chapterTitle = chapterTitle,
                             coverImageUri = coverUriString,
                             chapterIndex = chapterIndex,
+                            totalChapters = chapters.size,
                             ttsMode = currentTtsMode,
                             playbackSource = "READER",
                             authToken = token
@@ -1582,18 +1683,6 @@ fun EpubReaderHost(
         focusRequester = searchFocusRequester
     )
 
-    if (epubBook.extractionBasePath.isBlank() || !File(epubBook.extractionBasePath).exists()) {
-        Timber.e("Extraction base path is blank or does not exist: ${epubBook.extractionBasePath}"
-        )
-        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
-            Text(
-                "Error: Book content not found. Path: ${epubBook.extractionBasePath}",
-                color = MaterialTheme.colorScheme.error
-            )
-        }
-        return
-    }
-
     val totalPagesInCurrentChapter = remember(currentScrollHeightValue, currentClientHeightValue) {
         if (currentClientHeightValue > 0) {
             max(
@@ -1989,10 +2078,7 @@ fun EpubReaderHost(
         }
     }
 
-    val pageInfoBottomPadding by animateDpAsState(
-        targetValue = if (showBars && pageInfoMode == PageInfoMode.SYNC) 45.dp else 0.dp,
-        label = "PageInfoBottomPadding"
-    )
+    val pageInfoBarHeight = PAGE_INFO_BAR_HEIGHT + pageInfoCornerBottomPadding
 
     val isPageInfoVisible = when (pageInfoMode) {
         PageInfoMode.DEFAULT -> !showBars
@@ -2631,7 +2717,7 @@ fun EpubReaderHost(
         }
 
         val handleGenerateSummary: (Boolean) -> Unit = { force ->
-            if (!isProUser && credits <= 0) {
+            if (BuildConfig.FLAVOR != "oss" && !isProUser && credits <= 0) {
                 showInsufficientCreditsDialog = true
                 showAiHubSheet = false
             } else {
@@ -2688,6 +2774,7 @@ fun EpubReaderHost(
                                     val finalSummaryBuilder = StringBuilder()
                                     summarizeBookContent(
                                         content = text,
+                                        context = context,
                                         authToken = token,
                                         onUsageReceived = { cost, freeRemaining ->
                                             currentCost = cost
@@ -2750,7 +2837,7 @@ fun EpubReaderHost(
         }
 
         val handleGenerateRecap: () -> Unit = {
-            if (credits <= 0) {
+            if (BuildConfig.FLAVOR != "oss" && credits <= 0) {
                 showInsufficientCreditsDialog = true
                 showAiHubSheet = false
             } else {
@@ -2820,6 +2907,7 @@ fun EpubReaderHost(
                 modifier = Modifier
                     .fillMaxSize()
                     .background(effectiveBg)
+                    .then(activeTextureModifier)
                     .padding(top = effectiveTopPadding)
                     .focusRequester(containerFocusRequester)
                     .focusable()
@@ -2879,13 +2967,15 @@ fun EpubReaderHost(
             ) {
                 when (currentRenderMode) {
                     RenderMode.VERTICAL_SCROLL -> {
-                        val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp
+                        val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp
+                        val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp
+                        val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp
 
                         Box(
                             modifier = Modifier
                                 .fillMaxSize()
+                                .padding(top = contentTopPadding)
                                 .padding(bottom = contentBottomPadding)
-                                .padding(top = 16.dp)
                                 .testTag("ReaderContainer")
                         ) {
                             if (chapters.isEmpty()) {
@@ -3292,10 +3382,12 @@ fun EpubReaderHost(
                                             currentParagraphGap = currentParagraphGap,
                                             currentImageSize = currentImageSize,
                                             currentHorizontalMargin = currentHorizontalMargin,
+                                            currentVerticalMargin = currentVerticalMargin,
                                             currentFontFamily = currentFontFamily,
                                             customFontPath = currentCustomFontPath,
                                             currentTextAlign = currentTextAlign,
                                             activeTextureId = activeTextureId,
+                                            activeTextureAlpha = activeTextureAlpha,
                                             onHighlightClicked = {
                                                 lastHighlightClickTime = System.currentTimeMillis()
                                                 showBars = false
@@ -3462,7 +3554,7 @@ fun EpubReaderHost(
 
                                                     if (ttsChunks.isNotEmpty()) {
                                                         logTtsChapterDiag("Vertical TTS extraction produced ${ttsChunks.size} chunks for chapter $targetChapterIndex")
-                                                        if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
+                                                        if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
                                                             showInsufficientCreditsDialog = true
                                                             ttsShouldStartOnChapterLoad = false
                                                             return@launch
@@ -3483,6 +3575,7 @@ fun EpubReaderHost(
                                                             chapterTitle = chapterTitle,
                                                             coverImageUri = coverUriString,
                                                             chapterIndex = targetChapterIndex,
+                                                            totalChapters = chapters.size,
                                                             ttsMode = currentTtsMode,
                                                             playbackSource = "READER",
                                                             authToken = token
@@ -3528,6 +3621,7 @@ fun EpubReaderHost(
 
                                                     summarizeBookContent(
                                                         content = content,
+                                                        context = context,
                                                         authToken = token,
                                                         onUsageReceived = { cost: Double?, freeRemaining: Int? ->
                                                             currentCost = cost
@@ -3779,11 +3873,14 @@ fun EpubReaderHost(
                     }
 
                     RenderMode.PAGINATED -> {
-                        val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp
+                        val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp
+                        val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp
+                        val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp
 
                         BoxWithConstraints(
                             modifier = Modifier
                                 .fillMaxSize()
+                                .padding(top = contentTopPadding)
                                 .padding(bottom = contentBottomPadding)
                                 .testTag("ReaderContainer")
                         ) {
@@ -3799,6 +3896,7 @@ fun EpubReaderHost(
                                 paragraphGapMultiplier = currentParagraphGap,
                                 imageSizeMultiplier = currentImageSize,
                                 horizontalMarginMultiplier = currentHorizontalMargin,
+                                verticalMarginMultiplier = currentVerticalMargin,
                                 fontFamily = activeFontFamily,
                                 textAlign = currentTextAlign,
                                 activeHighlightPalette = currentHighlightPalette,
@@ -3810,6 +3908,7 @@ fun EpubReaderHost(
                                     offset = ttsState.startOffsetInSource
                                 ).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 },
                                 activeTextureId = activeTextureId,
+                                activeTextureAlpha = activeTextureAlpha,
                                 initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
                                 modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
                                 onPaginatorReady = { newPaginator ->
@@ -4094,17 +4193,19 @@ fun EpubReaderHost(
 
                 // Page Info Bar (Vertical)
                 AnimatedVisibility(
-                    visible = renderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
+                    visible = currentRenderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
                     enter = fadeIn(animationSpec = tween(200)),
                     exit = fadeOut(animationSpec = tween(200)),
-                    modifier = Modifier.align(Alignment.BottomCenter)
+                    modifier = Modifier.align(
+                        if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter
+                    )
                 ) {
                     Box(
                         modifier = Modifier
                             .fillMaxWidth()
-                            .height(PAGE_INFO_BAR_HEIGHT)
+                            .height(pageInfoBarHeight)
                             .background(infoBarBgColor)
-                            .padding(bottom = bottomPadding + pageInfoBottomPadding)
+                            .then(activeTextureModifier)
                             .padding(horizontal = 16.dp),
                         contentAlignment = Alignment.Center
                     ) {
@@ -4140,17 +4241,19 @@ fun EpubReaderHost(
 
                 // Page Info Bar (Paginated)
                 AnimatedVisibility(
-                    visible = renderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0,
+                    visible = currentRenderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0,
                     enter = fadeIn(animationSpec = tween(200)),
                     exit = fadeOut(animationSpec = tween(200)),
-                    modifier = Modifier.align(Alignment.BottomCenter)
+                    modifier = Modifier.align(
+                        if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter
+                    )
                 ) {
                     Box(
                         modifier = Modifier
                             .fillMaxWidth()
-                            .height(PAGE_INFO_BAR_HEIGHT)
+                            .height(pageInfoBarHeight)
                             .background(infoBarBgColor)
-                            .padding(bottom = bottomPadding + pageInfoBottomPadding)
+                            .then(activeTextureModifier)
                             .padding(horizontal = 16.dp),
                         contentAlignment = Alignment.Center
                     ) {
@@ -4442,6 +4545,8 @@ fun EpubReaderHost(
                     volumeScrollEnabled = volumeScrollEnabled,
                     isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
                     hiddenTools = hiddenTools,
+                    toolOrder = toolOrder,
+                    bottomTools = bottomTools,
                     onCustomizeTools = { showCustomizeToolsSheet = true },
                     onNavigateBack = { triggerSaveAndExit() },
                     isKeepScreenOn = isKeepScreenOn,
@@ -4522,6 +4627,71 @@ fun EpubReaderHost(
                     onOpenDictionarySettings = { showDictionarySettingsSheet = true },
                     onOpenThemeSettings = { showThemePanel = true },
                     onOpenVisualOptions = { showVisualOptionsSheet = 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 {
+                                    bannerMessage = BannerMessage("Book is not paginated yet.")
+                                }
+                            }
+                        }
+                    },
+                    onOpenDrawer = {
+                        scope.launch { drawerState.open() }
+                    },
+                    onToggleFormat = {
+                        showFormatAdjustmentBars = !showFormatAdjustmentBars
+                        if (showFormatAdjustmentBars) {
+                            searchState.showSearchResultsPanel = false
+                            isPageSliderVisible = false
+                        }
+                    },
+                    onToggleSearch = {
+                        searchState.isSearchActive = true
+                        searchState.showSearchResultsPanel = true
+                        showBars = true
+                        showFormatAdjustmentBars = false
+                    },
+                    onToggleTts = {
+                        if (isTtsSessionActive) {
+                            Timber.d("TTS button clicked: Stopping TTS")
+                            userStoppedTts = true
+                            ttsController.stop()
+                        } else {
+                            when {
+                                ContextCompat.checkSelfPermission(
+                                    context,
+                                    Manifest.permission.POST_NOTIFICATIONS
+                                ) == PackageManager.PERMISSION_GRANTED -> {
+                                    startTts()
+                                }
+                                activity?.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) == true -> {
+                                    showPermissionRationaleDialog = true
+                                }
+                                else -> {
+                                    permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+                                }
+                            }
+                        }
+                    },
                     onToggleReflow = if (onToggleReflow != null) {
                         {
                             val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
@@ -4687,8 +4857,12 @@ fun EpubReaderHost(
                     ttsState = ttsState,
                     isProUser = isProUser,
                     hiddenTools = hiddenTools,
+                    toolOrder = toolOrder,
+                    bottomTools = bottomTools,
                     currentTtsMode = currentTtsMode,
                     onOpenAiHub = { showAiHubSheet = true },
+                    onOpenDictionarySettings = { showDictionarySettingsSheet = true },
+                    onOpenThemeSettings = { showThemePanel = true },
                     onOpenSlider = {
                         when (currentRenderMode) {
                             RenderMode.VERTICAL_SCROLL -> {
@@ -4770,6 +4944,8 @@ fun EpubReaderHost(
                     onImageSizeChange = { currentImageSize = it },
                     currentHorizontalMargin = currentHorizontalMargin,
                     onHorizontalMarginChange = { currentHorizontalMargin = it },
+                    currentVerticalMargin = currentVerticalMargin,
+                    onVerticalMarginChange = { currentVerticalMargin = it },
                     currentFont = currentFontFamily,
                     currentCustomFontName = if(currentCustomFontPath != null) {
                         customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font"
@@ -4788,6 +4964,7 @@ fun EpubReaderHost(
                         currentParagraphGap = DEFAULT_PARAGRAPH_GAP_VAL
                         currentImageSize = DEFAULT_IMAGE_SIZE_VAL
                         currentHorizontalMargin = DEFAULT_HORIZONTAL_MARGIN_VAL
+                        currentVerticalMargin = DEFAULT_VERTICAL_MARGIN_VAL
                         currentFontFamily = ReaderFont.ORIGINAL
                         currentCustomFontPath = null
                         currentTextAlign = ReaderTextAlign.DEFAULT
@@ -5085,10 +5262,20 @@ fun EpubReaderHost(
         if (showCustomizeToolsSheet) {
             CustomizeToolsSheet(
                 hiddenTools = hiddenTools,
+                toolOrder = toolOrder,
+                bottomTools = bottomTools,
                 onUpdate = { newHiddenSet ->
                     hiddenTools = newHiddenSet
                     saveHiddenTools(context, newHiddenSet)
                 },
+                onOrderUpdate = { newOrder ->
+                    toolOrder = newOrder
+                    saveToolOrder(context, newOrder)
+                },
+                onPlacementUpdate = { newBottomTools ->
+                    bottomTools = newBottomTools
+                    saveBottomTools(context, newBottomTools)
+                },
                 onDismiss = { showCustomizeToolsSheet = false }
             )
         }
@@ -5133,6 +5320,11 @@ fun EpubReaderHost(
                     pageInfoMode = it
                     savePageInfoMode(context, it)
                 },
+                pageInfoPosition = pageInfoPosition,
+                onPageInfoPositionChange = {
+                    pageInfoPosition = it
+                    savePageInfoPosition(context, it)
+                },
                 pullToTurnEnabled = pullToTurnEnabled,
                 onPullToTurnChange = {
                     pullToTurnEnabled = it
@@ -5173,6 +5365,11 @@ fun EpubReaderHost(
             ReaderThemePanel(
                 isVisible = true,
                 currentThemeId = currentThemeId,
+                globalTextureTransparency = globalTextureTransparency,
+                onGlobalTextureTransparencyChange = {
+                    globalTextureTransparency = it
+                    saveGlobalTextureTransparency(context, it)
+                },
                 onThemeSelected = {
                     currentThemeId = it
                     saveReaderThemeId(context, it)
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt
index 80fcbcf..bad04ba 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt
@@ -120,6 +120,7 @@ private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
 private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled"
 private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode"
 private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
+private const val PAGE_INFO_POSITION_KEY = "reader_page_info_position"
 private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
 
 const val DEFAULT_FONT_SIZE_VAL = 1.0f
@@ -127,6 +128,7 @@ const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
 const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
 const val DEFAULT_IMAGE_SIZE_VAL = 1.0f
 const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f
+const val DEFAULT_VERTICAL_MARGIN_VAL = 1.0f
 private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
 private const val TTS_PITCH_KEY = "tts_pitch"
 
@@ -177,12 +179,18 @@ enum class PageInfoMode(val id: Int, val title: String) {
     HIDDEN(2, "Always Hide")
 }
 
+enum class PageInfoPosition(val id: Int, val title: String) {
+    BOTTOM(0, "Bottom"),
+    TOP(1, "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
@@ -194,9 +202,11 @@ private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_"
 private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_"
 private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_"
 private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_"
+private const val LOCAL_VERTICAL_MARGIN_PREFIX = "local_vertical_margin_"
 private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_"
 private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_"
 private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin"
+private const val VERTICAL_MARGIN_KEY = "reader_vertical_margin"
 
 fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
     val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@@ -216,6 +226,7 @@ fun saveLocalReaderSettings(
     paragraphGap: Float,
     imageSize: Float,
     horizontalMargin: Float,
+    verticalMargin: Float,
     fontFamily: ReaderFont,
     customFontPath: String?,
     textAlign: ReaderTextAlign
@@ -227,6 +238,7 @@ fun saveLocalReaderSettings(
         putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
         putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
         putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
+        putFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, verticalMargin)
         if (customFontPath != null) {
             putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
         } else {
@@ -258,6 +270,17 @@ fun loadPageInfoMode(context: Context): PageInfoMode {
     return PageInfoMode.entries.find { it.id == id } ?: PageInfoMode.DEFAULT
 }
 
+fun savePageInfoPosition(context: Context, position: PageInfoPosition) {
+    val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+    prefs.edit { putInt(PAGE_INFO_POSITION_KEY, position.id) }
+}
+
+fun loadPageInfoPosition(context: Context): PageInfoPosition {
+    val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+    val id = prefs.getInt(PAGE_INFO_POSITION_KEY, PageInfoPosition.BOTTOM.id)
+    return PageInfoPosition.entries.find { it.id == id } ?: PageInfoPosition.BOTTOM
+}
+
 fun savePullToTurn(context: Context, enabled: Boolean) {
     val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
     prefs.edit { putBoolean(PULL_TO_TURN_ENABLED_KEY, enabled) }
@@ -288,6 +311,11 @@ fun loadHorizontalMargin(context: Context): Float {
     return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL
 }
 
+fun loadVerticalMargin(context: Context): Float {
+    val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+    return prefs.getFloat(VERTICAL_MARGIN_KEY, DEFAULT_VERTICAL_MARGIN_VAL)
+}
+
 fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
     val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
 
@@ -321,6 +349,12 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
         loadHorizontalMargin(context)
     }
 
+    val verticalMargin = if (isLocal && prefs.contains(LOCAL_VERTICAL_MARGIN_PREFIX + bookId)) {
+        prefs.getFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, DEFAULT_VERTICAL_MARGIN_VAL)
+    } else {
+        loadVerticalMargin(context)
+    }
+
     val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) {
         prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
     } else {
@@ -346,6 +380,7 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
         paragraphGap = paragraphGap,
         imageSize = imageSize,
         horizontalMargin = horizontalMargin,
+        verticalMargin = verticalMargin,
         font = font,
         customPath = customPath,
         textAlign = textAlign
@@ -390,6 +425,7 @@ fun saveReaderSettings(
     paragraphGap: Float,
     imageSize: Float,
     horizontalMargin: Float,
+    verticalMargin: Float,
     fontFamily: ReaderFont,
     customFontPath: String?,
     textAlign: ReaderTextAlign
@@ -401,6 +437,7 @@ fun saveReaderSettings(
         putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
         putFloat(IMAGE_SIZE_KEY, imageSize)
         putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
+        putFloat(VERTICAL_MARGIN_KEY, verticalMargin)
         if (customFontPath != null) {
             putString(FONT_FAMILY_KEY, "custom|$customFontPath")
         } else {
@@ -454,6 +491,8 @@ fun ReaderTextFormatPanel(
     onImageSizeChange: (Float) -> Unit,
     currentHorizontalMargin: Float,
     onHorizontalMarginChange: (Float) -> Unit,
+    currentVerticalMargin: Float,
+    onVerticalMarginChange: (Float) -> Unit,
     currentFont: ReaderFont,
     currentCustomFontName: String?,
     onFontOptionClick: () -> Unit,
@@ -699,6 +738,20 @@ fun ReaderTextFormatPanel(
                             }
                         }
                     )
+
+                    FormatSlider(
+                        label = stringResource(R.string.label_vertical_margin),
+                        value = currentVerticalMargin,
+                        onValueChange = onVerticalMarginChange,
+                        valueRange = 0.0f..3.0f,
+                        formatValue = {
+                            when {
+                                it <= 0.01f -> noneLabel
+                                it in 0.99f..1.01f -> originalLabel
+                                else -> "%.1fx".format(it)
+                            }
+                        }
+                    )
                 }
             }
         }
@@ -830,6 +883,8 @@ fun VisualOptionsSheet(
     onSystemUiModeChange: (SystemUiMode) -> Unit,
     pageInfoMode: PageInfoMode,
     onPageInfoModeChange: (PageInfoMode) -> Unit,
+    pageInfoPosition: PageInfoPosition,
+    onPageInfoPositionChange: (PageInfoPosition) -> Unit,
     pullToTurnEnabled: Boolean,
     onPullToTurnChange: (Boolean) -> Unit,
     pullToTurnMultiplier: Float,
@@ -884,6 +939,16 @@ fun VisualOptionsSheet(
                 getLabel = { it.title }
             )
 
+            Spacer(modifier = Modifier.height(16.dp))
+            Text(stringResource(R.string.visual_options_progress_bar_position), style = MaterialTheme.typography.titleSmall)
+            Spacer(modifier = Modifier.height(8.dp))
+            OptionSegmentedControl(
+                options = PageInfoPosition.entries,
+                selectedOption = pageInfoPosition,
+                onOptionSelected = onPageInfoPositionChange,
+                getLabel = { it.title }
+            )
+
             Spacer(modifier = Modifier.height(24.dp))
 
             // Pull to change chapter
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt
index 0164118..42ce113 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSystem.kt
@@ -20,6 +20,7 @@
 package com.aryan.reader.epubreader
 
 import timber.log.Timber
+import android.graphics.Color
 import android.view.KeyEvent
 import android.view.View
 import android.view.Window
@@ -51,14 +52,20 @@ fun EpubReaderSystemUiController(
             return@DisposableEffect onDispose {}
         }
         val insetsController = WindowCompat.getInsetsController(window, view)
+        val originalStatusBarColor = window.statusBarColor
+        val originalNavigationBarColor = window.navigationBarColor
         Timber.d("Applying immersive mode.")
 
         WindowCompat.setDecorFitsSystemWindows(window, false)
+        window.statusBarColor = Color.TRANSPARENT
+        window.navigationBarColor = Color.TRANSPARENT
         insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
 
         onDispose {
             Timber.d("Restoring system UI.")
             WindowCompat.setDecorFitsSystemWindows(window, true)
+            window.statusBarColor = originalStatusBarColor
+            window.navigationBarColor = originalNavigationBarColor
             insetsController.show(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.statusBars())
             insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars
             insetsController.systemBarsBehavior = initialSystemBarsBehavior
@@ -142,4 +149,4 @@ fun Modifier.volumeScrollHandler(
         }
     }
     true
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
index 0c5082d..6808cf3 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
@@ -328,6 +328,8 @@ private fun handleVerticalAutoAdvance(
                         chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
                         coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
                         chapterIndex = currentTtsChapterIndex,
+                        totalChapters = chapters.size,
+                        continueSession = true,
                         ttsMode = currentTtsMode,
                         playbackSource = "READER",
                         authToken = token
@@ -358,6 +360,8 @@ private fun handleVerticalAutoAdvance(
                     chapterTitle = chapters.getOrNull(nextIdx)?.title,
                     coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
                     chapterIndex = nextIdx,
+                    totalChapters = chapters.size,
+                    continueSession = true,
                     ttsMode = currentTtsMode,
                     playbackSource = "READER",
                     authToken = token
@@ -433,6 +437,8 @@ private fun handlePaginatedAutoAdvance(
                         chapterTitle = chapterTitle,
                         coverImageUri = coverUriString,
                         chapterIndex = chapterToTry,
+                        totalChapters = chapters.size,
+                        continueSession = true,
                         ttsMode = ttsMode,
                         playbackSource = "READER",
                         authToken = token
diff --git a/app/src/main/java/com/aryan/reader/feedback/SupportProjectScreen.kt b/app/src/main/java/com/aryan/reader/feedback/SupportProjectScreen.kt
new file mode 100644
index 0000000..bf71e09
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/feedback/SupportProjectScreen.kt
@@ -0,0 +1,172 @@
+package com.aryan.reader.feedback
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.outlined.FavoriteBorder
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
+import com.aryan.reader.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SupportProjectScreen(
+    navController: NavHostController
+) {
+    val uriHandler = LocalUriHandler.current
+
+    Scaffold(
+        topBar = {
+            TopAppBar(
+                title = { Text(stringResource(R.string.support_project_title)) },
+                navigationIcon = {
+                    IconButton(onClick = { navController.popBackStack() }) {
+                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
+                    }
+                }
+            )
+        }
+    ) { paddingValues ->
+        Column(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(paddingValues)
+                .padding(horizontal = 16.dp),
+            horizontalAlignment = Alignment.CenterHorizontally
+        ) {
+            Spacer(modifier = Modifier.height(32.dp))
+
+            Icon(
+                imageVector = Icons.Outlined.FavoriteBorder,
+                contentDescription = null,
+                modifier = Modifier.size(72.dp),
+                tint = MaterialTheme.colorScheme.primary
+            )
+
+            Spacer(modifier = Modifier.height(16.dp))
+
+            Text(
+                text = stringResource(R.string.support_project_heading),
+                style = MaterialTheme.typography.headlineMedium,
+                fontWeight = FontWeight.Bold
+            )
+
+            Spacer(modifier = Modifier.height(8.dp))
+
+            Text(
+                text = stringResource(R.string.support_project_desc),
+                style = MaterialTheme.typography.bodyLarge,
+                textAlign = TextAlign.Center,
+                color = MaterialTheme.colorScheme.onSurfaceVariant,
+                modifier = Modifier.padding(horizontal = 16.dp)
+            )
+
+            Spacer(modifier = Modifier.height(48.dp))
+
+            SupportOptionCard(
+                title = stringResource(R.string.support_github_sponsor),
+                description = stringResource(R.string.support_github_sponsor_desc),
+                icon = {
+                    Icon(
+                        painter = painterResource(id = R.drawable.github),
+                        contentDescription = stringResource(R.string.support_github_sponsor),
+                        modifier = Modifier.size(28.dp),
+                        tint = MaterialTheme.colorScheme.primary
+                    )
+                },
+                onClick = {
+                    uriHandler.openUri("https://github.com/sponsors/Aryan-Raj3112")
+                }
+            )
+
+            Spacer(modifier = Modifier.height(16.dp))
+
+            SupportOptionCard(
+                title = stringResource(R.string.support_patreon),
+                description = stringResource(R.string.support_patreon_desc),
+                icon = {
+                    Icon(
+                        imageVector = Icons.Outlined.FavoriteBorder,
+                        contentDescription = stringResource(R.string.support_patreon),
+                        modifier = Modifier.size(28.dp),
+                        tint = MaterialTheme.colorScheme.primary
+                    )
+                },
+                onClick = {
+                    uriHandler.openUri("https://www.patreon.com/c/epistemereader")
+                }
+            )
+
+            Spacer(modifier = Modifier.weight(1f))
+        }
+    }
+}
+
+@Composable
+private fun SupportOptionCard(
+    title: String,
+    description: String,
+    icon: @Composable () -> Unit,
+    onClick: () -> Unit
+) {
+    OutlinedCard(
+        onClick = onClick,
+        modifier = Modifier.fillMaxWidth(),
+        shape = RoundedCornerShape(16.dp)
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(20.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            icon()
+            Spacer(modifier = Modifier.width(20.dp))
+            Column(modifier = Modifier.weight(1f)) {
+                Text(
+                    text = title,
+                    style = MaterialTheme.typography.titleMedium,
+                    fontWeight = FontWeight.Bold
+                )
+                Spacer(modifier = Modifier.height(4.dp))
+                Text(
+                    text = description,
+                    style = MaterialTheme.typography.bodyMedium,
+                    color = MaterialTheme.colorScheme.onSurfaceVariant
+                )
+            }
+            Spacer(modifier = Modifier.width(8.dp))
+            Icon(
+                imageVector = Icons.AutoMirrored.Filled.ArrowForward,
+                contentDescription = stringResource(R.string.action_open),
+                tint = MaterialTheme.colorScheme.onSurfaceVariant
+            )
+        }
+    }
+}
diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt b/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
index 15747ac..500a86c 100644
--- a/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
+++ b/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
@@ -38,6 +38,8 @@ data class OpdsAcquisition(
         get() = when {
             mimeType.contains("epub") -> "EPUB"
             mimeType.contains("pdf") -> "PDF"
+            mimeType.contains("markdown") || mimeType.contains("text/x-markdown") -> "MD"
+            mimeType.contains("html") || mimeType.contains("xhtml") -> "HTML"
             mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI"
             mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2"
             mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ"
@@ -52,6 +54,7 @@ data class OpdsAcquisition(
             "PDF" -> 4
             "MOBI" -> 3
             "FB2" -> 2
+            "MD", "HTML" -> 2
             "CBZ" -> 1
             "TXT" -> 0
             else -> -1
@@ -90,4 +93,4 @@ data class OpdsEntry(
 
     val isStreamable: Boolean
         get() = pseUrlTemplate != null && pseCount != null && pseCount > 0
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt
index 303ed2d..a8fdb05 100644
--- a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt
+++ b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt
@@ -5,6 +5,7 @@ import android.content.Context
 import android.net.Uri
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.viewModelScope
+import com.aryan.reader.resolveFileExtensionSuffixFromName
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
@@ -12,6 +13,7 @@ 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 timber.log.Timber
 import java.io.File
@@ -93,16 +95,7 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
                     val body = response.body ?: throw Exception("Empty body")
                     val contentLength = body.contentLength()
 
-                    val ext = when (acquisition.formatName) {
-                        "EPUB" -> ".epub"
-                        "PDF" -> ".pdf"
-                        "MOBI" -> ".mobi"
-                        "FB2" -> ".fb2"
-                        "CBZ" -> ".cbz"
-                        "CBR" -> ".cbr"
-                        "TXT" -> ".txt"
-                        else -> ".epub"
-                    }
+                    val ext = resolveOpdsDownloadExtension(acquisition, response)
 
                     val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50)
                     val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
@@ -148,6 +141,45 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
         }
     }
 
+    private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
+        val candidates = listOfNotNull(
+            response.header("Content-Disposition")?.let(::extractContentDispositionFilename),
+            Uri.parse(acquisition.url).lastPathSegment
+        )
+
+        candidates.forEach { candidate ->
+            resolveFileExtensionSuffixFromName(Uri.decode(candidate))?.let { return it }
+        }
+
+        return when (acquisition.formatName) {
+            "EPUB" -> ".epub"
+            "PDF" -> ".pdf"
+            "MOBI" -> ".mobi"
+            "FB2" -> ".fb2"
+            "CBZ" -> ".cbz"
+            "CBR" -> ".cbr"
+            "MD" -> ".md"
+            "HTML" -> ".html"
+            "TXT" -> ".txt"
+            else -> ".epub"
+        }
+    }
+
+    private fun extractContentDispositionFilename(contentDisposition: String): String? {
+        val encodedFilename = Regex("filename\\*=UTF-8''([^;]+)", RegexOption.IGNORE_CASE)
+            .find(contentDisposition)
+            ?.groupValues
+            ?.getOrNull(1)
+        if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
+
+        return Regex("filename=\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
+            .find(contentDisposition)
+            ?.groupValues
+            ?.getOrNull(1)
+            ?.trim()
+            ?.trim('"')
+    }
+
     init {
         loadCatalogs()
     }
@@ -221,4 +253,4 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
     fun clearError() {
         _uiState.update { it.copy(errorMessage = null) }
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt
new file mode 100644
index 0000000..a069fec
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt
@@ -0,0 +1,81 @@
+package com.aryan.reader.paginatedreader
+
+import android.graphics.BitmapFactory
+import androidx.compose.ui.text.font.FontFamily
+import java.io.File
+import java.net.URLDecoder
+import java.nio.file.Paths
+
+object AndroidHtmlResourceResolver : HtmlResourceResolver {
+    override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? {
+        if (src.isBlank()) return null
+        val decodedSrc = try {
+            URLDecoder.decode(src, "UTF-8")
+        } catch (_: Exception) {
+            src
+        }
+        val parentPath = File(chapterAbsPath).parent ?: ""
+        val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
+        val fromRelativeFile = File(extractionBasePath, relativePath)
+
+        return try {
+            when {
+                fromRelativeFile.exists() -> fromRelativeFile.canonicalFile.absolutePath
+                File(extractionBasePath, decodedSrc).exists() -> File(extractionBasePath, decodedSrc).canonicalFile.absolutePath
+                else -> null
+            }
+        } catch (_: Exception) {
+            null
+        }
+    }
+
+    override fun readText(path: String): String? {
+        return runCatching { File(path).readText() }.getOrNull()
+    }
+
+    override fun imageDimensions(path: String): Pair? {
+        return runCatching {
+            val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+            BitmapFactory.decodeFile(path, options)
+            if (options.outWidth > 0 && options.outHeight > 0) {
+                options.outWidth.toFloat() to options.outHeight.toFloat()
+            } else {
+                null
+            }
+        }.getOrNull()
+    }
+}
+
+object AndroidHtmlFontFamilyLoader : HtmlFontFamilyLoader {
+    override fun load(fontFaces: List, extractionBasePath: String): Map {
+        return loadFontFamilies(fontFaces, extractionBasePath)
+    }
+}
+
+fun androidHtmlToSemanticBlocks(
+    html: String,
+    cssRules: OptimizedCssRules,
+    textStyle: androidx.compose.ui.text.TextStyle,
+    chapterAbsPath: String,
+    extractionBasePath: String,
+    density: androidx.compose.ui.unit.Density,
+    fontFamilyMap: Map,
+    constraints: androidx.compose.ui.unit.Constraints,
+    imageDimensionsCache: Map> = emptyMap(),
+    mathSvgCache: Map = emptyMap()
+): List {
+    return htmlToSemanticBlocks(
+        html = html,
+        cssRules = cssRules,
+        textStyle = textStyle,
+        chapterAbsPath = chapterAbsPath,
+        extractionBasePath = extractionBasePath,
+        density = density,
+        fontFamilyMap = fontFamilyMap,
+        constraints = constraints,
+        imageDimensionsCache = imageDimensionsCache,
+        mathSvgCache = mathSvgCache,
+        resourceResolver = AndroidHtmlResourceResolver,
+        fontFamilyLoader = AndroidHtmlFontFamilyLoader
+    )
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
index 2ea88bb..8133d68 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
@@ -120,7 +120,8 @@ class BookPaginator(
     private val mathMLRenderer: MathMLRenderer,
     private val userTextAlign: TextAlign?,
     private val paragraphGapMultiplier: Float,
-    private val imageSizeMultiplier: Float
+    private val imageSizeMultiplier: Float,
+    private val verticalMarginMultiplier: Float
 ) : IPaginator {
     override var totalPageCount by mutableIntStateOf(0)
         private set
@@ -185,6 +186,14 @@ class BookPaginator(
                 isLoading = true
                 Timber.d("Initialization started.")
 
+                if (chapters.isEmpty()) {
+                    totalPageCount = 0
+                    pageCountsAreAccurate = true
+                    isLoading = false
+                    Timber.w("Paginator initialized with no chapters. Skipping pagination startup.")
+                    return@launch
+                }
+
                 // 1. Book processing check (Keep existing logic)
                 val bookRecord = bookCacheDao.getProcessedBook(bookId)
                 if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
@@ -214,7 +223,7 @@ class BookPaginator(
                 // 5. Prioritize CURRENT chapter only
                 // We no longer blindly queue neighbors immediately to keep startup fast.
                 // We only queue the requested chapter.
-                val startChapter = initialChapterToPaginate.coerceIn(0, chapters.size - 1)
+                val startChapter = initialChapterToPaginate.coerceIn(0, chapters.lastIndex)
 
                 // Trigger actual pagination for the current chapter to replace the estimate with reality
                 triggerPagination(startChapter, PRIORITY_HIGHEST)
@@ -277,6 +286,7 @@ class BookPaginator(
             append("-ta:$userTextAlign")
             append("-pg:$paragraphGapMultiplier")
             append("-img:$imageSizeMultiplier")
+            append("-vm:$verticalMarginMultiplier")
         }
         val hash = configString.hashCode()
         return hash
@@ -503,7 +513,7 @@ class BookPaginator(
             parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
         }
 
-        val semanticBlocks = htmlToSemanticBlocks(
+        val semanticBlocks = androidHtmlToSemanticBlocks(
             html = processedHtml,
             cssRules = parsingCssRules,
             textStyle = textStyle.copy(color = Color.Black),
@@ -792,6 +802,10 @@ class BookPaginator(
     }
 
     private fun triggerPagination(chapterIndex: Int, priority: Int) {
+        if (chapterIndex !in chapters.indices) {
+            Timber.w("Trigger: Ignoring invalid chapter index $chapterIndex. Chapter count: ${chapters.size}.")
+            return
+        }
         if (pageCache[chapterIndex] != null) {
             Timber.v("Trigger: Chapter $chapterIndex is already in cache. Ignoring.")
             return
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt b/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt
index 3b50a40..69dd687 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt
@@ -163,10 +163,12 @@ class ContentStyler(
             }
 
             is SemanticMath -> {
+                val svgContent = block.svgContent
+                val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
                 val finalSvgContent = when {
-                    block.isFromMathJax || block.svgContent.isNullOrBlank() -> block.svgContent
+                    block.isFromMathJax || nonBlankSvgContent == null -> svgContent
                     else -> {
-                        val themedSvg = applyThemeToSvg(block.svgContent)
+                        val themedSvg = applyThemeToSvg(nonBlankSvgContent)
                         embedImagesInSvg(themedSvg)
                     }
                 }
@@ -452,11 +454,11 @@ class ContentStyler(
                             }
                         }
 
-                        if (span.linkHref != null) {
-                            addStringAnnotation("URL", span.linkHref, span.start, span.end)
+                        span.linkHref?.let { linkHref ->
+                            addStringAnnotation("URL", linkHref, span.start, span.end)
                         }
-                        if (span.elementId != null) {
-                            addStringAnnotation("ID", span.elementId, span.start, span.end)
+                        span.elementId?.let { elementId ->
+                            addStringAnnotation("ID", elementId, span.start, span.end)
                         }
                     }
                 }
@@ -589,4 +591,4 @@ class ContentStyler(
             else -> if (isOrdered) "$counter. " else "• "
         }
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
index dc49af1..ae1f204 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
@@ -27,47 +27,6 @@ import androidx.compose.ui.text.font.FontWeight
 import java.io.File
 import java.security.MessageDigest
 
-/**
- * A centralized mapper for handling conversions between generic CSS font family names
- * and Compose's FontFamily objects.
- */
-object FontFamilyMapper {
-    private val genericFontMap = mapOf(
-        "serif" to FontFamily.Serif,
-        "sans-serif" to FontFamily.SansSerif,
-        "monospace" to FontFamily.Monospace,
-        "cursive" to FontFamily.Cursive,
-        "default" to FontFamily.Default,
-        "system-ui" to FontFamily.Default,
-        "ui-sans-serif" to FontFamily.Default,
-        "ui-serif" to FontFamily.Default,
-        "ui-monospace" to FontFamily.Default,
-        "ui-rounded" to FontFamily.Default
-    )
-
-    /**
-     * Converts a string name (e.g., "serif") to a Compose [FontFamily].
-     */
-    fun nameToFontFamily(name: String): FontFamily? {
-        return genericFontMap[name.trim().lowercase()]
-    }
-
-    /**
-     * Converts a Compose [FontFamily] back to its primary string name for serialization.
-     * Custom fonts are not serialized by name and will return null.
-     */
-    fun fontFamilyToName(fontFamily: FontFamily): String? {
-        return when (fontFamily) {
-            FontFamily.Serif -> "serif"
-            FontFamily.SansSerif -> "sans-serif"
-            FontFamily.Monospace -> "monospace"
-            FontFamily.Cursive -> "cursive"
-            FontFamily.Default -> "default"
-            else -> null
-        }
-    }
-}
-
 private fun getCacheKeyForFont(bookId: String, fontPath: String): String {
     val identifier = "$bookId:$fontPath"
     val digest = MessageDigest.getInstance("MD5").digest(identifier.toByteArray())
@@ -158,4 +117,4 @@ fun loadFontFamilies(fontFaces: List, extractionPath: String): Map
             null
         }
     }.filterValues { it != null }.mapValues { it.value!! }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt
index 6b1e545..55bd8c6 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt
@@ -115,7 +115,7 @@ class LocatorConverter(
                 otherComplex = mergedOtherComplex
             )
 
-            val semanticBlocks = htmlToSemanticBlocks(
+            val semanticBlocks = androidHtmlToSemanticBlocks(
                 html = htmlToParse,
                 cssRules = parsingCssRules,
                 textStyle = TextStyle(),
@@ -381,4 +381,4 @@ class LocatorConverter(
         }
         return@withContext null
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
index e673629..ce064a8 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt
@@ -21,6 +21,7 @@ import androidx.compose.foundation.gestures.awaitEachGesture
 import androidx.compose.foundation.gestures.awaitFirstDown
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.Column
@@ -147,7 +148,7 @@ import coil.compose.AsyncImage
 import coil.imageLoader
 import coil.request.ImageRequest.Builder
 import com.aryan.reader.R
-import com.aryan.reader.ReaderTexture
+import com.aryan.reader.loadReaderTextureBitmap
 import com.aryan.reader.countWords
 import com.aryan.reader.epub.EpubBook
 import com.aryan.reader.epubreader.HighlightColor
@@ -251,6 +252,12 @@ private fun headerFontScale(level: Int): Float = when (level) {
     else -> 1.0f
 }
 
+private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
+
+private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
+    return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
+}
+
 private fun createHeaderTextStyle(
     baseStyle: TextStyle,
     level: Int,
@@ -473,6 +480,51 @@ private fun computeImageRenderSizeDp(
     return with(density) { widthPx.toDp() to heightPx.toDp() }
 }
 
+private fun imageBlockContentAlignment(style: BlockStyle): Alignment {
+    return when {
+        style.float == "right" || style.horizontalAlign == "right" || style.horizontalAlign == "end" -> Alignment.CenterEnd
+        style.float == "left" || style.horizontalAlign == "left" || style.horizontalAlign == "start" -> Alignment.CenterStart
+        else -> Alignment.Center
+    }
+}
+
+private fun tableCellImageModifier(
+    block: ImageBlock,
+    density: Density,
+    imageSizeMultiplier: Float
+): Modifier {
+    val baseModifier = if (block.style.width.isSpecified && block.style.width > 0.dp) {
+        Modifier.width(block.style.width * imageSizeMultiplier)
+    } else {
+        Modifier.fillMaxWidth(imageSizeMultiplier.coerceIn(0f, 1f))
+    }
+
+    val intrinsicWidth = block.intrinsicWidth
+    val intrinsicHeight = block.intrinsicHeight
+    val sizedModifier = if (
+        intrinsicWidth != null &&
+        intrinsicHeight != null &&
+        intrinsicWidth > 0f &&
+        intrinsicHeight > 0f
+    ) {
+        baseModifier.aspectRatio(intrinsicWidth / intrinsicHeight)
+    } else {
+        baseModifier.height(
+            if (block.expectedHeight > 0) {
+                with(density) { (block.expectedHeight * imageSizeMultiplier).toDp() }
+            } else {
+                250.dp
+            }
+        )
+    }
+
+    return if (block.style.maxWidth.isSpecified && block.style.maxWidth > 0.dp) {
+        sizedModifier.widthIn(max = block.style.maxWidth * imageSizeMultiplier)
+    } else {
+        sizedModifier
+    }
+}
+
 @Composable
 private fun WrappingContentLayout(
     block: WrappingContentBlock,
@@ -682,6 +734,7 @@ fun PaginatedReaderScreen(
     paragraphGapMultiplier: Float,
     imageSizeMultiplier: Float,
     horizontalMarginMultiplier: Float,
+    verticalMarginMultiplier: Float,
     fontFamily: FontFamily,
     textAlign: ReaderTextAlign,
     ttsHighlightInfo: TtsHighlightInfo?,
@@ -702,7 +755,8 @@ fun PaginatedReaderScreen(
     onHighlightDeleted: (String) -> Unit,
     activeHighlightPalette: List,
     onUpdatePalette: (Int, HighlightColor) -> Unit,
-    activeTextureId: String? = null
+    activeTextureId: String? = null,
+    activeTextureAlpha: Float = 0.55f
 ) {
     LaunchedEffect(userHighlights) {
         Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
@@ -713,11 +767,7 @@ fun PaginatedReaderScreen(
 
     val context = LocalContext.current
     val textureBitmap = remember(activeTextureId) {
-        activeTextureId?.let { id ->
-            ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
-                ImageBitmap.imageResource(context.resources, resId)
-            }
-        }
+        loadReaderTextureBitmap(context, activeTextureId)
     }
 
     val textureModifier = if (textureBitmap != null) {
@@ -725,13 +775,13 @@ fun PaginatedReaderScreen(
             val brush = ShaderBrush(
                 ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)
             )
-            drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f)
+            drawRect(brush = brush, blendMode = BlendMode.SrcOver, alpha = activeTextureAlpha.coerceIn(0f, 1f))
         }
     } else Modifier
 
     var isNavigatingByLink by remember { mutableStateOf(false) }
 
-    BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg).then(textureModifier)) {
+    BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
         val textMeasurer = rememberTextMeasurer()
         val baseTextStyle = MaterialTheme.typography.bodyLarge
 
@@ -740,6 +790,7 @@ fun PaginatedReaderScreen(
         var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) }
         var debouncedImageSizeMult by remember { mutableFloatStateOf(imageSizeMultiplier) }
         var debouncedHorizontalMarginMult by remember { mutableFloatStateOf(horizontalMarginMultiplier) }
+        var debouncedVerticalMarginMult by remember { mutableFloatStateOf(verticalMarginMultiplier) }
         var debouncedFontFamily by remember { mutableStateOf(fontFamily) }
         var debouncedTextAlign by remember { mutableStateOf(textAlign) }
 
@@ -781,7 +832,7 @@ fun PaginatedReaderScreen(
             debouncedFontFamily
         ) {
             val adjustedFontSize = baseTextStyle.fontSize * debouncedFontSizeMult
-            val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult
+            val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
 
             baseTextStyle.copy(
                 color = effectiveText,
@@ -810,12 +861,13 @@ fun PaginatedReaderScreen(
             }
         }
 
-        LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, fontFamily, textAlign) {
+        LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, verticalMarginMultiplier, fontFamily, textAlign) {
             if (fontSizeMultiplier != debouncedFontSizeMult ||
                 lineHeightMultiplier != debouncedLineHeightMult ||
                 paragraphGapMultiplier != debouncedParagraphGapMult ||
                 imageSizeMultiplier != debouncedImageSizeMult ||
                 horizontalMarginMultiplier != debouncedHorizontalMarginMult ||
+                verticalMarginMultiplier != debouncedVerticalMarginMult ||
                 fontFamily != debouncedFontFamily ||
                 textAlign != debouncedTextAlign
             ) {
@@ -836,6 +888,7 @@ fun PaginatedReaderScreen(
                 debouncedParagraphGapMult = paragraphGapMultiplier
                 debouncedImageSizeMult = imageSizeMultiplier
                 debouncedHorizontalMarginMult = horizontalMarginMultiplier
+                debouncedVerticalMarginMult = verticalMarginMultiplier
                 debouncedFontFamily = fontFamily
                 debouncedTextAlign = textAlign
                 Timber.d("Debounce complete. Applying new format settings.")
@@ -851,8 +904,28 @@ fun PaginatedReaderScreen(
         }
 
         val density = LocalDensity.current
-        val horizontalPadding = 16.dp * debouncedHorizontalMarginMult
-        val verticalPadding = 16.dp
+        val requestedHorizontalPadding = 16.dp * debouncedHorizontalMarginMult
+        val requestedVerticalPadding = 16.dp * debouncedVerticalMarginMult
+        val effectiveReaderPadding =
+            remember(this.constraints, density, requestedHorizontalPadding, requestedVerticalPadding) {
+                val requestedHorizontalPaddingPx = with(density) { requestedHorizontalPadding.roundToPx() }
+                val requestedVerticalPaddingPx = with(density) { requestedVerticalPadding.roundToPx() }
+                val minReadableWidthPx = with(density) { 96.dp.roundToPx() }
+                    .coerceAtMost(this.constraints.maxWidth)
+                val minReadableHeightPx = with(density) { 160.dp.roundToPx() }
+                    .coerceAtMost(this.constraints.maxHeight)
+                val horizontalPaddingPx = requestedHorizontalPaddingPx.coerceAtMost(
+                    ((this.constraints.maxWidth - minReadableWidthPx) / 2).coerceAtLeast(0)
+                )
+                val verticalPaddingPx = requestedVerticalPaddingPx.coerceAtMost(
+                    ((this.constraints.maxHeight - minReadableHeightPx) / 2).coerceAtLeast(0)
+                )
+                with(density) {
+                    horizontalPaddingPx.toDp() to verticalPaddingPx.toDp()
+                }
+            }
+        val horizontalPadding = effectiveReaderPadding.first
+        val verticalPadding = effectiveReaderPadding.second
 
         val textConstraints =
             remember(this.constraints, density, horizontalPadding, verticalPadding) {
@@ -860,9 +933,9 @@ fun PaginatedReaderScreen(
                 val verticalPaddingPx = with(density) { verticalPadding.roundToPx() }
                 val finalConstraints = this.constraints.copy(
                     minWidth = 0,
-                    maxWidth = this.constraints.maxWidth - (2 * horizontalPaddingPx),
+                    maxWidth = (this.constraints.maxWidth - (2 * horizontalPaddingPx)).coerceAtLeast(1),
                     minHeight = 0,
-                    maxHeight = this.constraints.maxHeight - (2 * verticalPaddingPx)
+                    maxHeight = (this.constraints.maxHeight - (2 * verticalPaddingPx)).coerceAtLeast(1)
                 )
                 finalConstraints
             }
@@ -950,7 +1023,8 @@ fun PaginatedReaderScreen(
                 mathMLRenderer = mathMLRenderer,
                 userTextAlign = userTextAlign,
                 paragraphGapMultiplier = debouncedParagraphGapMult,
-                imageSizeMultiplier = debouncedImageSizeMult
+                imageSizeMultiplier = debouncedImageSizeMult,
+                verticalMarginMultiplier = debouncedVerticalMarginMult
             )
         }
 
@@ -1168,7 +1242,10 @@ fun PaginatedReaderScreen(
             isDarkTheme = isDarkTheme,
             activeHighlightPalette = activeHighlightPalette,
             onUpdatePalette = onUpdatePalette,
-            effectiveText = effectiveText
+            effectiveText = effectiveText,
+            pageTextureModifier = if (isPageTurnAnimationEnabled) Modifier else textureModifier,
+            pageTextureBitmap = textureBitmap,
+            pageTextureAlpha = activeTextureAlpha.coerceIn(0f, 1f)
         )
 
         androidx.compose.animation.AnimatedVisibility(
@@ -1989,7 +2066,10 @@ internal fun PaginatedReaderContent(
     onHighlightDeleted: (String) -> Unit,
     activeHighlightPalette: List,
     onUpdatePalette: (Int, HighlightColor) -> Unit,
-    isDarkTheme: Boolean
+    isDarkTheme: Boolean,
+    pageTextureModifier: Modifier = Modifier,
+    pageTextureBitmap: ImageBitmap? = null,
+    pageTextureAlpha: Float = 0f
 ) {
     val coroutineScope = rememberCoroutineScope()
     val density = LocalDensity.current
@@ -2130,7 +2210,9 @@ internal fun PaginatedReaderContent(
                                 pageIndex,
                                 effectiveBg,
                                 isDarkTheme,
-                                pageTurnTouchY
+                                pageTurnTouchY,
+                                pageTextureBitmap,
+                                pageTextureAlpha
                             )
                         } else Modifier
 
@@ -2284,7 +2366,7 @@ internal fun PaginatedReaderContent(
                             pendingCrossPageSelection = null
                         }
 
-                        Box(modifier = Modifier.fillMaxSize().then(pageModifier)) {
+                        Box(modifier = Modifier.fillMaxSize().background(effectiveBg).then(pageTextureModifier).then(pageModifier)) {
                             Box(modifier = Modifier.fillMaxSize()) {
                                 Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
                                     detectTapGestures(
@@ -2784,12 +2866,14 @@ internal fun PaginatedReaderContent(
                                                                 val markerAreaModifier =
                                                                     Modifier.width(32.dp)
                                                                         .padding(end = 8.dp)
+                                                                val itemMarkerImage = block.itemMarkerImage
+                                                                val itemMarker = block.itemMarker
 
-                                                                if (block.itemMarkerImage != null) {
+                                                                if (itemMarkerImage != null) {
                                                                     val imageRequest =
                                                                         Builder(LocalContext.current).data(
                                                                             File(
-                                                                                block.itemMarkerImage
+                                                                                itemMarkerImage
                                                                             )
                                                                         ).crossfade(true).build()
                                                                     val imageSize = with(density) {
@@ -2805,9 +2889,9 @@ internal fun PaginatedReaderContent(
                                                                         alignment = Alignment.CenterEnd,
                                                                         contentScale = ContentScale.FillHeight
                                                                     )
-                                                                } else if (block.itemMarker != null) {
+                                                                } else if (itemMarker != null) {
                                                                     Text(
-                                                                        text = block.itemMarker,
+                                                                        text = itemMarker,
                                                                         style = textStyle.copy(
                                                                             textAlign = TextAlign.End
                                                                         ),
@@ -3017,10 +3101,12 @@ internal fun PaginatedReaderContent(
                                                         }
 
                                                         is MathBlock -> {
+                                                            val svgContent = block.svgContent?.takeIf { it.isNotBlank() }
                                                             Timber.d(
-                                                                "PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${!block.svgContent.isNullOrBlank()}"
+                                                                "PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${svgContent != null}"
                                                             )
-                                                            if (!block.svgContent.isNullOrBlank()) {
+                                                            if (svgContent != null) {
+                                                                val nonBlankSvgContent = svgContent
                                                                 BoxWithConstraints(
                                                                     modifier = paddingModifier
                                                                 ) {
@@ -3104,7 +3190,7 @@ internal fun PaginatedReaderContent(
                                                                     val imageRequest =
                                                                         Builder(LocalContext.current).data(
                                                                             SvgData(
-                                                                                block.svgContent
+                                                                                nonBlankSvgContent
                                                                             )
                                                                         ).listener(
                                                                             onError = { _, result ->
@@ -3189,7 +3275,10 @@ internal fun PaginatedReaderContent(
                                                                         )
                                                                     }).crossfade(true).build()
 
-                                                            BoxWithConstraints(modifier = paddingModifier) {
+                                                            BoxWithConstraints(
+                                                                modifier = paddingModifier,
+                                                                contentAlignment = imageBlockContentAlignment(style)
+                                                            ) {
                                                                 val scaledSize = computeImageRenderSizeDp(
                                                                     block = block,
                                                                     density = density,
@@ -3357,9 +3446,10 @@ internal fun PaginatedReaderContent(
                                                                                             Row(
                                                                                                 verticalAlignment = Alignment.Top
                                                                                             ) {
-                                                                                                if (blockInCell.itemMarker != null) {
+                                                                                                val itemMarker = blockInCell.itemMarker
+                                                                                                if (itemMarker != null) {
                                                                                                     Text(
-                                                                                                        text = blockInCell.itemMarker,
+                                                                                                        text = itemMarker,
                                                                                                         style = cellTextStyle,
                                                                                                         modifier = Modifier.padding(
                                                                                                             end = 4.dp
@@ -3390,40 +3480,23 @@ internal fun PaginatedReaderContent(
                                                                                         }
 
                                                                                         is ImageBlock -> {
-                                                                                            BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
-                                                                                                val scaledSize = computeImageRenderSizeDp(
+                                                                                            AsyncImage(
+                                                                                                model = Builder(
+                                                                                                    LocalContext.current
+                                                                                                ).data(
+                                                                                                    File(
+                                                                                                        blockInCell.path
+                                                                                                    )
+                                                                                                )
+                                                                                                    .build(),
+                                                                                                contentDescription = blockInCell.altText,
+                                                                                                contentScale = ContentScale.Fit,
+                                                                                                modifier = tableCellImageModifier(
                                                                                                     block = blockInCell,
                                                                                                     density = density,
-                                                                                                    maxWidthDp = maxWidth,
                                                                                                     imageSizeMultiplier = imageSizeMultiplier
                                                                                                 )
-                                                                                                val imageModifier = Modifier.then(
-                                                                                                    if (scaledSize != null) {
-                                                                                                        Modifier.width(scaledSize.first).height(scaledSize.second)
-                                                                                                    } else {
-                                                                                                        Modifier.fillMaxWidth().then(
-                                                                                                            if (blockInCell.expectedHeight > 0) {
-                                                                                                                Modifier.height(with(density) { (blockInCell.expectedHeight * imageSizeMultiplier).toDp() })
-                                                                                                            } else {
-                                                                                                                Modifier.height(250.dp)
-                                                                                                            }
-                                                                                                        )
-                                                                                                    }
-                                                                                                )
-                                                                                                AsyncImage(
-                                                                                                    model = Builder(
-                                                                                                        LocalContext.current
-                                                                                                    ).data(
-                                                                                                        File(
-                                                                                                            blockInCell.path
-                                                                                                        )
-                                                                                                    )
-                                                                                                        .build(),
-                                                                                                    contentDescription = blockInCell.altText,
-                                                                                                    contentScale = ContentScale.Fit,
-                                                                                                    modifier = imageModifier
-                                                                                                )
-                                                                                            }
+                                                                                            )
                                                                                         }
 
                                                                                         is TextContentBlock -> {
@@ -4101,10 +4174,12 @@ private fun RenderFlexChildBlock(
                 val markerAreaModifier = Modifier
                     .width(32.dp)
                     .padding(end = 8.dp)
+                val itemMarkerImage = childBlock.itemMarkerImage
+                val itemMarker = childBlock.itemMarker
 
-                if (childBlock.itemMarkerImage != null) {
+                if (itemMarkerImage != null) {
                     val imageRequest =
-                        Builder(LocalContext.current).data(File(childBlock.itemMarkerImage))
+                        Builder(LocalContext.current).data(File(itemMarkerImage))
                             .crossfade(true).build()
                     val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
 
@@ -4115,9 +4190,9 @@ private fun RenderFlexChildBlock(
                         alignment = Alignment.CenterEnd,
                         contentScale = ContentScale.FillHeight
                     )
-                } else if (childBlock.itemMarker != null) {
+                } else if (itemMarker != null) {
                     Text(
-                        text = childBlock.itemMarker,
+                        text = itemMarker,
                         style = textStyle.copy(textAlign = TextAlign.End),
                         modifier = markerAreaModifier
                     )
@@ -4160,7 +4235,7 @@ private fun RenderFlexChildBlock(
                 ColorFilter.colorMatrix(ColorMatrix(matrix))
             } else null
 
-            BoxWithConstraints {
+            BoxWithConstraints(contentAlignment = imageBlockContentAlignment(style)) {
                 val scaledSize = computeImageRenderSizeDp(
                     block = childBlock,
                     density = density,
@@ -4278,37 +4353,20 @@ private fun RenderFlexChildBlock(
                                             modifier = Modifier.fillMaxWidth()
                                         )
                                     } else if (blockInCell is ImageBlock) {
-                                        BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
-                                            val scaledSize = computeImageRenderSizeDp(
+                                        AsyncImage(
+                                            model = Builder(LocalContext.current).data(
+                                                File(
+                                                    blockInCell.path
+                                                )
+                                            ).build(),
+                                            contentDescription = blockInCell.altText,
+                                            contentScale = ContentScale.Fit,
+                                            modifier = tableCellImageModifier(
                                                 block = blockInCell,
                                                 density = density,
-                                                maxWidthDp = maxWidth,
                                                 imageSizeMultiplier = imageSizeMultiplier
                                             )
-                                            val imageModifier = Modifier.then(
-                                                if (scaledSize != null) {
-                                                    Modifier.width(scaledSize.first).height(scaledSize.second)
-                                                } else {
-                                                    Modifier.fillMaxWidth().then(
-                                                        if (blockInCell.expectedHeight > 0) {
-                                                            Modifier.height(with(density) { (blockInCell.expectedHeight * imageSizeMultiplier).toDp() })
-                                                        } else {
-                                                            Modifier.height(250.dp)
-                                                        }
-                                                    )
-                                                }
-                                            )
-                                            AsyncImage(
-                                                model = Builder(LocalContext.current).data(
-                                                    File(
-                                                        blockInCell.path
-                                                    )
-                                                ).build(),
-                                                contentDescription = blockInCell.altText,
-                                                contentScale = ContentScale.Fit,
-                                                modifier = imageModifier
-                                            )
-                                        }
+                                        )
                                     }
                                 }
                             }
@@ -4332,7 +4390,9 @@ private fun Modifier.realisticBookPage(
     pageIndex: Int,
     paperColor: Color,
     isDarkTheme: Boolean,
-    touchY: Float?
+    touchY: Float?,
+    textureBitmap: ImageBitmap? = null,
+    textureAlpha: Float = 0f
 ): Modifier = composed {
 
     val frontPath = remember { Path() }
@@ -4360,9 +4420,19 @@ private fun Modifier.realisticBookPage(
         .drawWithContent {
             val drawStart = System.nanoTime()
             val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
+            fun drawPaperBackground() {
+                drawRect(color = paperColor)
+                if (textureBitmap != null && textureAlpha > 0f) {
+                    drawRect(
+                        brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
+                        blendMode = BlendMode.SrcOver,
+                        alpha = textureAlpha
+                    )
+                }
+            }
 
             if (abs(pageOffset) < 0.001f) {
-                drawRect(color = paperColor)
+                drawPaperBackground()
                 drawContent()
             }
             else if (pageOffset < 0f && pageOffset > -1f) {
@@ -4423,7 +4493,7 @@ private fun Modifier.realisticBookPage(
                     frontPath.close()
 
                     clipPath(frontPath) {
-                        drawRect(color = paperColor)
+                        drawPaperBackground()
                         this@drawWithContent.drawContent()
                     }
 
@@ -4466,6 +4536,15 @@ private fun Modifier.realisticBookPage(
                     clipRect(0f, 0f, w, h) {
                         clipPath(frontPath) {
                             drawPath(reflectedScreenPath, color = paperColor)
+                            if (textureBitmap != null && textureAlpha > 0f) {
+                                clipPath(reflectedScreenPath) {
+                                    drawRect(
+                                        brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
+                                        blendMode = BlendMode.SrcOver,
+                                        alpha = textureAlpha
+                                    )
+                                }
+                            }
                             val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
                             drawPath(reflectedScreenPath, color = flapTint)
 
@@ -4493,12 +4572,12 @@ private fun Modifier.realisticBookPage(
                     }
 
                 } else {
-                    drawRect(color = paperColor)
+                    drawPaperBackground()
                     drawContent()
                 }
             }
             else {
-                drawRect(color = paperColor)
+                drawPaperBackground()
                 drawContent()
             }
 
@@ -4515,10 +4594,14 @@ fun Modifier.drawCssBorders(
     blockStyle: BlockStyle,
     @Suppress("unused") density: Density
 ): Modifier = this.drawBehind {
-    val topWidth = blockStyle.borderTop?.width?.toPx() ?: 0f
-    val rightWidth = blockStyle.borderRight?.width?.toPx() ?: 0f
-    val bottomWidth = blockStyle.borderBottom?.width?.toPx() ?: 0f
-    val leftWidth = blockStyle.borderLeft?.width?.toPx() ?: 0f
+    val borderTop = blockStyle.borderTop
+    val borderRight = blockStyle.borderRight
+    val borderBottom = blockStyle.borderBottom
+    val borderLeft = blockStyle.borderLeft
+    val topWidth = borderTop?.width?.toPx() ?: 0f
+    val rightWidth = borderRight?.width?.toPx() ?: 0f
+    val bottomWidth = borderBottom?.width?.toPx() ?: 0f
+    val leftWidth = borderLeft?.width?.toPx() ?: 0f
 
     val tlRadius = blockStyle.borderTopLeftRadius.toPx()
     val trRadius = blockStyle.borderTopRightRadius.toPx()
@@ -4550,9 +4633,9 @@ fun Modifier.drawCssBorders(
     }
 
     // TOP
-    if (topWidth > 0f && blockStyle.borderTop != null) {
-        val color = blockStyle.borderTop.color
-        val effect = getPathEffect(blockStyle.borderTop.style, topWidth)
+    if (topWidth > 0f && borderTop != null) {
+        val color = borderTop.color
+        val effect = getPathEffect(borderTop.style, topWidth)
         val offset = topWidth / 2f
 
         val startX = if (tlRadius > 0) tlRadius else 0f
@@ -4568,9 +4651,9 @@ fun Modifier.drawCssBorders(
     }
 
     // BOTTOM
-    if (bottomWidth > 0f && blockStyle.borderBottom != null) {
-        val color = blockStyle.borderBottom.color
-        val effect = getPathEffect(blockStyle.borderBottom.style, bottomWidth)
+    if (bottomWidth > 0f && borderBottom != null) {
+        val color = borderBottom.color
+        val effect = getPathEffect(borderBottom.style, bottomWidth)
         val offset = size.height - (bottomWidth / 2f)
 
         val startX = if (blRadius > 0) blRadius else 0f
@@ -4586,9 +4669,9 @@ fun Modifier.drawCssBorders(
     }
 
     // LEFT
-    if (leftWidth > 0f && blockStyle.borderLeft != null) {
-        val color = blockStyle.borderLeft.color
-        val effect = getPathEffect(blockStyle.borderLeft.style, leftWidth)
+    if (leftWidth > 0f && borderLeft != null) {
+        val color = borderLeft.color
+        val effect = getPathEffect(borderLeft.style, leftWidth)
         val offset = leftWidth / 2f
 
         val startY = if (tlRadius > 0) tlRadius else 0f
@@ -4604,9 +4687,9 @@ fun Modifier.drawCssBorders(
     }
 
     // RIGHT
-    if (rightWidth > 0f && blockStyle.borderRight != null) {
-        val color = blockStyle.borderRight.color
-        val effect = getPathEffect(blockStyle.borderRight.style, rightWidth)
+    if (rightWidth > 0f && borderRight != null) {
+        val color = borderRight.color
+        val effect = getPathEffect(borderRight.style, rightWidth)
         val offset = size.width - (rightWidth / 2f)
 
         val startY = if (trRadius > 0) trRadius else 0f
@@ -4621,9 +4704,9 @@ fun Modifier.drawCssBorders(
         )
     }
 
-    if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && blockStyle.borderTop != null) {
+    if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && borderTop != null) {
         drawArc(
-            color = blockStyle.borderTop.color,
+            color = borderTop.color,
             startAngle = 180f, sweepAngle = 90f,
             useCenter = false,
             topLeft = Offset(leftWidth/2f, topWidth/2f),
@@ -4632,9 +4715,9 @@ fun Modifier.drawCssBorders(
         )
     }
 
-    if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && blockStyle.borderTop != null) {
+    if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && borderTop != null) {
         drawArc(
-            color = blockStyle.borderTop.color,
+            color = borderTop.color,
             startAngle = 270f, sweepAngle = 90f,
             useCenter = false,
             topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f),
@@ -4643,9 +4726,9 @@ fun Modifier.drawCssBorders(
         )
     }
 
-    if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && blockStyle.borderBottom != null) {
+    if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && borderBottom != null) {
         drawArc(
-            color = blockStyle.borderBottom.color,
+            color = borderBottom.color,
             startAngle = 0f, sweepAngle = 90f,
             useCenter = false,
             topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)),
@@ -4654,9 +4737,9 @@ fun Modifier.drawCssBorders(
         )
     }
 
-    if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && blockStyle.borderBottom != null) {
+    if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && borderBottom != null) {
         drawArc(
-            color = blockStyle.borderBottom.color,
+            color = borderBottom.color,
             startAngle = 90f, sweepAngle = 90f,
             useCenter = false,
             topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)),
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt
index 02a3234..fcbded3 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt
@@ -145,7 +145,8 @@ class PaginatedReaderViewModel : ViewModel() {
                 mathMLRenderer = mathMLRenderer,
                 userTextAlign = null,
                 paragraphGapMultiplier = paragraphGapMultiplier,
-                imageSizeMultiplier = 1.0f
+                imageSizeMultiplier = 1.0f,
+                verticalMarginMultiplier = 1.0f
             )
             paginator = newPaginator
 
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt
index 04c0e3a..3138b7b 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt
@@ -198,7 +198,7 @@ abstract class BookCacheDao {
         ConfigurationCache::class,
         AnchorIndexEntry::class
     ],
-    version = 8,
+    version = 10,
     exportSchema = false
 )
 abstract class BookCacheDatabase : RoomDatabase() {
@@ -222,4 +222,4 @@ abstract class BookCacheDatabase : RoomDatabase() {
             }
         }
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt
index 62d39b2..da13b51 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt
@@ -25,7 +25,7 @@ import androidx.room.ForeignKey
 import androidx.room.Index
 import androidx.room.PrimaryKey
 
-const val LATEST_PROCESSING_VERSION = 8
+const val LATEST_PROCESSING_VERSION = 10
 
 @Entity(tableName = "processed_books")
 data class ProcessedBook(
@@ -130,4 +130,4 @@ data class ConfigurationCache(
     val bookId: String,
     val configHash: Int,
     val chapterPageCounts: String
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt
index 50eec62..794a7fc 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt
+++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt
@@ -39,7 +39,7 @@ import com.aryan.reader.paginatedreader.FontFaceInfo
 import com.aryan.reader.paginatedreader.MathMLRenderer
 import com.aryan.reader.paginatedreader.OptimizedCssRules
 import com.aryan.reader.paginatedreader.RenderResult
-import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
+import com.aryan.reader.paginatedreader.androidHtmlToSemanticBlocks
 import com.aryan.reader.paginatedreader.loadFontFamilies
 import com.aryan.reader.paginatedreader.semanticBlockModule
 import kotlinx.coroutines.Dispatchers
@@ -277,7 +277,7 @@ class BookProcessingWorker(
                             Timber.d("Chapter $index (Background Worker): Processed HTML contains : ${processedHtml.contains("math-placeholder")}")
 
 
-                            val semanticBlocks = htmlToSemanticBlocks(
+                            val semanticBlocks = androidHtmlToSemanticBlocks(
                                 html = processedHtml,
                                 cssRules = lightThemeCssRules,
                                 textStyle = textStyle,
@@ -371,4 +371,4 @@ class BookProcessingWorker(
         blocks.forEach { walk(it) }
         return anchors
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt b/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt
index a493d42..ae9e2f7 100644
--- a/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt
@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.dp
+import kotlin.math.max
 import kotlin.math.roundToInt
 
 @Composable
@@ -68,6 +69,9 @@ fun MagnifierComposable(
         Canvas(modifier = Modifier.fillMaxSize()) {
             val magnifierWidthPx = size.width
             val magnifierHeightPx = size.height
+            if (magnifierWidthPx <= 0f || magnifierHeightPx <= 0f || zoomFactor <= 0f) {
+                return@Canvas
+            }
 
             Timber.d("Magnifier: START. scale=$currentScale, centerOnBitmap=$magnifierCenterOnBitmap")
 
@@ -109,8 +113,10 @@ fun MagnifierComposable(
                 val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
                 Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
 
-                val clampedSrcLeft = srcLeft.coerceIn(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
-                val clampedSrcTop = srcTop.coerceIn(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
+                val maxSrcLeft = max(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
+                val maxSrcTop = max(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
+                val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
+                val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
                 Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
 
                 val finalSrcLeftInt = clampedSrcLeft.roundToInt()
@@ -174,8 +180,10 @@ fun MagnifierComposable(
                 val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
                 Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
 
-                val clampedSrcLeft = srcLeft.coerceIn(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
-                val clampedSrcTop = srcTop.coerceIn(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
+                val maxSrcLeft = max(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
+                val maxSrcTop = max(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
+                val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
+                val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
                 Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
 
                 val finalSrcLeftInt = clampedSrcLeft.roundToInt()
@@ -226,4 +234,4 @@ fun MagnifierComposable(
             }
         }
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt
index 9bb73e5..f60efa1 100644
--- a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt
@@ -1,5 +1,7 @@
 package com.aryan.reader.pdf
 
+import com.aryan.reader.shared.pdf.PdfiumAnnotationSubtype
+
 object NativePdfiumBridge {
     init {
         System.loadLibrary("native-lib")
@@ -31,9 +33,9 @@ object NativePdfiumBridge {
     @JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
     @JvmStatic external fun checkActionSupport(): Boolean
 
-    const val ANNOT_TEXT = 1         // Sticky Note
-    const val ANNOT_LINK = 2         // Link
-    const val ANNOT_HIGHLIGHT = 8    // Highlight
-    const val ANNOT_INK = 12         // Freehand drawing
-    const val ANNOT_WIDGET = 19
-}
\ No newline at end of file
+    const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT
+    const val ANNOT_LINK = PdfiumAnnotationSubtype.LINK
+    const val ANNOT_HIGHLIGHT = PdfiumAnnotationSubtype.HIGHLIGHT
+    const val ANNOT_INK = PdfiumAnnotationSubtype.INK
+    const val ANNOT_WIDGET = PdfiumAnnotationSubtype.WIDGET
+}
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt
index 40fa298..a37225c 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt
@@ -84,7 +84,9 @@ fun VerticalScrollbar(
 
             if (viewportRatio >= 1f) return@derivedStateOf null
 
-            val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
+            val maxThumbHeight = viewportHeight / 2f
+            val minThumbHeight = minOf(80f, maxThumbHeight)
+            val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
 
             val firstItemIndex = listState.firstVisibleItemIndex
             val firstItemOffset = listState.firstVisibleItemScrollOffset
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
index b7c07c0..bd32dd1 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
@@ -69,9 +69,13 @@ import androidx.compose.ui.graphics.BlendMode
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ColorFilter
 import androidx.compose.ui.graphics.ColorMatrix
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.ImageShader
 import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.ShaderBrush
 import androidx.compose.ui.graphics.StrokeCap
 import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.TileMode
 import androidx.compose.ui.graphics.asImageBitmap
 import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.graphics.drawscope.clipRect
@@ -119,6 +123,7 @@ import androidx.core.graphics.scale
 import androidx.core.graphics.set
 import com.aryan.reader.R
 import com.aryan.reader.SearchResult
+import com.aryan.reader.loadReaderTextureBitmap
 import com.aryan.reader.ml.SpeechBubble
 import com.aryan.reader.pdf.data.PdfAnnotation
 import com.aryan.reader.pdf.data.PdfTextBox
@@ -127,6 +132,7 @@ import com.aryan.reader.pdf.ocr.OcrElement
 import com.aryan.reader.pdf.ocr.OcrResult
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.NonCancellable
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.coroutineScope
@@ -180,6 +186,13 @@ data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
 
 data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
 
+private const val PDF_TILE_SIZE_DP = 256
+private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
+private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
+private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L
+private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
+private const val PDF_PAGINATION_PAN_FLING_MULTIPLIER = 0.72f
+
 enum class LinkSource {
     ANNOTATION, TEXT_CONTENT
 }
@@ -426,7 +439,10 @@ data class PageStaticData(
     val colorFilter: StableHolder,
     val isDarkMode: Boolean,
     val excludeImages: Boolean,
-    val imageRects: StableHolder>
+    val imageRects: StableHolder>,
+    val textureBitmap: StableHolder,
+    val textureAlpha: Float,
+    val textureBlendMode: BlendMode
 )
 
 @Stable
@@ -497,6 +513,7 @@ internal fun PdfPageComposable(
     onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
     onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
     activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
+    activeTextureAlpha: Float = 0.55f,
     excludeImages: Boolean = false,
     onDoubleTap: ((Offset) -> Unit)? = null,
     isEditMode: Boolean = false,
@@ -552,7 +569,7 @@ internal fun PdfPageComposable(
     var isLoadingPage by remember { mutableStateOf(true) }
     var pageErrorMessage by remember { mutableStateOf(null) }
     val density = LocalDensity.current
-    LocalContext.current
+    val context = LocalContext.current
     val viewConfiguration = LocalViewConfiguration.current
     val coroutineScope = rememberCoroutineScope()
     var isStylusEraserOverride by remember { mutableStateOf(false) }
@@ -564,6 +581,7 @@ internal fun PdfPageComposable(
     var isTransforming by remember { mutableStateOf(false) }
     var scale by remember { mutableFloatStateOf(1f) }
     var offset by remember { mutableStateOf(Offset.Zero) }
+    var paginationPanFlingJob by remember { mutableStateOf(null) }
 
     LaunchedEffect(scale, offset) {
         onZoomAndPanChanged?.invoke(scale, offset)
@@ -590,8 +608,10 @@ internal fun PdfPageComposable(
     val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
 
     var tiles by remember { mutableStateOf>(emptyList()) }
-    val tileSizeDp = 256.dp
+    val tileSizeDp = PDF_TILE_SIZE_DP.dp
     val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
+    val latestEffectiveScale by rememberUpdatedState(effectiveScale)
+    val latestEffectiveOffset by rememberUpdatedState(effectiveOffset)
 
     SideEffect {
         Timber.tag("PdfDrawPerf")
@@ -633,6 +653,8 @@ internal fun PdfPageComposable(
     var actualBitmapHeightPx by remember { mutableIntStateOf(0) }
     var currentPageRotation by remember { mutableIntStateOf(0) }
 
+    val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
+
     val canvasWidthPx = remember { mutableFloatStateOf(0f) }
     val canvasHeightPx = remember { mutableFloatStateOf(0f) }
 
@@ -687,6 +709,15 @@ internal fun PdfPageComposable(
             activeTheme.backgroundColor
         }
     }
+    val textureBitmap = remember(activeTheme.textureId) {
+        loadReaderTextureBitmap(context, activeTheme.textureId)
+    }
+    val effectiveTextureAlpha = remember(activeTheme.textureId, activeTextureAlpha) {
+        if (activeTheme.textureId == null) 0f else activeTextureAlpha.coerceIn(0f, 1f)
+    }
+    val textureBlendMode = remember(activeTheme.textureId, activeTheme.isDark, activeTheme.id) {
+        if (activeTheme.isDark || activeTheme.id == "reverse") BlendMode.Screen else BlendMode.Multiply
+    }
 
     val centeringOffsetX by remember(canvasWidthPx.floatValue, actualBitmapWidthPx) {
         derivedStateOf { (canvasWidthPx.floatValue - actualBitmapWidthPx) / 2f }
@@ -1146,13 +1177,13 @@ internal fun PdfPageComposable(
                     try {
                         val pagePtr = pageWrapper.getNativePointer()
                         if (pagePtr != 0L) {
-                            val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
+                            val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
                             val imgRects = mutableListOf()
                             val outRect = FloatArray(4)
 
                             for (i in 0 until objCount) {
-                                if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
-                                    if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
+                                if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
+                                    if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
                                         val pdfRectF = android.graphics.RectF(
                                             min(outRect[0], outRect[2]),
                                             max(outRect[1], outRect[3]),
@@ -1180,26 +1211,26 @@ internal fun PdfPageComposable(
                         val pagePtr = pageWrapper.getNativePointer()
 
                         if (pagePtr != 0L) {
-                            val count = NativePdfiumBridge.getAnnotCount(pagePtr)
+                            val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
                             Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
                             if (count > 0) {
-                                val count = NativePdfiumBridge.getAnnotCount(pagePtr)
+                                val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
                                 Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
                                 if (count > 0) {
                                     val allAnnots = (0 until count).mapNotNull { i ->
-                                        val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, i)
+                                        val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
                                         if (subtype == annotLink) return@mapNotNull null
 
-                                        var contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "Contents")
+                                        var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
                                         if (contents.isNullOrBlank()) {
-                                            contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "RC")
+                                            contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
                                         }
 
-                                        val name = NativePdfiumBridge.getAnnotString(pagePtr, i, "NM")
-                                        val irt = NativePdfiumBridge.getAnnotString(pagePtr, i, "IRT")
-                                        val author = NativePdfiumBridge.getAnnotString(pagePtr, i, "T")
+                                        val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
+                                        val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
+                                        val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
 
-                                        val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, i)
+                                        val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
                                         val pdfRectF = if (pdfRectArray != null) {
                                             android.graphics.RectF(
                                                 min(pdfRectArray[0], pdfRectArray[2]),
@@ -1311,8 +1342,7 @@ internal fun PdfPageComposable(
     }
 
     LaunchedEffect(
-        effectiveScale,
-        effectiveOffset,
+        needsTilingNow,
         actualBitmapWidthPx,
         actualBitmapHeightPx,
         canvasWidthPx.floatValue,
@@ -1323,8 +1353,7 @@ internal fun PdfPageComposable(
         virtualPage,
         isActivePage
     ) {
-        val needsTiling = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
-        if (!needsTiling) {
+        if (!needsTilingNow) {
             if (tiles.isNotEmpty()) {
                 val oldTiles = tiles
                 tiles = emptyList()
@@ -1358,24 +1387,37 @@ internal fun PdfPageComposable(
 
             snapshotFlow {
                 val rect = visibleScreenRect()
-                if (rect == null) null
-                else {
+                val observedScale = latestEffectiveScale
+                if (isVerticalScroll && rect != null) {
                     val qTop = rect.top / (tileSizePx / 2)
                     val qLeft = rect.left / (tileSizePx / 2)
                     val qBottom = rect.bottom / (tileSizePx / 2)
                     val qRight = rect.right / (tileSizePx / 2)
-                    listOf(qTop, qLeft, qBottom, qRight)
+                    listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt())
+                } else if (!isVerticalScroll) {
+                    val observedOffset = latestEffectiveOffset
+                    val pivotX = screenWidth / 2f
+                    val pivotY = screenHeight / 2f
+                    val pxTl = (((0 - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX
+                    val pyTl = (((0 - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY
+                    val pxBr = (((screenWidth - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX
+                    val pyBr = (((screenHeight - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY
+
+                    val qTop = pyTl.toInt() / (tileSizePx / 2)
+                    val qLeft = pxTl.toInt() / (tileSizePx / 2)
+                    val qBottom = pyBr.toInt() / (tileSizePx / 2)
+                    val qRight = pxBr.toInt() / (tileSizePx / 2)
+                    listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt())
+                } else {
+                    null
                 }
             }.conflate().collectLatest { _ ->
 
-                delay(150)
-
                 val tileCalcStart = System.nanoTime()
                 if (!isActive) return@collectLatest
 
-                if (isScrolling && effectiveScale > 1f) {
-                    return@collectLatest
-                }
+                val renderScale = latestEffectiveScale
+                val renderOffset = latestEffectiveOffset
 
                 val currentVisibleRect = visibleScreenRect()
 
@@ -1404,14 +1446,14 @@ internal fun PdfPageComposable(
                     val pivotX = screenWidth / 2f
                     val pivotY = screenHeight / 2f
 
-                    pxTl = (((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
-                    pyTl = (((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
-                    pxBr = (((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
-                    pyBr = (((screenHeight - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
+                    pxTl = (((0 - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
+                    pyTl = (((0 - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
+                    pxBr = (((screenWidth - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
+                    pyBr = (((screenHeight - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
                 }
 
                 val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt())
-                val inset = if (effectiveScale > 2f) 0 else -tileSizePx
+                val inset = if (renderScale > 2f) 0 else -tileSizePx
                 visibleBitmapRect.inset(inset, inset)
 
                 val requiredTileIds = mutableSetOf()
@@ -1431,8 +1473,10 @@ internal fun PdfPageComposable(
 
                 val currentTileIds = tiles.map { it.tileId }.toSet()
 
-                val scaleTolerance = 0.05f
-                val validCurrentTileIds = tiles.filter { abs(it.renderScale - effectiveScale) <= scaleTolerance }.map { it.tileId }.toSet()
+                val scaleTolerance = PDF_TILE_SCALE_TOLERANCE
+                val validCurrentTileIds = tiles.filter { abs(it.renderScale - renderScale) <= scaleTolerance }.map { it.tileId }.toSet()
+                val tilesToRenderIds = requiredTileIds - validCurrentTileIds
+                val tilesToRecycleIds = currentTileIds - requiredTileIds
 
                 val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f
                 if (duration > 2f) {
@@ -1441,21 +1485,26 @@ internal fun PdfPageComposable(
                     )
                 }
 
-                if (requiredTileIds != validCurrentTileIds) {
-
-                    val tilesToRenderIds = requiredTileIds - validCurrentTileIds
-                    val tilesToRecycleIds = currentTileIds - requiredTileIds
-
-                    if (tilesToRecycleIds.isNotEmpty()) {
-                        val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds }
-                        tiles = tilesToKeep
-                        withContext(Dispatchers.IO) {
-                            tilesToRecycle.forEach { PdfBitmapPool.recycle(it.bitmap) }
-                        }
+                if (tilesToRecycleIds.isNotEmpty()) {
+                    val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds }
+                    tiles = tilesToKeep
+                    withContext(Dispatchers.IO) {
+                        tilesToRecycle.forEach { PdfBitmapPool.recycle(it.bitmap) }
                     }
+                }
 
+                if (isScrolling && renderScale > 1f) {
+                    return@collectLatest
+                }
+
+                if (requiredTileIds != validCurrentTileIds) {
                     if (tilesToRenderIds.isNotEmpty()) {
-                        withContext(Dispatchers.IO) {
+                        delay(PDF_TILE_IDLE_RENDER_DELAY_MS)
+                        if (!isActive) return@collectLatest
+                        if (isScrolling && latestEffectiveScale > 1f) return@collectLatest
+
+                        val renderedTiles = withContext(Dispatchers.IO) {
+                            val newTiles = mutableListOf()
                             tilesToRenderIds.forEach { tileId ->
                                 if (!isActive) return@forEach
 
@@ -1469,14 +1518,18 @@ internal fun PdfPageComposable(
                                     (col + 1) * tileSizePx,
                                     (row + 1) * tileSizePx
                                 )
-                                val tileRenderSize = (tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
+                                val tileRenderScale = min(
+                                    renderScale,
+                                    PDF_MAX_TILE_BITMAP_SIZE_PX.toFloat() / tileSizePx.toFloat()
+                                )
+                                val tileRenderSize = (tileSizePx * tileRenderScale).toInt().coerceAtLeast(1)
 
                                 val tileBitmap = PdfBitmapPool.get(tileRenderSize)
 
-                                val fullPageRenderWidth = (actualBitmapWidthPx * effectiveScale).toInt()
-                                val fullPageRenderHeight = (actualBitmapHeightPx * effectiveScale).toInt()
-                                val tileRenderX = (col * tileSizePx * effectiveScale).toInt()
-                                val tileRenderY = (row * tileSizePx * effectiveScale).toInt()
+                                val fullPageRenderWidth = (actualBitmapWidthPx * tileRenderScale).toInt()
+                                val fullPageRenderHeight = (actualBitmapHeightPx * tileRenderScale).toInt()
+                                val tileRenderX = (col * tileSizePx * tileRenderScale).toInt()
+                                val tileRenderY = (row * tileSizePx * tileRenderScale).toInt()
 
                                 page?.renderPageBitmap(
                                     bitmap = tileBitmap,
@@ -1487,24 +1540,26 @@ internal fun PdfPageComposable(
                                     renderAnnot = true
                                 )
 
-                                val newTile = PdfTile(tileBitmap, tileRect, tileId, effectiveScale)
-                                var handedOver = false
-                                try {
-                                    withContext(Dispatchers.Main) {
-                                        val oldTile = tiles.find { it.tileId == tileId }
-                                        tiles = tiles.filter { it.tileId != tileId } + newTile
-                                        handedOver = true
+                                newTiles += PdfTile(tileBitmap, tileRect, tileId, renderScale)
+                            }
+                            newTiles
+                        }
 
-                                        oldTile?.let {
-                                            coroutineScope.launch(Dispatchers.IO) {
-                                                PdfBitmapPool.recycle(it.bitmap)
-                                            }
-                                        }
-                                    }
-                                } finally {
-                                    if (!handedOver) {
-                                        PdfBitmapPool.recycle(tileBitmap)
-                                    }
+                        if (!isActive) {
+                            withContext(Dispatchers.IO) {
+                                renderedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
+                            }
+                            return@collectLatest
+                        }
+
+                        if (renderedTiles.isNotEmpty()) {
+                            val renderedIds = renderedTiles.map { it.tileId }.toSet()
+                            val replacedTiles = tiles.filter { it.tileId in renderedIds }
+                            tiles = tiles.filterNot { it.tileId in renderedIds } + renderedTiles
+
+                            if (replacedTiles.isNotEmpty()) {
+                                coroutineScope.launch(Dispatchers.IO) {
+                                    replacedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
                                 }
                             }
                         }
@@ -2799,7 +2854,7 @@ internal fun PdfPageComposable(
 
                                     Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr")
 
-                                    val linkInfo = NativePdfiumBridge.getLinkInfoAtPoint(
+                                    val linkInfo = PdfiumEngineProvider.bridge.getLinkInfoAtPoint(
                                         docPtr, pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()
                                     )
 
@@ -2818,7 +2873,7 @@ internal fun PdfPageComposable(
                                         }
                                     }
 
-                                    val clickHandled = NativePdfiumBridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble())
+                                    val clickHandled = PdfiumEngineProvider.bridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble())
                                     if (clickHandled) {
                                         return@withContext 2
                                     }
@@ -3007,25 +3062,21 @@ internal fun PdfPageComposable(
                 awaitEachGesture {
                     @Suppress("UnusedVariable", "Unused") val down =
                         awaitFirstDown(requireUnconsumed = false)
+                    paginationPanFlingJob?.cancel()
+                    paginationPanFlingJob = null
                     velocityTracker.resetTracking()
 
                     var mode = 0
                     var accumulatedZoom = 1f
                     var accumulatedPan = Offset.Zero
                     var swipeAccumulatorX = 0f
+                    var velocityAccumulator = Offset.Zero
 
                     do {
                         val event = awaitPointerEvent()
                         val canceled = event.changes.any { it.isConsumed }
                         val pointerCount = event.changes.size
 
-                        val currentCentroid = event.calculateCentroid(useCurrent = true)
-                        if (pointerCount > 0 && currentCentroid != Offset.Unspecified) {
-                            velocityTracker.addPosition(
-                                event.changes[0].uptimeMillis, currentCentroid
-                            )
-                        }
-
                         if (!canceled) {
                             val rawPanChange = event.calculatePan()
                             val panChange = if (isScrollLocked && pointerCount == 1) {
@@ -3077,6 +3128,14 @@ internal fun PdfPageComposable(
                                     Timber.tag("PdfZoomDebug").v("Panning: Offset $offset -> $newX, $newY (Max: $maxOffsetX, $maxOffsetY)")
                                     offset = Offset(newX, newY)
 
+                                    if (event.changes.isNotEmpty() && panChange != Offset.Zero) {
+                                        velocityAccumulator += panChange
+                                        velocityTracker.addPosition(
+                                            event.changes[0].uptimeMillis,
+                                            velocityAccumulator
+                                        )
+                                    }
+
                                     event.changes.forEach {
                                         if (it.positionChanged()) it.consume()
                                     }
@@ -3189,38 +3248,54 @@ internal fun PdfPageComposable(
                             }
                         }
                     } else if (mode == 1 && scale > 1f) {
-                        val velocity = velocityTracker.calculateVelocity()
                         val contentWidth = actualBitmapWidthPx * scale
                         val contentHeight = actualBitmapHeightPx * scale
                         val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f
                         val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f
 
-                        val startX = offset.x
-                        val startY = offset.y
+                        val velocity = velocityTracker.calculateVelocity()
+                        val flingX = if (!isScrollLocked && abs(velocity.x) > PDF_PAGINATION_PAN_FLING_MIN_VELOCITY) {
+                            velocity.x * PDF_PAGINATION_PAN_FLING_MULTIPLIER
+                        } else {
+                            0f
+                        }
+                        val flingY = if (abs(velocity.y) > PDF_PAGINATION_PAN_FLING_MIN_VELOCITY) {
+                            velocity.y * PDF_PAGINATION_PAN_FLING_MULTIPLIER
+                        } else {
+                            0f
+                        }
 
-                        coroutineScope.launch {
-                            coroutineScope {
-                                launch {
-                                    if (!isScrollLocked) {
-                                        Animatable(startX).animateDecay(
-                                            velocity.x, decay
-                                        ) {
-                                            val newX = value.coerceIn(
-                                                -maxOffsetX, maxOffsetX
-                                            )
-                                            offset = offset.copy(x = newX)
+                        if (flingX == 0f && flingY == 0f) {
+                            offset = Offset(
+                                x = offset.x.coerceIn(-maxOffsetX, maxOffsetX),
+                                y = offset.y.coerceIn(-maxOffsetY, maxOffsetY)
+                            )
+                        } else {
+                            val startOffset = offset
+                            paginationPanFlingJob = coroutineScope.launch {
+                                try {
+                                    coroutineScope {
+                                        launch {
+                                            if (flingX != 0f) {
+                                                Animatable(startOffset.x).animateDecay(flingX, decay) {
+                                                    offset = offset.copy(
+                                                        x = value.coerceIn(-maxOffsetX, maxOffsetX)
+                                                    )
+                                                }
+                                            }
+                                        }
+                                        launch {
+                                            if (flingY != 0f) {
+                                                Animatable(startOffset.y).animateDecay(flingY, decay) {
+                                                    offset = offset.copy(
+                                                        y = value.coerceIn(-maxOffsetY, maxOffsetY)
+                                                    )
+                                                }
+                                            }
                                         }
                                     }
-                                }
-                                launch {
-                                    Animatable(startY).animateDecay(
-                                        velocity.y, decay
-                                    ) {
-                                        val newY = value.coerceIn(
-                                            -maxOffsetY, maxOffsetY
-                                        )
-                                        offset = offset.copy(y = newY)
-                                    }
+                                } finally {
+                                    paginationPanFlingJob = null
                                 }
                             }
                         }
@@ -3256,7 +3331,7 @@ internal fun PdfPageComposable(
                         }
 
                         val buttons = currentEvent.buttons
-                        Timber.tag("StylusEraserDiagnostic").d(
+                        Timber.tag("StylusDebug").d(
                             "Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
                         )
 
@@ -3881,7 +3956,10 @@ internal fun PdfPageComposable(
                         stableColorFilter,
                         isDarkMode,
                         excludeImages,
-                        stableImageRects
+                        stableImageRects,
+                        textureBitmap,
+                        effectiveTextureAlpha,
+                        textureBlendMode
                     ) {
                         Timber.tag("PdfDrawPerf").v(
                             "STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
@@ -3899,7 +3977,10 @@ internal fun PdfPageComposable(
                             colorFilter = stableColorFilter,
                             isDarkMode = isDarkMode,
                             excludeImages = excludeImages,
-                            imageRects = stableImageRects
+                            imageRects = stableImageRects,
+                            textureBitmap = StableHolder(textureBitmap),
+                            textureAlpha = effectiveTextureAlpha,
+                            textureBlendMode = textureBlendMode
                         )
                     }
 
@@ -4263,7 +4344,10 @@ private fun PdfBitmapLayer(
     colorFilter: ColorFilter? = null,
     isDarkMode: Boolean = false,
     excludeImages: Boolean = false,
-    imageRects: List = emptyList()
+    imageRects: List = emptyList(),
+    textureBitmap: ImageBitmap? = null,
+    textureAlpha: Float = 0f,
+    textureBlendMode: BlendMode = BlendMode.Multiply
 ) {
     Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
         translate(left = centeringOffsetX, top = centeringOffsetY) {
@@ -4363,6 +4447,15 @@ private fun PdfBitmapLayer(
                             }
                         }
                     }
+
+                    if (textureBitmap != null && textureAlpha > 0f) {
+                        drawRect(
+                            brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
+                            size = Size(dstW.toFloat(), dstH.toFloat()),
+                            blendMode = textureBlendMode,
+                            alpha = textureAlpha
+                        )
+                    }
                 }
             }
         }
@@ -4884,7 +4977,10 @@ private fun PdfPageStaticLayer(data: PageStaticData) {
         colorFilter = data.colorFilter.item,
         isDarkMode = data.isDarkMode,
         excludeImages = data.excludeImages,
-        imageRects = data.imageRects.item
+        imageRects = data.imageRects.item,
+        textureBitmap = data.textureBitmap.item,
+        textureAlpha = data.textureAlpha,
+        textureBlendMode = data.textureBlendMode
     )
 }
 
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt
index 044edde..0209c26 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt
@@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.toArgb
 import androidx.core.content.edit
 import com.aryan.reader.BuildConfig
 import com.aryan.reader.ReaderTheme
+import com.aryan.reader.ReaderTexture
 import com.aryan.reader.epubreader.SystemUiMode
 
 internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll"
@@ -39,6 +40,8 @@ private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
 private const val PDF_THEME_KEY = "pdf_reader_theme"
 private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
 private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
+private const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
+private const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
 private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
 internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
 
@@ -76,7 +79,13 @@ val PdfBuiltInThemes = listOf(
     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("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 loadPdfHiddenTools(context: Context): Set {
@@ -89,6 +98,32 @@ internal fun savePdfHiddenTools(context: Context, hiddenTools: Set) {
     prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) }
 }
 
+internal fun loadPdfToolOrder(context: Context): List {
+    val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+    val savedTools = prefs.getString(PDF_TOOL_ORDER_KEY, null)
+        ?.split(',')
+        ?.filter { it.isNotBlank() }
+        ?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
+        .orEmpty()
+    return (savedTools + PdfReaderTool.entries.filterNot { it in savedTools }).distinct()
+}
+
+internal fun savePdfToolOrder(context: Context, toolOrder: List) {
+    val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+    prefs.edit { putString(PDF_TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
+}
+
+internal fun loadPdfBottomTools(context: Context): Set {
+    val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+    val defaultBottomTools = PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
+    return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools
+}
+
+internal fun savePdfBottomTools(context: Context, bottomTools: Set) {
+    val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+    prefs.edit { putStringSet(PDF_BOTTOM_TOOLS_KEY, bottomTools) }
+}
+
 internal fun loadCustomHighlightColors(context: Context): Map {
     val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
     return PdfHighlightColor.entries.associateWith {
@@ -139,7 +174,7 @@ internal fun loadPdfThemeId(context: Context): String {
 }
 
 internal fun loadUseOnlineDict(context: Context): Boolean {
-    @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
+    @Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) return false
     val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
     return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
 }
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt
index d531e0b..7e436fd 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfSettingsSheets.kt
@@ -1,8 +1,9 @@
-@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
+@file:OptIn(ExperimentalMaterial3Api::class)
 
 package com.aryan.reader.pdf
 
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
@@ -11,94 +12,346 @@ import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.navigationBars
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.LazyListState
 import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.zIndex
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
 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.LockOpen
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Check
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.Switch
 import androidx.compose.material3.Text
 import androidx.compose.material3.rememberModalBottomSheetState
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
 import com.aryan.reader.R
 import com.aryan.reader.epubreader.OptionSegmentedControl
 import com.aryan.reader.epubreader.SystemUiMode
 
+
+enum class PdfFlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
+
+data class PdfFlatToolItem(
+    val id: String,
+    val type: PdfFlatItemType,
+    val tool: PdfReaderTool? = null,
+    val section: PdfToolbarSection? = null,
+    val title: String? = null
+)
+
+fun sanitizePdfPlaceholders(list: List): List {
+    val result = mutableListOf()
+    val sectionMap = mutableMapOf>()
+    PdfToolbarSection.entries.forEach { sectionMap[it] = mutableListOf() }
+
+    list.forEach { item ->
+        if (item.type == PdfFlatItemType.TOOL) {
+            item.section?.let { sectionMap[it]?.add(item) }
+        }
+    }
+
+    PdfToolbarSection.entries.forEach { section ->
+        result.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
+        val tools = sectionMap[section] ?: emptyList()
+        if (tools.isEmpty()) {
+            result.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
+        } else {
+            result.addAll(tools)
+        }
+    }
+
+    list.filter { it.type == PdfFlatItemType.MORE_HEADER || it.type == PdfFlatItemType.MORE_TOOL }.forEach {
+        result.add(it)
+    }
+
+    return result
+}
+
+class PdfDragDropState(
+    val lazyListState: LazyListState,
+    val onMove: (String, String) -> Unit
+) {
+    var draggedItemId by mutableStateOf(null)
+    var dragOffset by mutableStateOf(Offset.Zero)
+
+    fun onDragStart(id: String) { draggedItemId = id; dragOffset = Offset.Zero }
+    fun onDrag(delta: Offset) {
+        val draggedId = draggedItemId ?: return
+        dragOffset += delta
+        val visibleItems = lazyListState.layoutInfo.visibleItemsInfo
+        val currentItem = visibleItems.find { it.key == draggedId } ?: return
+        val center = currentItem.offset + dragOffset.y + currentItem.size / 2f
+        val targetItem = visibleItems.find { it.key != draggedId && center >= it.offset && center <= (it.offset + it.size) }
+        if (targetItem != null) {
+            onMove(draggedId, targetItem.key.toString())
+            dragOffset = dragOffset.copy(y = dragOffset.y - (targetItem.offset - currentItem.offset))
+        }
+    }
+    fun onDragEnd() { draggedItemId = null; dragOffset = Offset.Zero }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun PdfCustomizeToolsSheet(
     hiddenTools: Set,
+    toolOrder: List,
+    bottomTools: Set,
     onUpdate: (Set) -> Unit,
+    onOrderUpdate: (List) -> Unit,
+    onPlacementUpdate: (Set) -> Unit,
     onDismiss: () -> Unit
 ) {
-    ModalBottomSheet(
-        onDismissRequest = onDismiss,
-        contentWindowInsets = { WindowInsets.navigationBars }
-    ) {
-        Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
-            Text(
-                text = stringResource(R.string.title_customize_toolbar),
-                style = MaterialTheme.typography.headlineSmall,
-                fontWeight = FontWeight.Bold,
-                color = MaterialTheme.colorScheme.onSurface
-            )
-            Spacer(modifier = Modifier.height(8.dp))
-            Text(
-                text = stringResource(R.string.desc_customize_toolbar),
-                style = MaterialTheme.typography.bodyMedium,
-                color = MaterialTheme.colorScheme.onSurfaceVariant
-            )
-            Spacer(modifier = Modifier.height(16.dp))
+    val reorderableToolbarTools = setOf(
+        PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
+        PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
+        PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
+        PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS
+    )
 
-            LazyColumn(modifier = Modifier.fillMaxWidth()) {
-                PdfReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
-                    item {
-                        Text(
-                            text = category,
-                            style = MaterialTheme.typography.labelMedium,
-                            color = MaterialTheme.colorScheme.primary,
-                            modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
-                        )
+    var localHiddenTools by remember { mutableStateOf(hiddenTools) }
+    var flatItems by remember {
+        mutableStateOf>(
+            run {
+                val toolbarTools = toolOrder.filter { it in reorderableToolbarTools }
+                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 reorderableToolbarTools }
+
+                val list = mutableListOf()
+
+                PdfToolbarSection.entries.forEach { section ->
+                    val tools = when(section) {
+                        PdfToolbarSection.TOP -> topTools
+                        PdfToolbarSection.BOTTOM -> bottomToolsList
+                        PdfToolbarSection.HIDDEN -> hiddenToolsList
                     }
-                    items(tools) { tool ->
-                        Row(
+                    list.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
+                    if (tools.isEmpty()) {
+                        list.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
+                    } else {
+                        tools.forEach { tool ->
+                            list.add(PdfFlatToolItem("tool_${tool.name}", PdfFlatItemType.TOOL, tool = tool, section = section))
+                        }
+                    }
+                }
+
+                list.add(PdfFlatToolItem("more_header", PdfFlatItemType.MORE_HEADER, title = "More menu"))
+                moreTools.forEach { tool ->
+                    list.add(PdfFlatToolItem("more_${tool.name}", PdfFlatItemType.MORE_TOOL, tool = tool))
+                }
+                list
+            }
+        )
+    }
+
+    val commitDragDrop = {
+        val newHidden = localHiddenTools.filter { toolName ->
+            toolOrder.find { it.name == toolName } !in reorderableToolbarTools
+        }.toMutableSet()
+
+        val newBottom = mutableSetOf()
+        val newOrder = mutableListOf()
+
+        flatItems.forEach { item ->
+            if (item.type == PdfFlatItemType.TOOL && item.tool != null) {
+                newOrder.add(item.tool)
+                if (item.section == PdfToolbarSection.HIDDEN) newHidden.add(item.tool.name)
+                if (item.section == PdfToolbarSection.BOTTOM) newBottom.add(item.tool.name)
+            }
+        }
+
+        val moreTools = flatItems.filter { it.type == PdfFlatItemType.MORE_TOOL }.mapNotNull { it.tool }
+        newOrder.addAll(moreTools)
+
+        localHiddenTools = newHidden
+        onUpdate(newHidden)
+        onPlacementUpdate(newBottom)
+        onOrderUpdate(newOrder)
+    }
+
+    val lazyListState = rememberLazyListState()
+    val dragDropState = remember {
+        PdfDragDropState(lazyListState) { fromKey, toKey ->
+            val fromIndex = flatItems.indexOfFirst { it.id == fromKey }
+            val toIndex = flatItems.indexOfFirst { it.id == toKey }
+            if (fromIndex == -1 || toIndex == -1 || fromIndex == toIndex) return@PdfDragDropState
+
+            val fromItem = flatItems[fromIndex]
+            if (fromItem.type != PdfFlatItemType.TOOL) return@PdfDragDropState
+
+            val toItem = flatItems[toIndex]
+            if (toItem.type == PdfFlatItemType.MORE_HEADER || toItem.type == PdfFlatItemType.MORE_TOOL) return@PdfDragDropState
+
+            val newList = flatItems.toMutableList()
+            val movedItem = newList.removeAt(fromIndex)
+
+            val newToIndex = newList.indexOfFirst { it.id == toKey }
+            val insertIndex = if (fromIndex < toIndex) newToIndex + 1 else newToIndex
+
+            newList.add(insertIndex, movedItem)
+
+            var actualSection = movedItem.section
+            for (i in insertIndex downTo 0) {
+                val item = newList[i]
+                if (item.type == PdfFlatItemType.SECTION_HEADER) {
+                    actualSection = item.section
+                    break
+                }
+            }
+
+            newList[insertIndex] = movedItem.copy(section = actualSection)
+            flatItems = newList
+        }
+    }
+
+    Dialog(
+        onDismissRequest = onDismiss,
+        properties = DialogProperties(usePlatformDefaultWidth = false)
+    ) {
+        androidx.compose.material3.Surface(
+            modifier = Modifier
+                .fillMaxSize()
+                .windowInsetsPadding(WindowInsets.navigationBars),
+            color = MaterialTheme.colorScheme.surface
+        ) {
+            Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp)) {
+                Row(
+                    modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
+                    verticalAlignment = Alignment.CenterVertically
+                ) {
+                    Text(
+                        text = stringResource(R.string.title_customize_toolbar),
+                        style = MaterialTheme.typography.headlineSmall,
+                        fontWeight = FontWeight.Bold,
+                        color = MaterialTheme.colorScheme.onSurface,
+                        modifier = Modifier.weight(1f)
+                    )
+                    IconButton(onClick = onDismiss) {
+                        Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
+                    }
+                }
+
+                LazyColumn(
+                    state = lazyListState,
+                    modifier = Modifier.fillMaxSize(),
+                    contentPadding = PaddingValues(bottom = 24.dp)
+                ) {
+                    items(flatItems, key = { it.id }) { item ->
+                        val isDragged = item.id == dragDropState.draggedItemId
+                        val zIndex = if (isDragged) 1f else 0f
+                        val elevation = if (isDragged) 8.dp else 0.dp
+                        val scale = if (isDragged) 1.03f else 1f
+                        val translationY = if (isDragged) dragDropState.dragOffset.y else 0f
+
+                        Box(
                             modifier = Modifier
                                 .fillMaxWidth()
-                                .clip(RoundedCornerShape(8.dp))
-                                .clickable {
-                                    val newSet = hiddenTools.toMutableSet()
-                                    if (newSet.contains(tool.name)) newSet.remove(tool.name)
-                                    else newSet.add(tool.name)
-                                    onUpdate(newSet)
+                                .then(if (isDragged) Modifier else Modifier.animateItem())
+                                .zIndex(zIndex)
+                                .graphicsLayer {
+                                    this.translationY = translationY
+                                    this.scaleX = scale
+                                    this.scaleY = scale
+                                    this.shadowElevation = elevation.toPx()
                                 }
-                                .padding(vertical = 12.dp, horizontal = 8.dp),
-                            verticalAlignment = Alignment.CenterVertically,
-                            horizontalArrangement = Arrangement.SpaceBetween
                         ) {
-                            Text(
-                                text = tool.title,
-                                style = MaterialTheme.typography.bodyLarge,
-                                color = MaterialTheme.colorScheme.onSurface
-                            )
-                            Switch(
-                                checked = !hiddenTools.contains(tool.name),
-                                onCheckedChange = { isVisible ->
-                                    val newSet = hiddenTools.toMutableSet()
-                                    if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
-                                    onUpdate(newSet)
+                            when (item.type) {
+                                PdfFlatItemType.SECTION_HEADER -> {
+                                    Text(
+                                        text = item.title ?: "",
+                                        style = MaterialTheme.typography.titleMedium,
+                                        fontWeight = FontWeight.Bold,
+                                        color = MaterialTheme.colorScheme.onSurface,
+                                        modifier = Modifier.padding(top = 16.dp, bottom = 8.dp, start = 4.dp)
+                                    )
                                 }
-                            )
+                                PdfFlatItemType.EMPTY_PLACEHOLDER -> {
+                                    Box(
+                                        modifier = Modifier
+                                            .fillMaxWidth()
+                                            .height(64.dp)
+                                            .padding(vertical = 4.dp)
+                                            .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
+                                        contentAlignment = Alignment.Center
+                                    ) {
+                                        Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant)
+                                    }
+                                }
+                                PdfFlatItemType.TOOL -> {
+                                    PdfToolbarDragRow(
+                                        tool = item.tool!!,
+                                        isDragging = isDragged,
+                                        onDragStart = { dragDropState.onDragStart(item.id) },
+                                        onDrag = { dragDropState.onDrag(it) },
+                                        onDragEnd = {
+                                            dragDropState.onDragEnd()
+                                            flatItems = sanitizePdfPlaceholders(flatItems).toList()
+                                            commitDragDrop()
+                                        }
+                                    )
+                                }
+                                PdfFlatItemType.MORE_HEADER -> {
+                                    Text(
+                                        text = item.title ?: "More menu",
+                                        style = MaterialTheme.typography.labelMedium,
+                                        color = MaterialTheme.colorScheme.primary,
+                                        modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
+                                    )
+                                }
+                                PdfFlatItemType.MORE_TOOL -> {
+                                    PdfMoreToolVisibilityRow(
+                                        title = item.tool!!.title,
+                                        visible = !localHiddenTools.contains(item.tool.name),
+                                        onToggle = {
+                                            localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
+                                                localHiddenTools - item.tool.name
+                                            } else {
+                                                localHiddenTools + item.tool.name
+                                            }
+                                            onUpdate(localHiddenTools)
+                                        }
+                                    )
+                                }
+                            }
                         }
                     }
                 }
@@ -107,6 +360,154 @@ fun PdfCustomizeToolsSheet(
     }
 }
 
+@Composable
+private fun PdfToolbarDragRow(
+    tool: PdfReaderTool,
+    isDragging: Boolean,
+    onDragStart: () -> Unit,
+    onDrag: (Offset) -> Unit,
+    onDragEnd: () -> Unit
+) {
+    androidx.compose.material3.Surface(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(vertical = 4.dp),
+        shape = RoundedCornerShape(12.dp),
+        color = if (isDragging) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+    ) {
+        Row(
+            modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            PdfToolPreviewIcon(tool)
+            Spacer(Modifier.width(16.dp))
+            Text(
+                text = tool.title,
+                style = MaterialTheme.typography.bodyLarge,
+                color = MaterialTheme.colorScheme.onSurface,
+                modifier = Modifier.weight(1f)
+            )
+            Icon(
+                Icons.Default.Menu,
+                contentDescription = "Drag to reorder",
+                tint = MaterialTheme.colorScheme.onSurfaceVariant,
+                modifier = Modifier
+                    .size(48.dp)
+                    .padding(12.dp)
+                    .pointerInput(tool) {
+                        detectDragGestures(
+                            onDragStart = { onDragStart() },
+                            onDrag = { change, dragAmount ->
+                                change.consume()
+                                onDrag(dragAmount)
+                            },
+                            onDragEnd = onDragEnd,
+                            onDragCancel = onDragEnd
+                        )
+                    }
+            )
+        }
+    }
+}
+
+@Composable
+private fun PdfToolbarDragRow(
+    tool: PdfReaderTool,
+    isDragging: Boolean,
+    onBounds: (Rect) -> Unit,
+    onDragStart: (Offset) -> Unit,
+    onDrag: (Offset) -> Unit,
+    onDragEnd: () -> Unit
+) {
+    var bounds by remember { mutableStateOf(null) }
+    androidx.compose.material3.Surface(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(vertical = 4.dp)
+            .onGloballyPositioned {
+                bounds = it.boundsInWindow()
+                onBounds(it.boundsInWindow())
+            }
+            .pointerInput(tool) {
+                detectDragGesturesAfterLongPress(
+                    onDragStart = { onDragStart(bounds?.center ?: Offset.Zero) },
+                    onDragEnd = onDragEnd,
+                    onDragCancel = onDragEnd,
+                    onDrag = { change, dragAmount ->
+                        change.consume()
+                        onDrag(dragAmount)
+                    }
+                )
+            },
+        shape = RoundedCornerShape(12.dp),
+        color = if (isDragging) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface
+    ) {
+        Row(
+            modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            PdfToolPreviewIcon(tool)
+            Spacer(Modifier.width(12.dp))
+            Text(
+                text = tool.title,
+                style = MaterialTheme.typography.bodyLarge,
+                color = MaterialTheme.colorScheme.onSurface,
+                modifier = Modifier.weight(1f)
+            )
+            Icon(Icons.Default.Menu, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+        }
+    }
+}
+
+@Composable
+private fun PdfMoreToolVisibilityRow(
+    title: String,
+    visible: Boolean,
+    onToggle: () -> Unit
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clip(RoundedCornerShape(8.dp))
+            .clickable(onClick = onToggle)
+            .padding(vertical = 12.dp, horizontal = 8.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Text(
+            text = title,
+            style = MaterialTheme.typography.bodyLarge,
+            color = MaterialTheme.colorScheme.onSurface,
+            modifier = Modifier.weight(1f)
+        )
+        if (visible) {
+            Icon(Icons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+        }
+    }
+}
+
+enum class PdfToolbarSection(val title: String) {
+    TOP("Top Bar"),
+    BOTTOM("Bottom Bar"),
+    HIDDEN("Hidden Tools")
+}
+
+@Composable
+private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
+    when (tool) {
+        PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        PdfReaderTool.HIGHLIGHT_ALL -> Icon(painterResource(id = R.drawable.highlight_text), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
+        else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
+    }
+}
+
 @Composable
 fun PdfVisualOptionsSheet(
     systemUiMode: SystemUiMode,
@@ -150,4 +551,4 @@ fun PdfVisualOptionsSheet(
             )
         }
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt
index b10cbe9..c9a0616 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt
@@ -34,7 +34,9 @@ object PdfToHtmlGenerator {
         }
 
         try {
-            val doc = pdfiumCore.newDocument(pfd)
+            val doc = PdfiumEngineProvider.withPdfium {
+                pdfiumCore.newDocument(pfd)
+            }
             val totalPages = doc.getPageCount()
             Timber.tag(TAG).d("Document loaded. Total pages: $totalPages")
 
@@ -56,7 +58,9 @@ object PdfToHtmlGenerator {
                 writer.write(buildGlobalHtmlFooter())
             }
 
-            doc.close()
+            PdfiumEngineProvider.withPdfium {
+                doc.close()
+            }
             pfd.close()
             Timber.tag(TAG).d("generateHtmlFile SUCCESS | ${System.currentTimeMillis() - t0}ms")
             return@withContext true
@@ -137,14 +141,14 @@ object PdfToHtmlGenerator {
                     val textPagePtr = getNativePointer(textPage)
 
                     val imageElements = mutableListOf()
-                    val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
+                    val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
                     for (i in 0 until objCount) {
-                        if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) {
+                        if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) {
                             val bbox = FloatArray(4)
-                            if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
+                            if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
                                 val topY = bbox[3]
                                 val dimens = IntArray(2)
-                                val pixels = NativePdfiumBridge.extractImagePixels(pagePtr, i, dimens)
+                                val pixels = PdfiumEngineProvider.bridge.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)
@@ -175,11 +179,11 @@ object PdfToHtmlGenerator {
                     val flags: IntArray?
                     val charBoxes: FloatArray?
 
-                    synchronized(NativePdfiumBridge::class.java) {
-                        sizes     = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount)
-                        weights   = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount)
-                        flags     = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount)
-                        charBoxes = NativePdfiumBridge.getPageCharBoxes(textPagePtr, actualCount)
+                    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)
                     }
 
                     if (sizes == null || weights == null || flags == null) {
@@ -547,4 +551,4 @@ object PdfToHtmlGenerator {
         }
         return 0L
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt
index 1f2bfcd..4b593ab 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt
@@ -18,13 +18,14 @@ import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.automirrored.filled.Undo
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
 import androidx.compose.material.icons.filled.*
 import androidx.compose.material3.*
 import androidx.compose.runtime.*
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.rotate
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.res.painterResource
@@ -33,6 +34,7 @@ import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.unit.sp
 import com.aryan.reader.BuildConfig
@@ -41,9 +43,23 @@ import com.aryan.reader.R
 import com.aryan.reader.SearchState
 import com.aryan.reader.SearchTopBar
 import com.aryan.reader.TooltipIconButton
+import com.aryan.reader.areReaderAiFeaturesEnabled
 import com.aryan.reader.epubreader.SystemUiMode
 import kotlin.collections.isNotEmpty
 
+private val pdfToolbarTools = setOf(
+    PdfReaderTool.DICTIONARY,
+    PdfReaderTool.THEME,
+    PdfReaderTool.LOCK_PANNING,
+    PdfReaderTool.SLIDER,
+    PdfReaderTool.TOC,
+    PdfReaderTool.SEARCH,
+    PdfReaderTool.HIGHLIGHT_ALL,
+    PdfReaderTool.AI_FEATURES,
+    PdfReaderTool.EDIT_MODE,
+    PdfReaderTool.TTS_CONTROLS
+)
+
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 internal fun PdfTopBar(
@@ -60,6 +76,8 @@ internal fun PdfTopBar(
     totalPages: Int,
     pagerStatePageCount: Int,
     hiddenTools: Set,
+    toolOrder: List,
+    bottomTools: Set,
     isScrollLocked: Boolean,
     isEditMode: Boolean,
     displayMode: DisplayMode,
@@ -83,6 +101,16 @@ internal fun PdfTopBar(
     onShowCustomizeTools: () -> Unit,
     onShowOcrLanguage: () -> Unit,
     onShowVisualOptions: () -> Unit,
+    onShowSlider: () -> Unit,
+    onShowToc: () -> Unit,
+    onSearchClick: () -> Unit,
+    onToggleHighlights: () -> Unit,
+    onShowAiHub: () -> Unit,
+    onToggleEditMode: () -> Unit,
+    onToggleTts: () -> Unit,
+    isTtsPlayingOrLoading: Boolean,
+    showAllTextHighlights: Boolean,
+    isHighlightingLoading: Boolean,
     tapToNavigateEnabled: Boolean,
     onToggleTapToNavigate: () -> Unit,
     onChangeDisplayMode: (DisplayMode) -> Unit,
@@ -153,35 +181,89 @@ internal fun PdfTopBar(
                             modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
                         )
 
-                        if (!hiddenTools.contains(PdfReaderTool.THEME.name)) {
-                            TooltipIconButton(
-                                text = stringResource(R.string.tooltip_theme),
-                                description = stringResource(R.string.tooltip_theme_desc),
-                                onClick = onShowThemePanel
-                            ) {
-                                Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+                        toolOrder
+                            .filter { it in pdfToolbarTools && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
+                            .forEach { tool ->
+                                when (tool) {
+                                    PdfReaderTool.THEME -> TooltipIconButton(
+                                        text = stringResource(R.string.tooltip_theme),
+                                        description = stringResource(R.string.tooltip_theme_desc),
+                                        onClick = onShowThemePanel
+                                    ) {
+                                        Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), 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),
+                                        onClick = onToggleScrollLock
+                                    ) {
+                                        Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+                                    }
+                                    PdfReaderTool.DICTIONARY -> TooltipIconButton(
+                                        text = stringResource(R.string.tooltip_dictionary),
+                                        description = stringResource(R.string.tooltip_dictionary_desc),
+                                        onClick = onShowDictionarySettings
+                                    ) {
+                                        Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+                                    }
+                                    PdfReaderTool.SLIDER -> TooltipIconButton(
+                                        text = stringResource(R.string.tooltip_slider),
+                                        description = stringResource(R.string.tooltip_slider_desc),
+                                        onClick = onShowSlider,
+                                        enabled = !isTtsPlayingOrLoading
+                                    ) {
+                                        Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
+                                    }
+                                    PdfReaderTool.TOC -> TooltipIconButton(
+                                        text = stringResource(R.string.tooltip_toc),
+                                        description = stringResource(R.string.tooltip_toc_desc),
+                                        onClick = onShowToc,
+                                        enabled = !isTtsPlayingOrLoading
+                                    ) {
+                                        Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
+                                    }
+                                    PdfReaderTool.SEARCH -> TooltipIconButton(
+                                        text = stringResource(R.string.tooltip_search),
+                                        description = stringResource(R.string.tooltip_search_desc),
+                                        onClick = onSearchClick,
+                                        enabled = !isTtsPlayingOrLoading
+                                    ) {
+                                        Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
+                                    }
+                                    PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
+                                        text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
+                                        description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
+                                        onClick = onToggleHighlights
+                                    ) {
+                                        if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
+                                        else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
+                                    }
+                                    PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
+                                        TooltipIconButton(
+                                            text = stringResource(R.string.tooltip_ai),
+                                            description = stringResource(R.string.tooltip_ai_desc),
+                                            onClick = onShowAiHub
+                                        ) {
+                                            Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
+                                        }
+                                    }
+                                    PdfReaderTool.EDIT_MODE -> TooltipIconButton(
+                                        text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
+                                        description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
+                                        onClick = onToggleEditMode
+                                    ) {
+                                        Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
+                                    }
+                                    PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
+                                        text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
+                                        description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
+                                        onClick = onToggleTts
+                                    ) {
+                                        Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
+                                    }
+                                    else -> Unit
+                                }
                             }
-                        }
-
-                        if (!hiddenTools.contains(PdfReaderTool.LOCK_PANNING.name)) {
-                            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),
-                                onClick = onToggleScrollLock
-                            ) {
-                                Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
-                            }
-                        }
-
-                        if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
-                            TooltipIconButton(
-                                text = stringResource(R.string.tooltip_dictionary),
-                                description = stringResource(R.string.tooltip_dictionary_desc),
-                                onClick = onShowDictionarySettings
-                            ) {
-                                Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
-                            }
-                        }
 
                         if (BuildConfig.DEBUG) {
                             TooltipIconButton(text = stringResource(R.string.tooltip_demo_annotations), onClick = onGenerateDemoAnnotations) {
@@ -197,14 +279,25 @@ internal fun PdfTopBar(
 
                         Box {
                             var showMoreMenu by remember { mutableStateOf(false) }
+                            var showHiddenToolsExpanded by remember { mutableStateOf(false) }
                             TooltipIconButton(
                                 text = stringResource(R.string.tooltip_more_options),
                                 description = stringResource(R.string.tooltip_more_options_desc),
-                                onClick = { showMoreMenu = true }) {
+                                onClick = {
+                                    showHiddenToolsExpanded = false
+                                    showMoreMenu = true
+                                }) {
                                 Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
                             }
 
-                            DropdownMenu(expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false }) {
+                            DropdownMenu(
+                                expanded = showMoreMenu,
+                                onDismissRequest = {
+                                    showHiddenToolsExpanded = false
+                                    showMoreMenu = false
+                                }
+                            ) {
+                                val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) }
                                 DropdownMenuItem(
                                     text = { Text(stringResource(R.string.title_customize_toolbar)) },
                                     onClick = { showMoreMenu = false; onShowCustomizeTools() },
@@ -212,6 +305,47 @@ internal fun PdfTopBar(
                                 )
                                 HorizontalDivider()
 
+                                if (hiddenToolbarTools.isNotEmpty()) {
+                                    DropdownMenuItem(
+                                        text = { Text("Hidden tools") },
+                                        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
+                                            )
+                                        }
+                                    }
+                                    HorizontalDivider()
+                                }
+
                                 if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
                                     DropdownMenuItem(
                                         text = { Text(stringResource(R.string.menu_ocr_language)) },
@@ -402,6 +536,72 @@ internal fun PdfTopBar(
     }
 }
 
+@Composable
+private fun HiddenPdfToolMenuItem(
+    tool: PdfReaderTool,
+    isTtsPlayingOrLoading: Boolean,
+    showAllTextHighlights: Boolean,
+    isHighlightingLoading: Boolean,
+    isEditMode: Boolean,
+    isTtsSessionActive: Boolean,
+    closeMenu: () -> Unit,
+    onShowThemePanel: () -> Unit,
+    onToggleScrollLock: () -> Unit,
+    onShowDictionarySettings: () -> Unit,
+    onShowSlider: () -> Unit,
+    onShowToc: () -> Unit,
+    onSearchClick: () -> Unit,
+    onToggleHighlights: () -> Unit,
+    onShowAiHub: () -> Unit,
+    onToggleEditMode: () -> Unit,
+    onToggleTts: () -> Unit
+) {
+    val enabled = when (tool) {
+        PdfReaderTool.SLIDER,
+        PdfReaderTool.TOC,
+        PdfReaderTool.SEARCH -> !isTtsPlayingOrLoading
+        else -> true
+    }
+    DropdownMenuItem(
+        text = { Text(tool.title) },
+        enabled = enabled,
+        onClick = {
+            closeMenu()
+            when (tool) {
+                PdfReaderTool.THEME -> onShowThemePanel()
+                PdfReaderTool.LOCK_PANNING -> onToggleScrollLock()
+                PdfReaderTool.DICTIONARY -> onShowDictionarySettings()
+                PdfReaderTool.SLIDER -> onShowSlider()
+                PdfReaderTool.TOC -> onShowToc()
+                PdfReaderTool.SEARCH -> onSearchClick()
+                PdfReaderTool.HIGHLIGHT_ALL -> onToggleHighlights()
+                PdfReaderTool.AI_FEATURES -> onShowAiHub()
+                PdfReaderTool.EDIT_MODE -> onToggleEditMode()
+                PdfReaderTool.TTS_CONTROLS -> onToggleTts()
+                else -> Unit
+            }
+        },
+        leadingIcon = {
+            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.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.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 -> {
+                    if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(20.dp))
+                    else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
+                }
+                PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = null, modifier = Modifier.size(20.dp))
+                PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
+                PdfReaderTool.TTS_CONTROLS -> Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
+                else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
+            }
+        }
+    )
+}
+
 @Composable
 fun ReflowProgressOverlay(
     modifier: Modifier = Modifier,
@@ -447,6 +647,89 @@ fun ReflowProgressOverlay(
     }
 }
 
+@Composable
+fun PdfJumpHistoryBar(
+    modifier: Modifier = Modifier,
+    showStandardBars: Boolean,
+    searchStateActive: Boolean,
+    backPage: Int?,
+    forwardPage: Int?,
+    onBack: () -> Unit,
+    onForward: () -> Unit,
+    onClear: () -> Unit
+) {
+    AnimatedVisibility(
+        visible = showStandardBars && !searchStateActive && (backPage != null || forwardPage != null),
+        enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
+        exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
+        modifier = modifier
+    ) {
+        Surface(
+            modifier = Modifier.fillMaxWidth(),
+            color = MaterialTheme.colorScheme.surfaceContainer,
+            tonalElevation = 3.dp
+        ) {
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .height(40.dp)
+                    .padding(horizontal = 8.dp),
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.SpaceBetween
+            ) {
+                TextButton(
+                    onClick = onBack,
+                    enabled = backPage != null,
+                    modifier = Modifier.weight(1f)
+                ) {
+                    Icon(
+                        Icons.AutoMirrored.Filled.ArrowBack,
+                        contentDescription = stringResource(R.string.content_desc_jump_back),
+                        modifier = Modifier.size(16.dp)
+                    )
+                    Spacer(Modifier.width(4.dp))
+                    Text(
+                        text = backPage?.let { stringResource(R.string.pdf_page_short, it + 1) } ?: "",
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                }
+
+                TextButton(
+                    onClick = onClear,
+                    modifier = Modifier.weight(1f)
+                ) {
+                    Icon(
+                        Icons.Default.Close,
+                        contentDescription = stringResource(R.string.action_clear),
+                        modifier = Modifier.size(16.dp)
+                    )
+                    Spacer(Modifier.width(4.dp))
+                    Text(stringResource(R.string.action_clear), maxLines = 1)
+                }
+
+                TextButton(
+                    onClick = onForward,
+                    enabled = forwardPage != null,
+                    modifier = Modifier.weight(1f)
+                ) {
+                    Text(
+                        text = forwardPage?.let { stringResource(R.string.pdf_page_short, it + 1) } ?: "",
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                    Spacer(Modifier.width(4.dp))
+                    Icon(
+                        Icons.AutoMirrored.Filled.ArrowForward,
+                        contentDescription = stringResource(R.string.content_desc_jump_forward),
+                        modifier = Modifier.size(16.dp)
+                    )
+                }
+            }
+        }
+    }
+}
+
 @Composable
 fun PdfBottomBar(
     modifier: Modifier = Modifier,
@@ -455,14 +738,17 @@ fun PdfBottomBar(
     systemUiMode: SystemUiMode,
     navBarHeightDp: Dp,
     hiddenTools: Set,
+    toolOrder: List,
+    bottomTools: Set,
     isTtsPlayingOrLoading: Boolean,
     showAllTextHighlights: Boolean,
     isHighlightingLoading: Boolean,
     isEditMode: Boolean,
     isTtsSessionActive: Boolean,
     ttsErrorMessage: String?,
-    jumpBackPage: Int?,
-    onJumpBack: () -> Unit,
+    onShowThemePanel: () -> Unit,
+    onToggleScrollLock: () -> Unit,
+    onShowDictionarySettings: () -> Unit,
     onShowSlider: () -> Unit,
     onShowToc: () -> Unit,
     onSearchClick: () -> Unit,
@@ -490,106 +776,91 @@ fun PdfBottomBar(
                 verticalAlignment = Alignment.CenterVertically,
                 horizontalArrangement = Arrangement.SpaceEvenly
             ) {
-                if (jumpBackPage != null) {
-                    TooltipIconButton(
-                        text = stringResource(R.string.action_jump_back_to_page, jumpBackPage + 1),
-                        description = stringResource(R.string.desc_return_to_previous_page),
-                        onClick = onJumpBack
-                    ) {
-                        Column(
-                            horizontalAlignment = Alignment.CenterHorizontally,
-                            verticalArrangement = Arrangement.Center
-                        ) {
-                            Icon(
-                                Icons.AutoMirrored.Filled.Undo,
-                                contentDescription = stringResource(R.string.content_desc_jump_back),
-                                tint = MaterialTheme.colorScheme.primary,
-                                modifier = Modifier.size(16.dp)
-                            )
-                            Text(
-                                text = "${jumpBackPage + 1}",
-                                fontSize = 10.sp,
-                                lineHeight = 10.sp,
-                                fontWeight = FontWeight.Bold,
-                                color = MaterialTheme.colorScheme.primary
-                            )
+                toolOrder
+                    .filter { it in pdfToolbarTools && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
+                    .forEach { tool ->
+                        when (tool) {
+                            PdfReaderTool.THEME -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_theme),
+                                description = stringResource(R.string.tooltip_theme_desc),
+                                onClick = onShowThemePanel
+                            ) {
+                                Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+                            }
+                            PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_lock_pan),
+                                description = stringResource(R.string.tooltip_lock_pan_desc),
+                                onClick = onToggleScrollLock
+                            ) {
+                                Icon(Icons.Default.LockOpen, contentDescription = stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+                            }
+                            PdfReaderTool.DICTIONARY -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_dictionary),
+                                description = stringResource(R.string.tooltip_dictionary_desc),
+                                onClick = onShowDictionarySettings
+                            ) {
+                                Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+                            }
+                            PdfReaderTool.SLIDER -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_slider),
+                                description = stringResource(R.string.tooltip_slider_desc),
+                                onClick = onShowSlider,
+                                enabled = !isTtsPlayingOrLoading
+                            ) {
+                                Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
+                            }
+                            PdfReaderTool.TOC -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_toc),
+                                description = stringResource(R.string.tooltip_toc_desc),
+                                onClick = onShowToc,
+                                enabled = !isTtsPlayingOrLoading,
+                                modifier = Modifier.testTag("TocButton")
+                            ) {
+                                Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
+                            }
+                            PdfReaderTool.SEARCH -> TooltipIconButton(
+                                text = stringResource(R.string.tooltip_search),
+                                description = stringResource(R.string.tooltip_search_desc),
+                                onClick = onSearchClick,
+                                enabled = !isTtsPlayingOrLoading,
+                                modifier = Modifier.testTag("SearchButton")
+                            ) {
+                                Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
+                            }
+                            PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
+                                text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
+                                description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
+                                onClick = onToggleHighlights
+                            ) {
+                                if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
+                                else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
+                            }
+                            PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
+                                TooltipIconButton(
+                                    text = stringResource(R.string.tooltip_ai),
+                                    description = stringResource(R.string.tooltip_ai_desc),
+                                    onClick = onShowAiHub
+                                ) {
+                                    Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
+                                }
+                            }
+                            PdfReaderTool.EDIT_MODE -> TooltipIconButton(
+                                text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
+                                description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
+                                onClick = onToggleEditMode
+                            ) {
+                                Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
+                            }
+                            PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
+                                text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
+                                description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
+                                onClick = onToggleTts
+                            ) {
+                                Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
+                            }
+                            else -> Unit
                         }
                     }
-                }
-
-                if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
-                    TooltipIconButton(
-                        text = stringResource(R.string.tooltip_slider),
-                        description = stringResource(R.string.tooltip_slider_desc),
-                        onClick = onShowSlider,
-                        enabled = !isTtsPlayingOrLoading
-                    ) {
-                        Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
-                    }
-                }
-                if (!hiddenTools.contains(PdfReaderTool.TOC.name)) {
-                    TooltipIconButton(
-                        text = stringResource(R.string.tooltip_toc),
-                        description = stringResource(R.string.tooltip_toc_desc),
-                        onClick = onShowToc,
-                        enabled = !isTtsPlayingOrLoading,
-                        modifier = Modifier.testTag("TocButton")
-                    ) {
-                        Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
-                    }
-                }
-
-                if (!hiddenTools.contains(PdfReaderTool.SEARCH.name)) {
-                    TooltipIconButton(
-                        text = stringResource(R.string.tooltip_search),
-                        description = stringResource(R.string.tooltip_search_desc),
-                        onClick = onSearchClick,
-                        enabled = !isTtsPlayingOrLoading,
-                        modifier = Modifier.testTag("SearchButton")
-                    ) {
-                        Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
-                    }
-                }
-                if (!hiddenTools.contains(PdfReaderTool.HIGHLIGHT_ALL.name)) {
-                    TooltipIconButton(
-                        text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
-                        description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
-                        onClick = onToggleHighlights
-                    ) {
-                        if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
-                        else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
-                    }
-                }
-
-                if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) {
-                    TooltipIconButton(
-                        text = stringResource(R.string.tooltip_ai),
-                        description = stringResource(R.string.tooltip_ai_desc),
-                        onClick = onShowAiHub
-                    ) {
-                        Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
-                    }
-                }
-
-                if (!hiddenTools.contains(PdfReaderTool.EDIT_MODE.name)) {
-                    TooltipIconButton(
-                        text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
-                        description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
-                        onClick = onToggleEditMode
-                    ) {
-                        Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
-                    }
-                }
-
-                if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
-                    TooltipIconButton(
-                        text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
-                        description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
-                        onClick = onToggleTts
-                    ) {
-                        Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
-                    }
-                }
 
                 if (BuildConfig.FLAVOR != "oss") {
                     TooltipIconButton(
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
index 95f4947..57557a5 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
@@ -193,6 +193,7 @@ internal fun PdfVerticalReader(
     state: VerticalPdfReaderState,
     pdfDocument: StableHolder,
     activeTheme: com.aryan.reader.ReaderTheme,
+    activeTextureAlpha: Float = 0.55f,
     excludeImages: Boolean = false,
     totalPages: Int,
     virtualPages: List = emptyList(),
@@ -988,7 +989,7 @@ internal fun PdfVerticalReader(
                 }
 
                 val buttons = currentEvent.buttons
-                Timber.tag("StylusEraserDiagnostic").d(
+                Timber.tag("StylusDebug").d(
                     "VerticalReader | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
                 )
 
@@ -1688,6 +1689,7 @@ internal fun PdfVerticalReader(
                                     virtualPage = virtualPage,
                                     totalPages = totalPages,
                                     activeTheme = activeTheme,
+                                    activeTextureAlpha = activeTextureAlpha,
                                     excludeImages = excludeImages,
                                     externalScale = highResScale,
                                     onScaleChanged = {},
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt
index 39f3cd2..28f1688 100644
--- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt
@@ -92,7 +92,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.BasicTextField
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.automirrored.filled.Undo
 import androidx.compose.material.icons.filled.ArrowDownward
 import androidx.compose.material.icons.filled.ArrowUpward
 import androidx.compose.material.icons.filled.Close
@@ -113,8 +112,6 @@ import androidx.compose.material3.ModalDrawerSheet
 import androidx.compose.material3.ModalNavigationDrawer
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Slider
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
@@ -198,9 +195,12 @@ import androidx.media3.common.util.UnstableApi
 import androidx.paging.compose.collectAsLazyPagingItems
 import androidx.work.WorkInfo
 import com.aryan.reader.AiDefinitionPopup
+import com.aryan.reader.AiFeature
 import com.aryan.reader.AiDefinitionResult
 import com.aryan.reader.AiHubBottomSheet
+import com.aryan.reader.BannerMessage
 import com.aryan.reader.BuildConfig
+import com.aryan.reader.CustomTopBanner
 import com.aryan.reader.FileType
 import com.aryan.reader.HighlightColorPickerDialog
 import com.aryan.reader.MainViewModel
@@ -219,7 +219,11 @@ import com.aryan.reader.epubreader.TtsOverlayControls
 import com.aryan.reader.epubreader.loadTapToNavigateSetting
 import com.aryan.reader.epubreader.saveTapToNavigateSetting
 import com.aryan.reader.fetchAiDefinition
+import com.aryan.reader.areReaderAiFeaturesEnabled
+import com.aryan.reader.callByokGeminiInlineAi
+import com.aryan.reader.isByokCloudTtsAvailable
 import com.aryan.reader.loadCustomThemes
+import com.aryan.reader.loadGlobalTextureTransparency
 import com.aryan.reader.paginatedreader.TtsChunk
 import com.aryan.reader.pdf.data.AnnotationSettingsRepository
 import com.aryan.reader.pdf.data.PdfAnnotation
@@ -233,6 +237,7 @@ import com.aryan.reader.pdf.data.TextStyleConfig
 import com.aryan.reader.pdf.data.VirtualPage
 import com.aryan.reader.rememberSearchState
 import com.aryan.reader.saveCustomThemes
+import com.aryan.reader.saveGlobalTextureTransparency
 import com.aryan.reader.summarizationUrl
 import com.aryan.reader.tts.SpeakerSamplePlayer
 import com.aryan.reader.tts.TtsPlaybackManager
@@ -265,6 +270,12 @@ import kotlin.math.atan2
 import kotlin.math.max
 import kotlin.math.min
 import kotlin.math.roundToInt
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.pointer.isPrimaryPressed
+import androidx.compose.ui.input.pointer.isSecondaryPressed
+import androidx.compose.ui.input.pointer.isTertiaryPressed
+import androidx.compose.ui.input.pointer.isBackPressed
+import androidx.compose.ui.input.pointer.isForwardPressed
 
 @Suppress("KotlinConstantConditions")
 @SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
@@ -300,6 +311,7 @@ fun PdfViewerScreen(
     var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) }
     var excludeImages by remember { mutableStateOf(com.aryan.reader.loadExcludeImages(context)) }
     var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
+    var globalTextureTransparency by remember { mutableFloatStateOf(loadGlobalTextureTransparency(context)) }
     val documentCache = remember { DocumentCache(3) }
     val summaryCacheManager = remember(context) { SummaryCacheManager(context) }
     val tabStateMap = remember { mutableStateMapOf() }
@@ -335,12 +347,24 @@ fun PdfViewerScreen(
 
     var showCustomizeToolsSheet by remember { mutableStateOf(false) }
     var hiddenTools by remember { mutableStateOf(loadPdfHiddenTools(context)) }
+    var toolOrder by remember { mutableStateOf(loadPdfToolOrder(context)) }
+    var bottomTools by remember { mutableStateOf(loadPdfBottomTools(context)) }
 
     val onUpdateHiddenTools = { newSet: Set ->
         hiddenTools = newSet
         savePdfHiddenTools(context, newSet)
     }
 
+    val onUpdateToolOrder = { newOrder: List ->
+        toolOrder = newOrder
+        savePdfToolOrder(context, newOrder)
+    }
+
+    val onUpdateBottomTools = { newBottomTools: Set ->
+        bottomTools = newBottomTools
+        savePdfBottomTools(context, newBottomTools)
+    }
+
     val isOss = BuildConfig.FLAVOR == "oss"
 
     val executeWithOcrCheck = remember(hasSelectedOcrLanguage) {
@@ -412,7 +436,7 @@ fun PdfViewerScreen(
     var currentTtsMode by remember {
         mutableStateOf(
             com.aryan.reader.tts.loadTtsMode(context).let {
-                if (BuildConfig.FLAVOR == "oss") TtsPlaybackManager.TtsMode.BASE else it
+                if (BuildConfig.FLAVOR == "oss" && !isByokCloudTtsAvailable(context)) TtsPlaybackManager.TtsMode.BASE else it
             }
         )
     }
@@ -455,7 +479,10 @@ fun PdfViewerScreen(
 
     val customFonts by viewModel.customFonts.collectAsState()
 
-    val snackbarHostState = remember { SnackbarHostState() }
+    var bannerMessage by remember { mutableStateOf(null) }
+    fun showBanner(message: String, isError: Boolean = false) {
+        bannerMessage = BannerMessage(message, isError = isError)
+    }
     val onOcrStateChange: (Boolean) -> Unit = {}
 
     var showZoomIndicator by remember { mutableStateOf(false) }
@@ -498,9 +525,7 @@ fun PdfViewerScreen(
             )
         } catch (e: Exception) {
             Timber.tag("PdfPrint").e(e, "Failed to initialize print job")
-            coroutineScope.launch {
-                snackbarHostState.showSnackbar(context.getString(R.string.error_open_print_settings))
-            }
+            showBanner(context.getString(R.string.error_open_print_settings), isError = true)
         }
     }
 
@@ -655,12 +680,6 @@ fun PdfViewerScreen(
     val searchState = rememberSearchState(scope = coroutineScope, searcher = dummySearcher)
     val navBarHeight = WindowInsets.systemBars.getBottom(density)
 
-    val effectiveNavBarForSnackbar = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp
-    val snackbarPadding by animateDpAsState(
-        targetValue = if (showStandardBars && !searchState.isSearchActive) 56.dp + effectiveNavBarForSnackbar else effectiveNavBarForSnackbar,
-        label = "SnackbarPadding"
-    )
-
     val targetVerticalHeaderHeight = remember(
         dockLocation,
         snapPreviewLocation,
@@ -733,6 +752,7 @@ fun PdfViewerScreen(
             }
         }
     }
+    var errorMessage by remember { mutableStateOf(null) }
 
     LaunchedEffect(ocrLanguage) { OcrHelper.init(ocrLanguage) }
 
@@ -754,13 +774,23 @@ fun PdfViewerScreen(
                 showInsufficientCreditsDialog = true
                 ttsController.stop()
             } else {
-                coroutineScope.launch {
-                    snackbarHostState.showSnackbar(message)
-                }
+                showBanner(message, isError = true)
             }
         }
     }
 
+    LaunchedEffect(errorMessage) {
+        errorMessage?.let { showBanner(it, isError = true) }
+    }
+
+    LaunchedEffect(bannerMessage) {
+        val message = bannerMessage ?: return@LaunchedEffect
+        if (!message.isPersistent) {
+            delay(2500L)
+            bannerMessage = null
+        }
+    }
+
     val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
     val toolSettings by annotationSettingsRepo.settings.collectAsState()
     var showToolSettings by rememberSaveable { mutableStateOf(false) }
@@ -985,13 +1015,80 @@ fun PdfViewerScreen(
     }
 
     val jumpHistory = remember { mutableStateListOf() }
-    var showJumpPill by remember { mutableStateOf(false) }
+    var jumpHistoryCursor by remember { mutableIntStateOf(-1) }
 
-    LaunchedEffect(showJumpPill, jumpHistory.size) {
-        if (showJumpPill && jumpHistory.isNotEmpty()) {
-            delay(4000)
-            showJumpPill = false
+    fun pruneJumpHistoryForDocument() {
+        if (totalPages <= 0) {
+            jumpHistory.clear()
+            jumpHistoryCursor = -1
+            return
         }
+
+        var index = jumpHistory.lastIndex
+        while (index >= 0) {
+            if (jumpHistory[index] !in 0 until totalPages) {
+                jumpHistory.removeAt(index)
+                if (jumpHistoryCursor >= index) jumpHistoryCursor--
+            }
+            index--
+        }
+        jumpHistoryCursor = jumpHistoryCursor.coerceIn(-1, jumpHistory.lastIndex)
+    }
+
+    fun recordJumpHistory(currentPageIndex: Int, targetPageIndex: Int) {
+        if (totalPages <= 0 || currentPageIndex !in 0 until totalPages || targetPageIndex !in 0 until totalPages || currentPageIndex == targetPageIndex) {
+            return
+        }
+
+        pruneJumpHistoryForDocument()
+        while (jumpHistory.lastIndex > jumpHistoryCursor) {
+            jumpHistory.removeAt(jumpHistory.lastIndex)
+        }
+
+        if (jumpHistoryCursor > 0 && jumpHistory.getOrNull(jumpHistoryCursor - 1) == currentPageIndex) {
+            jumpHistory[jumpHistoryCursor] = targetPageIndex
+            return
+        }
+
+        if (jumpHistoryCursor == -1 || jumpHistory.getOrNull(jumpHistoryCursor) != currentPageIndex) {
+            jumpHistory.add(currentPageIndex)
+            jumpHistoryCursor = jumpHistory.lastIndex
+        }
+
+        if (jumpHistory.lastOrNull() != targetPageIndex) {
+            jumpHistory.add(targetPageIndex)
+            jumpHistoryCursor = jumpHistory.lastIndex
+        }
+
+        while (jumpHistory.size > 21) {
+            jumpHistory.removeAt(0)
+            jumpHistoryCursor--
+        }
+        jumpHistoryCursor = jumpHistoryCursor.coerceIn(0, jumpHistory.lastIndex)
+    }
+
+    fun clearJumpHistory() {
+        jumpHistory.clear()
+        jumpHistoryCursor = -1
+    }
+
+    fun navigateToJumpHistoryPage(targetPageIndex: Int) {
+        if (targetPageIndex !in 0 until totalPages) {
+            pruneJumpHistoryForDocument()
+            return
+        }
+
+        coroutineScope.launch {
+            if (displayMode == DisplayMode.PAGINATION) {
+                pagerState.animateScrollToPage(targetPageIndex)
+            } else {
+                verticalReaderState.scrollToPage(targetPageIndex)
+            }
+        }
+    }
+
+    LaunchedEffect(totalPages) {
+        pruneJumpHistoryForDocument()
     }
 
     LaunchedEffect(currentPage, isDocumentReady, totalPages, initialScrollDone) {
@@ -1154,9 +1251,8 @@ fun PdfViewerScreen(
     var isHighlightingLoading by remember { mutableStateOf(false) }
 
     var ttsPageData by remember { mutableStateOf(null) }
+    var ttsDisplayPageIndex by remember { mutableStateOf(null) }
     var ttsHighlightData by remember { mutableStateOf(null) }
-
-    var errorMessage by remember { mutableStateOf(null) }
     var isLoadingDocument by remember { mutableStateOf(true) }
 
     var selectionClearTrigger by remember { mutableLongStateOf(0L) }
@@ -1179,6 +1275,26 @@ fun PdfViewerScreen(
         }
     }
 
+    fun displayPageToPdfPage(displayPageIndex: Int): Int? {
+        if (displayPageIndex !in 0 until totalDisplayPages) return null
+        if (virtualPages.isEmpty()) return displayPageIndex.takeIf { it in 0 until totalPages }
+
+        return when (val virtualPage = virtualPages.getOrNull(displayPageIndex)) {
+            is VirtualPage.PdfPage -> virtualPage.pdfIndex.takeIf { it in 0 until totalPages }
+            is VirtualPage.BlankPage -> null
+            null -> null
+        }
+    }
+
+    fun pdfPageToDisplayPage(pdfPageIndex: Int): Int? {
+        if (pdfPageIndex !in 0 until totalPages) return null
+        if (virtualPages.isEmpty()) return pdfPageIndex.takeIf { it in 0 until totalDisplayPages }
+
+        return virtualPages.indexOfFirst {
+            it is VirtualPage.PdfPage && it.pdfIndex == pdfPageIndex
+        }.takeIf { it >= 0 }
+    }
+
     LaunchedEffect(richTextController, toolSettings.textStyle) {
         richTextController?.let { controller ->
             val config = toolSettings.textStyle
@@ -1416,7 +1532,7 @@ fun PdfViewerScreen(
                 bookmarks = loadPdfBookmarksFromJson(result.bookmarksJson)
                 onBookmarksChanged(result.bookmarksJson)
 
-                snackbarHostState.showSnackbar("Page added at ${targetIndex + 1}")
+                showBanner("Page added at ${targetIndex + 1}")
             }
         }
     }
@@ -1504,7 +1620,7 @@ fun PdfViewerScreen(
                 bookmarks = loadPdfBookmarksFromJson(result.bookmarksJson)
                 onBookmarksChanged(result.bookmarksJson)
 
-                snackbarHostState.showSnackbar("Page deleted")
+                showBanner("Page deleted")
 
                 val newMax = (virtualPages.size - 1).coerceAtLeast(0)
                 if (currentPage > newMax) {
@@ -1689,7 +1805,7 @@ fun PdfViewerScreen(
             }
 
             if (pageRemoved) {
-                snackbarHostState.showSnackbar("Extra page removed")
+                showBanner("Extra page removed")
             }
         }
     }
@@ -2051,11 +2167,11 @@ fun PdfViewerScreen(
     val onDictionaryLookupStable = remember(executeWithOcrCheck, useOnlineDictionary, selectedDictPackage, uiState.credits, isProUser) {
         { text: String ->
             executeWithOcrCheck {
-                val effectiveUseOnline = !isOss && useOnlineDictionary
+                val effectiveUseOnline = areReaderAiFeaturesEnabled(context) && useOnlineDictionary
 
                 if (effectiveUseOnline) {
                     val wordCount = com.aryan.reader.countWords(text)
-                    if (wordCount > 1 && !isProUser) {
+                    if (BuildConfig.FLAVOR != "oss" && wordCount > 1 && !isProUser) {
                         showDictionaryUpsellDialog = true
                     } else {
                         selectedTextForAi = text
@@ -2128,9 +2244,7 @@ fun PdfViewerScreen(
                     val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
 
                     if (current != targetPage) {
-                        if (jumpHistory.size > 20) jumpHistory.removeAt(0)
-                        jumpHistory.add(current)
-                        showJumpPill = true
+                        recordJumpHistory(current, targetPage)
                     }
 
                     if (displayMode == DisplayMode.PAGINATION) {
@@ -2219,6 +2333,27 @@ fun PdfViewerScreen(
                 pageBitmap.recycle()
                 pageBitmap = null
 
+                @Suppress("KotlinConstantConditions")
+                if (BuildConfig.FLAVOR == "oss") {
+                    val fullText = StringBuilder()
+                    callByokGeminiInlineAi(
+                        context = context,
+                        feature = AiFeature.SUMMARIZE,
+                        mimeType = "image/jpeg",
+                        base64Data = base64Image,
+                        systemInstruction = "You are an expert in analyzing visual content. You will be given an image of a page. Describe what is happening, identify key information, and summarize the text. Do not add a preamble.",
+                        temperature = 0.2,
+                        maxTokens = 8192,
+                        onUpdate = {
+                            fullText.append(it)
+                            onUpdate(SummarizationResult(summary = fullText.toString()))
+                        },
+                        onError = { onUpdate(SummarizationResult(error = it)) }
+                    )
+                    onFinish()
+                    return@withContext
+                }
+
                 val url = URL(summarizationUrl)
                 connection = url.openConnection() as HttpURLConnection
                 connection.requestMethod = "POST"
@@ -2394,8 +2529,12 @@ fun PdfViewerScreen(
         return false
     }
 
-    fun startTts(pageToReadOverride: Int? = null, startCharIndex: Int? = null) {
-        if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && uiState.credits <= 0) {
+    fun startTts(
+        pageToReadOverride: Int? = null,
+        startCharIndex: Int? = null,
+        continueSession: Boolean = false
+    ) {
+        if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && uiState.credits <= 0) {
             showInsufficientCreditsDialog = true
             return
         }
@@ -2406,7 +2545,13 @@ fun PdfViewerScreen(
         }
         coroutineScope.launch {
             val token = viewModel.getAuthToken()
-            val pageToRead = pageToReadOverride ?: currentPage
+            val requestedDisplayPage = pageToReadOverride ?: currentPage
+            val pageToRead = displayPageToPdfPage(requestedDisplayPage)
+            if (pageToRead == null) {
+                Timber.w("TTS: Ignoring blank or invalid display page $requestedDisplayPage.")
+                return@launch
+            }
+            val displayPageForTts = pdfPageToDisplayPage(pageToRead) ?: requestedDisplayPage
             var rawPageText: String? = null
             var tempPage: ReaderPage? = null
             var tempTextPage: ReaderTextPage? = null
@@ -2454,6 +2599,7 @@ fun PdfViewerScreen(
             if (rawPageText != null && rawPageText!!.isNotBlank()) {
                 val processedText = preprocessTextForTts(rawPageText!!)
                 ttsPageData = TtsPageData(pageToRead, processedText, ocrUsedForCurrentPageTts)
+                ttsDisplayPageIndex = displayPageForTts
 
                 val cleanStartIndex = if (startCharIndex != null && startCharIndex >= 0) {
                     processedText.indexMap.indexOfFirst { it >= startCharIndex }.coerceAtLeast(0)
@@ -2476,6 +2622,7 @@ fun PdfViewerScreen(
                     bookTitle = bookTitle,
                     chapterTitle = pageTitle,
                     coverImageUri = null,
+                    continueSession = continueSession,
                     ttsMode = currentTtsMode,
                     playbackSource = "READER",
                     authToken = token
@@ -2495,10 +2642,16 @@ fun PdfViewerScreen(
                 if (nextPage < totalPages) {
                     Timber.i("TTS found no text on page $pageToRead. Skipping to $nextPage.")
                     isAutoPagingForTts = true
+                    val nextDisplayPage = pdfPageToDisplayPage(nextPage)
                     if (displayMode == DisplayMode.PAGINATION) {
-                        coroutineScope.launch { pagerState.animateScrollToPage(nextPage) }
+                        nextDisplayPage?.let { coroutineScope.launch { pagerState.animateScrollToPage(it) } }
                     } else {
-                        startTts(pageToReadOverride = nextPage)
+                        if (nextDisplayPage != null) {
+                            startTts(pageToReadOverride = nextDisplayPage, continueSession = true)
+                        } else {
+                            ttsController.stop()
+                            isAutoPagingForTts = false
+                        }
                     }
                 } else {
                     ttsController.stop()
@@ -2556,12 +2709,24 @@ fun PdfViewerScreen(
                     DisplayMode.PAGINATION -> {
                         Timber.d("TTS auto-paging to next page: ${nextPage + 1}")
                         isAutoPagingForTts = true
-                        coroutineScope.launch { pagerState.animateScrollToPage(nextPage) }
+                        val nextDisplayPage = pdfPageToDisplayPage(nextPage)
+                        if (nextDisplayPage != null) {
+                            coroutineScope.launch { pagerState.animateScrollToPage(nextDisplayPage) }
+                        } else {
+                            ttsController.stop()
+                            isAutoPagingForTts = false
+                        }
                     }
 
                     DisplayMode.VERTICAL_SCROLL -> {
                         Timber.d("TTS auto-starting on next page (no scroll): ${nextPage + 1}")
-                        startTts(pageToReadOverride = nextPage)
+                        val nextDisplayPage = pdfPageToDisplayPage(nextPage)
+                        if (nextDisplayPage != null) {
+                            startTts(pageToReadOverride = nextDisplayPage, continueSession = true)
+                        } else {
+                            ttsController.stop()
+                            isAutoPagingForTts = false
+                        }
                     }
                 }
             } else {
@@ -2719,7 +2884,11 @@ fun PdfViewerScreen(
                 }
 
                 pdfDocument = doc
-                documentMetadataTitle = (doc as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
+                documentMetadataTitle = (doc as? PdfDocumentWrapper)?.let { wrapper ->
+                    PdfiumEngineProvider.withPdfium {
+                        wrapper.pdfDocument.getDocumentMeta().title?.takeIf { it.isNotBlank() }
+                    }
+                }
                 pfdState = currentPfdOpened
                 val pagesCount = doc.getPageCount()
 
@@ -2891,7 +3060,7 @@ fun PdfViewerScreen(
     LaunchedEffect(currentPage) {
         if (previousPage != -1 && previousPage != currentPage) {
             if (isAutoPagingForTts) {
-                startTts()
+                startTts(continueSession = true)
             } else if (displayMode == DisplayMode.PAGINATION && (ttsState.isPlaying || ttsState.isLoading)) {
                 Timber.d("Page changed manually while TTS active, stopping.")
                 ttsController.stop()
@@ -2969,8 +3138,11 @@ fun PdfViewerScreen(
                 )
 
                 bgPfd = context.contentResolver.openFileDescriptor(effectivePdfUri, "r")
-                if (bgPfd != null) {
-                    bgDoc = pdfiumCore.newDocument(bgPfd, documentPassword)
+                val openedBgPfd = bgPfd
+                if (openedBgPfd != null) {
+                    bgDoc = PdfiumEngineProvider.withPdfium {
+                        pdfiumCore.newDocument(openedBgPfd, documentPassword)
+                    }
 
                     val pagesToIndex = (0 until totalPages).filter { !existingPages.contains(it) }
                     val totalToDo = pagesToIndex.size
@@ -3001,7 +3173,9 @@ fun PdfViewerScreen(
                 Timber.e(e, "Indexer: Fatal error")
             } finally {
                 try {
-                    bgDoc?.close()
+                    PdfiumEngineProvider.withPdfium {
+                        bgDoc?.close()
+                    }
                     bgPfd?.close()
                 } catch (e: Exception) {
                     Timber.e(e, "Indexer: Cleanup failed")
@@ -3157,9 +3331,7 @@ fun PdfViewerScreen(
                 val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
 
                 if (current != targetPage) {
-                    if (jumpHistory.size > 20) jumpHistory.removeAt(0)
-                    jumpHistory.add(current)
-                    showJumpPill = true
+                    recordJumpHistory(current, targetPage)
                 }
 
                 if (displayMode == DisplayMode.PAGINATION) {
@@ -3197,9 +3369,7 @@ fun PdfViewerScreen(
             val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
 
             if (current != targetPage) {
-                if (jumpHistory.size > 20) jumpHistory.removeAt(0)
-                jumpHistory.add(current)
-                showJumpPill = true
+                recordJumpHistory(current, targetPage)
             }
 
             if (displayMode == DisplayMode.PAGINATION) {
@@ -3288,9 +3458,7 @@ fun PdfViewerScreen(
                             val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
 
                             if (current != targetPage) {
-                                if (jumpHistory.size > 20) jumpHistory.removeAt(0)
-                                jumpHistory.add(current)
-                                showJumpPill = true
+                                recordJumpHistory(current, targetPage)
                             }
 
                             if (displayMode == DisplayMode.PAGINATION) {
@@ -3322,14 +3490,34 @@ fun PdfViewerScreen(
         @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
         Scaffold(
             contentWindowInsets = WindowInsets(0, 0, 0, 0),
-            snackbarHost = {
-                SnackbarHost(
-                    hostState = snackbarHostState,
-                    modifier = Modifier.padding(bottom = snackbarPadding)
-                )
-            }
         ) { _ ->
-            BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+            BoxWithConstraints(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .pointerInput(Unit) {
+                        awaitPointerEventScope {
+                            while (true) {
+                                val event = awaitPointerEvent(androidx.compose.ui.input.pointer.PointerEventPass.Initial)
+                                event.changes.forEach { change ->
+                                    if (change.type == androidx.compose.ui.input.pointer.PointerType.Stylus ||
+                                        change.type == androidx.compose.ui.input.pointer.PointerType.Eraser) {
+                                        val buttons = event.buttons
+                                        Timber.tag("StylusDebug").d(
+                                            "GlobalPointer | type=${change.type}, pressed=${change.pressed}, " +
+                                                    "primary=${buttons.isPrimaryPressed}, secondary=${buttons.isSecondaryPressed}, " +
+                                                    "tertiary=${buttons.isTertiaryPressed}, back=${buttons.isBackPressed}, " +
+                                                    "forward=${buttons.isForwardPressed}"
+                                        )
+                                    }
+                                }
+                            }
+                        }
+                    }
+                    .onPreviewKeyEvent { keyEvent ->
+                        Timber.tag("StylusDebug").d("GlobalKey | key=${keyEvent.key}, type=${keyEvent.type}")
+                        false
+                    }
+            ) {
                 IntSize(constraints.maxWidth, constraints.maxHeight)
                 val boxConstraints = constraints
                 val boxMaxWidthFloat = boxConstraints.maxWidth.toFloat()
@@ -3617,6 +3805,7 @@ fun PdfViewerScreen(
                                                 virtualPage = virtualPage,
                                                 totalPages = totalDisplayPages,
                                                 activeTheme = activeTheme,
+                                                activeTextureAlpha = 1f - globalTextureTransparency,
                                                 excludeImages = excludeImages,
                                                 isScrollLocked = isScrollLocked,
                                                 customHighlightColors = customHighlightColors,
@@ -3629,7 +3818,7 @@ fun PdfViewerScreen(
                                                         currentPageScale = newScale
                                                     }
                                                 },
-                                                ttsHighlightData = if (pagerState.currentPage == pageIndex) ttsHighlightData else null,
+                                                ttsHighlightData = if (ttsDisplayPageIndex == pageIndex) ttsHighlightData else null,
                                                 searchQuery = searchState.searchQuery,
                                                 searchHighlightMode = searchHighlightMode,
                                                 searchResultToHighlight = if (pagerState.currentPage == pageIndex) searchHighlightTarget else null,
@@ -4055,6 +4244,7 @@ fun PdfViewerScreen(
                                             state = verticalReaderState,
                                             pdfDocument = docHolder,
                                             activeTheme = activeTheme,
+                                            activeTextureAlpha = 1f - globalTextureTransparency,
                                             excludeImages = excludeImages,
                                             isScrollLocked = isScrollLocked,
                                             customHighlightColors = customHighlightColors,
@@ -4078,7 +4268,7 @@ fun PdfViewerScreen(
                                             onTranslateText = onTranslateTextStable,
                                             onSearchText = onSearchTextStable,
                                             ttsHighlightData = ttsHighlightData,
-                                            ttsReadingPage = ttsPageData?.pageIndex,
+                                            ttsReadingPage = ttsDisplayPageIndex,
                                             userHighlights = userHighlights,
                                             onHighlightAdd = onHighlightAdd,
                                             onHighlightUpdate = onHighlightUpdate,
@@ -4517,11 +4707,7 @@ fun PdfViewerScreen(
                                                     val targetPage = newValue.roundToInt()
 
                                                     if (targetPage != sliderStartPage) {
-                                                        if (jumpHistory.lastOrNull() != sliderStartPage) {
-                                                            if (jumpHistory.size > 20) jumpHistory.removeAt(0)
-                                                            jumpHistory.add(sliderStartPage)
-                                                        }
-                                                        showJumpPill = true
+                                                        recordJumpHistory(sliderStartPage, targetPage)
                                                     }
                                                     if (displayMode == DisplayMode.PAGINATION) {
                                                         pagerState.scrollToPage(
@@ -4653,6 +4839,71 @@ fun PdfViewerScreen(
                     }
                 }
 
+                val isPdfTtsPlayingOrLoading = ttsState.isPlaying || ttsState.isLoading
+                val showPdfThemePanel = { showThemePanel = true }
+                val showPdfDictionarySettings = { showDictionarySettingsSheet = true }
+                val togglePdfScrollLock = {
+                    isScrollLocked = !isScrollLocked
+                    savePdfScrollLocked(context, bookId, isScrollLocked)
+                    if (isScrollLocked) {
+                        savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
+                        lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
+                    }
+                }
+                val showPdfSlider = {
+                    val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
+                    sliderStartPage = currentPageForSlider
+                    sliderCurrentPage = currentPageForSlider.toFloat()
+                    isPageSliderVisible = true
+                    showBars = false
+                }
+                val showPdfToc = {
+                    coroutineScope.launch { drawerState.open() }
+                    Unit
+                }
+                val showPdfSearch = {
+                    executeWithOcrCheck {
+                        searchState.isSearchActive = true
+                        showBars = true
+                    }
+                }
+                val togglePdfHighlights = {
+                    if (!showAllTextHighlights && !isHighlightingLoading) {
+                        showAllTextHighlights = true
+                        isHighlightingLoading = true
+                    } else if (showAllTextHighlights) {
+                        showAllTextHighlights = false
+                        isHighlightingLoading = false
+                    }
+                }
+                val showPdfAiHub = { showAiHubSheet = true }
+                val togglePdfEditMode = {
+                    val newEditMode = !isEditMode
+                    val currentActivePage = richTextController?.activePageIndex ?: -1
+                    Timber.tag("RichTextMigration").i("Edit Toggle: $isEditMode -> $newEditMode (ActivePage: $currentActivePage)")
+
+                    if (!newEditMode && richTextController != null) {
+                        coroutineScope.launch {
+                            richTextController.saveImmediate()
+                            withContext(Dispatchers.Main) {
+                                keyboardController?.hide()
+                            }
+                        }
+                    }
+
+                    isEditMode = newEditMode
+                    if (!newEditMode) showBars = true
+                }
+                val togglePdfTts = {
+                    if (isTtsSessionActive) {
+                        Timber.d("TTS button clicked: Stopping TTS")
+                        ttsController.stop()
+                        isAutoPagingForTts = false
+                    } else {
+                        startTtsWithPermissionCheck(null, null)
+                    }
+                }
+
                 // Custom Top Bar
                 PdfTopBar(
                     modifier = Modifier.align(Alignment.TopCenter),
@@ -4677,6 +4928,8 @@ fun PdfViewerScreen(
                     totalPages = totalPages,
                     pagerStatePageCount = pagerState.pageCount,
                     hiddenTools = hiddenTools,
+                    toolOrder = toolOrder,
+                    bottomTools = bottomTools,
                     isScrollLocked = isScrollLocked,
                     isEditMode = isEditMode,
                     displayMode = displayMode,
@@ -4692,16 +4945,9 @@ fun PdfViewerScreen(
                     activeTabBookId = activeTabBookId,
                     effectiveFileType = effectiveFileType,
                     onNavigateBack = { saveStateAndExit() },
-                    onShowThemePanel = { showThemePanel = true },
-                    onToggleScrollLock = {
-                        isScrollLocked = !isScrollLocked
-                        savePdfScrollLocked(context, bookId, isScrollLocked)
-                        if (isScrollLocked) {
-                            savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
-                            lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
-                        }
-                    },
-                    onShowDictionarySettings = { showDictionarySettingsSheet = true },
+                    onShowThemePanel = showPdfThemePanel,
+                    onToggleScrollLock = togglePdfScrollLock,
+                    onShowDictionarySettings = showPdfDictionarySettings,
                     onShowPenPlayground = { showPenPlayground = true },
                     onImportSvg = {
                         val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
@@ -4723,9 +4969,12 @@ fun PdfViewerScreen(
                                     }
                                     redoStack.clear()
 
-                                    snackbarHostState.showSnackbar(context.getString(R.string.msg_imported_svg_strokes))
+                                    showBanner(context.getString(R.string.msg_imported_svg_strokes))
                                 } else {
-                                    snackbarHostState.showSnackbar(context.getString(R.string.error_import_svg_failed))
+                                    showBanner(
+                                        context.getString(R.string.error_import_svg_failed),
+                                        isError = true
+                                    )
                                 }
                             }
                         }
@@ -4738,6 +4987,16 @@ fun PdfViewerScreen(
                         }
                     },
                     onShowVisualOptions = { showVisualOptionsSheet = true },
+                    isTtsPlayingOrLoading = isPdfTtsPlayingOrLoading,
+                    showAllTextHighlights = showAllTextHighlights,
+                    isHighlightingLoading = isHighlightingLoading,
+                    onShowSlider = showPdfSlider,
+                    onShowToc = showPdfToc,
+                    onSearchClick = showPdfSearch,
+                    onToggleHighlights = togglePdfHighlights,
+                    onShowAiHub = showPdfAiHub,
+                    onToggleEditMode = togglePdfEditMode,
+                    onToggleTts = togglePdfTts,
                     tapToNavigateEnabled = tapToNavigateEnabled,
                     onToggleTapToNavigate = {
                         tapToNavigateEnabled = !tapToNavigateEnabled
@@ -5017,55 +5276,32 @@ fun PdfViewerScreen(
                     )
                 }
 
-                val effectiveNavBarForPill = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp
+                val jumpBackPage = jumpHistory.getOrNull(jumpHistoryCursor - 1)
+                val jumpForwardPage = jumpHistory.getOrNull(jumpHistoryCursor + 1)
+                val effectiveNavBarForJumpBar = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp
 
-                val isBottomBarVisibleForPill = showStandardBars && !searchState.isSearchActive
-                val targetPillBottomPadding = if (isBottomBarVisibleForPill) 56.dp + 16.dp + effectiveNavBarForPill else 16.dp + effectiveNavBarForPill
-
-                val pillBottomPadding by animateDpAsState(
-                    targetValue = targetPillBottomPadding,
-                    label = "PillBottomPadding"
-                )
-
-                AnimatedVisibility(
-                    visible = showJumpPill && jumpHistory.isNotEmpty(),
-                    enter = fadeIn() + slideInVertically { it },
-                    exit = fadeOut() + slideOutVertically { it },
+                PdfJumpHistoryBar(
                     modifier = Modifier
-                        .align(Alignment.BottomStart)
-                        .padding(bottom = pillBottomPadding)
-                        .padding(start = 16.dp)
-                ) {
-                    val lastPage = jumpHistory.lastOrNull() ?: 0
-                    Surface(
-                        shape = CircleShape,
-                        color = MaterialTheme.colorScheme.secondaryContainer,
-                        contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
-                        shadowElevation = 6.dp,
-                        onClick = {
-                            val target = jumpHistory.removeLastOrNull()
-                            if (target != null) {
-                                showJumpPill = false
-                                coroutineScope.launch {
-                                    if (displayMode == DisplayMode.PAGINATION) {
-                                        pagerState.animateScrollToPage(target)
-                                    } else {
-                                        verticalReaderState.scrollToPage(target)
-                                    }
-                                }
-                            }
+                        .align(Alignment.BottomCenter)
+                        .padding(bottom = 56.dp + effectiveNavBarForJumpBar),
+                    showStandardBars = showStandardBars,
+                    searchStateActive = searchState.isSearchActive,
+                    backPage = jumpBackPage,
+                    forwardPage = jumpForwardPage,
+                    onBack = {
+                        jumpBackPage?.let { target ->
+                            jumpHistoryCursor = (jumpHistoryCursor - 1).coerceAtLeast(0)
+                            navigateToJumpHistoryPage(target)
                         }
-                    ) {
-                        Row(
-                            modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
-                            verticalAlignment = Alignment.CenterVertically
-                        ) {
-                            Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = stringResource(R.string.content_desc_jump_back), modifier = Modifier.size(18.dp))
-                            Spacer(modifier = Modifier.width(8.dp))
-                            Text(stringResource(R.string.pdf_back_to_page_short, lastPage + 1), style = MaterialTheme.typography.labelLarge)
+                    },
+                    onForward = {
+                        jumpForwardPage?.let { target ->
+                            jumpHistoryCursor = (jumpHistoryCursor + 1).coerceAtMost(jumpHistory.lastIndex)
+                            navigateToJumpHistoryPage(target)
                         }
-                    }
-                }
+                    },
+                    onClear = { clearJumpHistory() }
+                )
 
                 // Bottom Bar
                 PdfBottomBar(
@@ -5075,80 +5311,31 @@ fun PdfViewerScreen(
                     systemUiMode = systemUiMode,
                     navBarHeightDp = with(density) { navBarHeight.toDp() },
                     hiddenTools = hiddenTools,
-                    isTtsPlayingOrLoading = ttsState.isPlaying || ttsState.isLoading,
+                    toolOrder = toolOrder,
+                    bottomTools = bottomTools,
+                    isTtsPlayingOrLoading = isPdfTtsPlayingOrLoading,
                     showAllTextHighlights = showAllTextHighlights,
                     isHighlightingLoading = isHighlightingLoading,
                     isEditMode = isEditMode,
                     isTtsSessionActive = isTtsSessionActive,
-                    ttsErrorMessage = ttsState.errorMessage,
-                    jumpBackPage = jumpHistory.lastOrNull(),
-                    onJumpBack = {
-                        val target = jumpHistory.removeLastOrNull()
-                        if (target != null) {
-                            showJumpPill = false
-                            coroutineScope.launch {
-                                if (displayMode == DisplayMode.PAGINATION) {
-                                    pagerState.animateScrollToPage(target)
-                                } else {
-                                    verticalReaderState.scrollToPage(target)
-                                }
-                            }
-                        }
-                    },
-                    onShowSlider = {
-                        val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
-                        sliderStartPage = currentPageForSlider
-                        sliderCurrentPage = currentPageForSlider.toFloat()
-                        isPageSliderVisible = true
-                        showBars = false
-                    },
-                    onShowToc = { coroutineScope.launch { drawerState.open() } },
-                    onSearchClick = {
-                        executeWithOcrCheck {
-                            searchState.isSearchActive = true
-                            showBars = true
-                        }
-                    },
-                    onToggleHighlights = {
-                        if (!showAllTextHighlights && !isHighlightingLoading) {
-                            showAllTextHighlights = true
-                            isHighlightingLoading = true
-                        } else if (showAllTextHighlights) {
-                            showAllTextHighlights = false
-                            isHighlightingLoading = false
-                        }
-                    },
-                    onShowAiHub = { showAiHubSheet = true },
-                    onToggleEditMode = {
-                        val newEditMode = !isEditMode
-                        val currentActivePage = richTextController?.activePageIndex ?: -1
-                        Timber.tag("RichTextMigration").i("Edit Toggle: $isEditMode -> $newEditMode (ActivePage: $currentActivePage)")
-
-                        if (!newEditMode && richTextController != null) {
-                            coroutineScope.launch {
-                                richTextController.saveImmediate()
-                                withContext(Dispatchers.Main) {
-                                    keyboardController?.hide()
-                                }
-                            }
-                        }
-
-                        isEditMode = newEditMode
-                        if (!newEditMode) showBars = true
-                    },
-                    onToggleTts = {
-                        if (isTtsSessionActive) {
-                            Timber.d("TTS button clicked: Stopping TTS")
-                            ttsController.stop()
-                            isAutoPagingForTts = false
-                        } else {
-                            startTtsWithPermissionCheck(null, null)
-                        }
-                    },
+                    ttsErrorMessage = null,
+                    onShowThemePanel = showPdfThemePanel,
+                    onToggleScrollLock = togglePdfScrollLock,
+                    onShowDictionarySettings = showPdfDictionarySettings,
+                    onShowSlider = showPdfSlider,
+                    onShowToc = showPdfToc,
+                    onSearchClick = showPdfSearch,
+                    onToggleHighlights = togglePdfHighlights,
+                    onShowAiHub = showPdfAiHub,
+                    onToggleEditMode = togglePdfEditMode,
+                    onToggleTts = togglePdfTts,
                     isBubbleZoomModeActive = isBubbleZoomModeActive,
                     onToggleBubbleZoom = {
                         if (isOss) {
-                            coroutineScope.launch { snackbarHostState.showSnackbar("Bubble Zoom is only available in Playstore version of Episteme") }
+                            showBanner(
+                                "Bubble Zoom is only available in Playstore version of Episteme",
+                                isError = true
+                            )
                         } else if (!isBubbleZoomModeActive && !viewModel.isSpeechBubbleModelAvailable(context)) {
                             showBubbleZoomDownloadDialog = true
                         } else {
@@ -5499,7 +5686,7 @@ fun PdfViewerScreen(
                     }
                 }
 
-                val ttsReadingPage = ttsPageData?.pageIndex
+                val ttsReadingPage = ttsDisplayPageIndex
 
                 val isTtsPageVisible by remember(
                     ttsReadingPage,
@@ -5731,728 +5918,6 @@ fun PdfViewerScreen(
                     }
                 }
 
-                if (showAiHubSheet) {
-                    val currentPageForDisplay = if (displayMode == DisplayMode.PAGINATION) {
-                        pagerState.currentPage
-                    } else {
-                        verticalReaderState.currentPage
-                    }
-                    val bookTitle = documentMetadataTitle ?: originalFileName
-
-                    AiHubBottomSheet(
-                        bookTitle = bookTitle,
-                        currentChapterIndex = currentPageForDisplay,
-                        chapterTitle = "Page ${currentPageForDisplay + 1}",
-                        summaryCacheManager = summaryCacheManager,
-                        summarizationResult = summarizationResult,
-                        isSummarizationLoading = isSummarizationLoading,
-                        onClearSummary = { summarizationResult = null },
-                        onGenerateSummary = { force ->
-                            if (!isProUser && uiState.credits <= 0) {
-                                showInsufficientCreditsDialog = true
-                                showAiHubSheet = false
-                            } else {
-                                coroutineScope.launch {
-                                    isSummarizationLoading = true
-                                    summarizationResult = null
-
-                                    val cached = if (!force) summaryCacheManager.getSummary(bookTitle, currentPageForDisplay) else null
-                                    if (cached != null) {
-                                        summarizationResult = SummarizationResult(summary = cached, isCacheHit = true)
-                                        isSummarizationLoading = false
-                                        return@launch
-                                    }
-
-                                    val token = viewModel.getAuthToken()
-                                    summarizeCurrentPage(
-                                        authToken = token,
-                                        onUpdate = { result ->
-                                            if (result.error == "INSUFFICIENT_CREDITS") {
-                                                showInsufficientCreditsDialog = true
-                                                showAiHubSheet = false
-                                                isSummarizationLoading = false
-                                            } else {
-                                                summarizationResult = result
-                                            }
-                                        }, onFinish = {
-                                            isSummarizationLoading = false
-                                            val finalSummary = summarizationResult?.summary
-                                            if (!finalSummary.isNullOrBlank() && summarizationResult?.error == null) {
-                                                summaryCacheManager.saveSummary(bookTitle, currentPageForDisplay, "Page ${currentPageForDisplay + 1}", finalSummary)
-                                            }
-                                        }
-                                    )
-                                }
-                            }
-                        },
-                        recapResult = null,
-                        isRecapLoading = false,
-                        onGenerateRecap = null,
-                        onDismiss = { showAiHubSheet = false },
-                        isMainTtsActive = isTtsSessionActive,
-                        getAuthToken = { viewModel.getAuthToken() },
-                        credits = uiState.credits,
-                        isProUser = isProUser
-                    )
-                }
-
-                if (showPermissionRationaleDialog) {
-                    AlertDialog(
-                        onDismissRequest = { showPermissionRationaleDialog = false },
-                        title = { Text(stringResource(R.string.dialog_permission_required)) },
-                        text = {
-                            Text(
-                                stringResource(R.string.dialog_permission_notification_desc)
-                            )
-                        },
-                        confirmButton = {
-                            TextButton(
-                                onClick = {
-                                    showPermissionRationaleDialog = false
-                                    permissionLauncher.launch(
-                                        Manifest.permission.POST_NOTIFICATIONS
-                                    )
-                                }) { Text(stringResource(R.string.action_continue)) }
-                        },
-                        dismissButton = {
-                            TextButton(
-                                onClick = {
-                                    showPermissionRationaleDialog = false
-                                    startTts()
-                                }) { Text(stringResource(R.string.action_not_now)) }
-                        })
-                }
-                if (showSummarizationUpsellDialog) {
-                    AlertDialog(
-                        onDismissRequest = { showSummarizationUpsellDialog = false },
-                        icon = {
-                            Icon(
-                                painter = painterResource(id = R.drawable.summarize),
-                                contentDescription = null
-                            )
-                        },
-                        title = { Text(stringResource(R.string.dialog_unlock_page_summarization)) },
-                        text = {
-                            Text(
-                                stringResource(R.string.dialog_unlock_page_summarization_desc)
-                            )
-                        },
-                        confirmButton = {
-                            TextButton(
-                                onClick = {
-                                    showSummarizationUpsellDialog = false
-                                    onNavigateToPro()
-                                }) { Text(stringResource(R.string.action_learn_more)) }
-                        },
-                        dismissButton = {
-                            TextButton(onClick = { showSummarizationUpsellDialog = false }) {
-                                Text(stringResource(R.string.action_not_now))
-                            }
-                        })
-                }
-
-                if (showInsufficientCreditsDialog) {
-                    AlertDialog(
-                        onDismissRequest = { showInsufficientCreditsDialog = false },
-                        icon = { Icon(painterResource(id = R.drawable.crown), contentDescription = null) },
-                        title = { Text(stringResource(R.string.dialog_out_of_credits_title)) },
-                        text = { Text(stringResource(R.string.dialog_out_of_credits_desc)) },
-                        confirmButton = {
-                            TextButton(onClick = {
-                                showInsufficientCreditsDialog = false
-                                onNavigateToPro()
-                            }) { Text(stringResource(R.string.action_get_pro_or_add_credits)) }
-                        },
-                        dismissButton = {
-                            TextButton(onClick = { showInsufficientCreditsDialog = false }) {
-                                Text(stringResource(R.string.action_cancel))
-                            }
-                        }
-                    )
-                }
-
-                // --- PANEL POPUP ---
-                if (poppedUpPanelBitmap != null) {
-                    Box(
-                        modifier = Modifier
-                            .fillMaxSize()
-                            .background(Color.Black.copy(alpha = 0.85f))
-                            .clickable(
-                                interactionSource = remember { MutableInteractionSource() },
-                                indication = null
-                            ) {
-                                poppedUpPanelBitmap?.recycle()
-                                poppedUpPanelBitmap = null
-                            },
-                        contentAlignment = Alignment.Center
-                    ) {
-                        Image(
-                            bitmap = poppedUpPanelBitmap!!.asImageBitmap(),
-                            contentDescription = stringResource(R.string.content_desc_annotated_page),
-                            modifier = Modifier
-                                .fillMaxWidth()
-                                .padding(16.dp)
-                                .clip(RoundedCornerShape(12.dp)),
-                            contentScale = ContentScale.Fit
-                        )
-
-                        IconButton(
-                            onClick = {
-                                poppedUpPanelBitmap?.recycle()
-                                poppedUpPanelBitmap = null
-                            },
-                            modifier = Modifier
-                                .align(Alignment.TopEnd)
-                                .padding(24.dp)
-                                .background(Color.Black.copy(alpha = 0.6f), CircleShape)
-                        ) {
-                            Icon(
-                                imageVector = Icons.Default.Close,
-                                contentDescription = stringResource(R.string.content_desc_close_image),
-                                tint = Color.White
-                            )
-                        }
-                    }
-                }
-                // --- END PANEL POPUP ---
-
-                if (showPasswordDialog) {
-                    PasswordDialog(
-                        isError = isPasswordError,
-                        onDismiss = { onNavigateBack() },
-                        onConfirm = { password -> documentPassword = password })
-                }
-
-                if (showBubbleZoomDownloadDialog) {
-                    AlertDialog(
-                        onDismissRequest = { showBubbleZoomDownloadDialog = false },
-                        icon = { Icon(Icons.Default.Info, contentDescription = null) },
-                        title = { Text(stringResource(R.string.dialog_download_bubble_zoom_model)) },
-                        text = {
-                            Text(stringResource(R.string.dialog_download_bubble_zoom_model_desc))
-                        },
-                        confirmButton = {
-                            TextButton(onClick = {
-                                showBubbleZoomDownloadDialog = false
-                                viewModel.downloadSpeechBubbleModel(context)
-                            }) {
-                                Text(stringResource(R.string.action_download))
-                            }
-                        },
-                        dismissButton = {
-                            TextButton(onClick = { showBubbleZoomDownloadDialog = false }) {
-                                Text(stringResource(R.string.action_cancel))
-                            }
-                        }
-                    )
-                }
-
-                if (showNewTabSheet) {
-                    ModalBottomSheet(
-                        onDismissRequest = { showNewTabSheet = false },
-                        sheetState = sheetState,
-                        containerColor = MaterialTheme.colorScheme.surface
-                    ) {
-                        val pdfFiles = remember(uiState.rawLibraryFiles, openTabs) {
-                            val openIds = openTabs.map { it.bookId }
-                            uiState.rawLibraryFiles
-                                .filter { it.type == FileType.PDF && it.bookId !in openIds }
-                                .sortedByDescending { it.timestamp }
-                        }
-
-                        Column(
-                            modifier = Modifier
-                                .fillMaxWidth()
-                                .padding(bottom = 16.dp)
-                        ) {
-                            Text(
-                                text = stringResource(R.string.title_add_pdf_to_tab),
-                                style = MaterialTheme.typography.titleLarge,
-                                modifier = Modifier.padding(16.dp)
-                            )
-                            if (pdfFiles.isEmpty()) {
-                                Text(
-                                    stringResource(R.string.msg_no_other_pdfs_found),
-                                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
-                                    color = MaterialTheme.colorScheme.onSurfaceVariant
-                                )
-                            } else {
-                                LazyColumn(modifier = Modifier.fillMaxWidth()) {
-                                    items(pdfFiles, key = { it.bookId }) { file ->
-                                        ListItem(
-                                            headlineContent = { Text(file.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) },
-                                            supportingContent = { file.author?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) } },
-                                            modifier = Modifier.clickable {
-                                                coroutineScope.launch {
-                                                    sheetState.hide()
-                                                    showNewTabSheet = false
-                                                    viewModel.switchTab(file.bookId)
-                                                }
-                                            }
-                                        )
-                                        HorizontalDivider()
-                                    }
-                                }
-                            }
-                        }
-                    }
-                }
-
-                if (showPenPlayground) {
-                    Box(
-                        modifier = Modifier
-                            .fillMaxSize()
-                            .background(Color.Black.copy(alpha = 0.6f))
-                            .clickable { showPenPlayground = false },
-                        contentAlignment = Alignment.Center
-                    ) { PenPlayground(onClose = { showPenPlayground = false }) }
-                }
-
-                if (showAiDefinitionPopup) {
-                    AiDefinitionPopup(
-                        word = selectedTextForAi,
-                        result = aiDefinitionResult,
-                        isLoading = isAiDefinitionLoading,
-                        onDismiss = {
-                            showAiDefinitionPopup = false
-                            selectedTextForAi = null
-                            aiDefinitionResult = null
-                        },
-                        isMainTtsActive = isTtsSessionActive,
-                        onOpenExternalDictionary = {
-                            selectedTextForAi?.let { text ->
-                                if (!selectedDictPackage.isNullOrEmpty()) {
-                                    ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
-                                } else {
-                                    Toast.makeText(context, context.getString(R.string.toast_select_offline_dict_first), Toast.LENGTH_SHORT).show()
-                                    showDictionarySettingsSheet = true
-                                }
-                            }
-                        },
-                        getAuthToken = { viewModel.getAuthToken() }
-                    )
-                }
-                if (showDictionaryUpsellDialog) {
-                    AlertDialog(onDismissRequest = { showDictionaryUpsellDialog = false }, icon = {
-                        Icon(
-                            painter = painterResource(id = R.drawable.ai),
-                            contentDescription = null
-                        )
-                    }, title = { Text(stringResource(R.string.ai_unlock_smart_dict)) }, text = {
-                        Text(
-                            stringResource(R.string.ai_unlock_smart_dict_desc)
-                        )
-                    }, confirmButton = {
-                        TextButton(
-                            onClick = {
-                                showDictionaryUpsellDialog = false
-                                onNavigateToPro()
-                            }) { Text(stringResource(R.string.action_learn_more)) }
-                    }, dismissButton = {
-                        TextButton(onClick = { showDictionaryUpsellDialog = false }) {
-                            Text(stringResource(R.string.action_not_now))
-                        }
-                    })
-                }
-
-                showReindexDialog?.let { newLanguage ->
-                    if (BuildConfig.IS_PRO) {
-                        AlertDialog(
-                            onDismissRequest = { showReindexDialog = null },
-                            icon = { Icon(Icons.Default.Info, contentDescription = null) },
-                            title = { Text(stringResource(R.string.title_reindex_document)) },
-                            text = {
-                                Text(
-                                    stringResource(R.string.desc_reindex_document_warning)
-                                )
-                            },
-                            confirmButton = {
-                                TextButton(
-                                    onClick = {
-                                        coroutineScope.launch {
-                                            ocrLanguage = newLanguage
-                                            saveOcrLanguage(context, newLanguage)
-                                            hasSelectedOcrLanguage = true
-
-                                            currentBookId?.let { id ->
-                                                isBackgroundIndexing = true
-                                                backgroundIndexingProgress = 0f
-                                                withContext(Dispatchers.IO) {
-                                                    pdfTextRepository.clearBookText(id)
-                                                    pdfTextRepository.setBookLanguage(id, newLanguage.name)
-                                                }
-                                                isBackgroundIndexing = false
-                                            }
-
-                                            pendingActionAfterOcrSelection?.invoke()
-                                            pendingActionAfterOcrSelection = null
-                                            showReindexDialog = null
-                                            showOcrLanguageDialog = false
-                                        }
-                                    }
-                                ) { Text(stringResource(R.string.action_reindex)) }
-                            },
-                            dismissButton = {
-                                TextButton(onClick = { showReindexDialog = null }) {
-                                    Text(stringResource(R.string.action_cancel))
-                                }
-                            }
-                        )
-                    } else {
-                        showReindexDialog = null
-                    }
-                }
-
-                if (showOcrLanguageDialog && !isOss) {
-                    OcrLanguageSelectionDialog(
-                        currentLanguage = ocrLanguage,
-                        isFirstRun = !hasSelectedOcrLanguage,
-                        onDismiss = {
-                            showOcrLanguageDialog = false
-                            pendingActionAfterOcrSelection = null
-                        },
-                        onLanguageSelected = { selected ->
-                            coroutineScope.launch {
-                                val storedLangName = currentBookId?.let {
-                                    pdfTextRepository.getBookLanguage(it)
-                                }
-
-                                val hasIndexedPages = currentBookId?.let {
-                                    pdfTextRepository.getIndexedPages(it).isNotEmpty()
-                                } == true
-
-                                if (hasIndexedPages && storedLangName != null && storedLangName != selected.name) {
-                                    showReindexDialog = selected
-                                    showOcrLanguageDialog = false
-                                } else {
-                                    ocrLanguage = selected
-                                    saveOcrLanguage(context, selected)
-                                    hasSelectedOcrLanguage = true
-
-                                    currentBookId?.let {
-                                        pdfTextRepository.setBookLanguage(it, selected.name)
-                                    }
-
-                                    showOcrLanguageDialog = false
-                                    pendingActionAfterOcrSelection?.invoke()
-                                    pendingActionAfterOcrSelection = null
-                                }
-                            }
-                        })
-                }
-
-                if (showTtsSettingsSheet) {
-                    val bookTitle = documentMetadataTitle ?: originalFileName
-                    TtsSettingsSheet(
-                        isVisible = true,
-                        onDismiss = { showTtsSettingsSheet = false },
-                        currentMode = currentTtsMode,
-                        onModeChange = { newMode ->
-                            currentTtsMode = newMode
-                            saveTtsMode(context, newMode)
-                            ttsController.changeTtsMode(newMode.name)
-                        },
-                        currentSpeakerId = ttsState.speakerId,
-                        onSpeakerChange = { newSpeaker ->
-                            ttsController.changeSpeaker(newSpeaker)
-                        },
-                        isTtsActive = isTtsSessionActive,
-                        getAuthToken = { viewModel.getAuthToken() },
-                        bookTitle = bookTitle
-                    )
-                }
-
-                if (showDictionarySettingsSheet) {
-                    DictionarySettingsDialog(
-                        isVisible = true,
-                        onDismiss = { showDictionarySettingsSheet = false },
-                        isProUser = isProUser,
-                        useOnlineDictionary = useOnlineDictionary,
-                        onToggleOnlineDictionary = { newState ->
-                            useOnlineDictionary = newState
-                            saveUseOnlineDict(context, newState)
-                        },
-                        selectedDictionaryPackageName = selectedDictPackage,
-                        onSelectDictionaryPackage = { pkg ->
-                            selectedDictPackage = pkg
-                            saveExternalDictPackage(context, pkg)
-                        },
-                        selectedTranslatePackageName = selectedTranslatePackage,
-                        onSelectTranslatePackage = { pkg ->
-                            selectedTranslatePackage = pkg
-                            saveExternalTranslatePackage(context, pkg)
-                        },
-                        selectedSearchPackageName = selectedSearchPackage,
-                        onSelectSearchPackage = { pkg ->
-                            selectedSearchPackage = pkg
-                            saveExternalSearchPackage(context, pkg)
-                        }
-                    )
-                }
-
-                if (highlightToNoteId != null) {
-                    val targetHighlight = userHighlights.find { it.id == highlightToNoteId }
-                    if (targetHighlight != null) {
-                        val effectiveBg = if (activeTheme.backgroundColor == Color.Unspecified) MaterialTheme.colorScheme.surface else activeTheme.backgroundColor
-                        val effectiveText = if (activeTheme.textColor == Color.Unspecified) MaterialTheme.colorScheme.onSurface else activeTheme.textColor
-
-                        PdfAnnotationBottomSheet(
-                            highlight = targetHighlight,
-                            effectiveBg = effectiveBg,
-                            effectiveText = effectiveText,
-                            customHighlightColors = customHighlightColors,
-                            onPaletteClick = {
-                                highlightColorPickerInitialSlot = targetHighlight.color
-                                showHighlightColorPicker = true
-                            },
-                            onColorChange = { newColor ->
-                                onHighlightUpdate(
-                                    targetHighlight.id,
-                                    newColor
-                                )
-                            },
-                            onDismiss = { highlightToNoteId = null },
-                            onSave = { noteText ->
-                                val index =
-                                    userHighlights.indexOfFirst { it.id == targetHighlight.id }
-                                if (index != -1) {
-                                    userHighlights[index] =
-                                        targetHighlight.copy(note = noteText.takeIf { it.isNotBlank() })
-                                }
-                                highlightToNoteId = null
-                            },
-                            onDelete = {
-                                onHighlightDelete(targetHighlight.id)
-                                highlightToNoteId = null
-                            },
-                            onCopy = {
-                                val clip = ClipData.newPlainText(
-                                    "Copied Text",
-                                    targetHighlight.text
-                                )
-                                clipboardManager.setText(
-                                    androidx.compose.ui.text.AnnotatedString(
-                                        targetHighlight.text
-                                    )
-                                )
-                                highlightToNoteId = null
-                            },
-                            onDictionary = {
-                                onDictionaryLookupStable(targetHighlight.text)
-                                highlightToNoteId = null
-                            },
-                            onTranslate = {
-                                onTranslateTextStable(targetHighlight.text)
-                                highlightToNoteId = null
-                            },
-                            onSearch = {
-                                onSearchTextStable(targetHighlight.text)
-                                highlightToNoteId = null
-                            }
-                        )
-                    } else {
-                        highlightToNoteId = null
-                    }
-                }
-
-                if (showHighlightColorPicker) {
-                    HighlightColorPickerDialog(
-                        initialColors = customHighlightColors,
-                        initialSelection = highlightColorPickerInitialSlot,
-                        onDismiss = { showHighlightColorPicker = false },
-                        onSave = { newColors ->
-                            customHighlightColors = newColors
-                            saveCustomHighlightColors(context, newColors)
-                            showHighlightColorPicker = false
-                        }
-                    )
-                }
-
-                if (showThemePanel) {
-                    ReaderThemePanel(
-                        isVisible = true,
-                        currentThemeId = currentThemeId,
-                        excludeImages = excludeImages,
-                        onExcludeImagesChange = {
-                            excludeImages = it
-                            com.aryan.reader.saveExcludeImages(context, it)
-                        },
-                        showExcludeImagesOption = true,
-                        builtInThemes = PdfBuiltInThemes,
-                        onThemeSelected = {
-                            currentThemeId = it
-                            savePdfThemeId(context, it)
-                            showThemePanel = false
-                        },
-                        onDismiss = { showThemePanel = false },
-                        customThemes = customThemes,
-                        onCustomThemesUpdated = {
-                            customThemes = it
-                            saveCustomThemes(context, it)
-                        }
-                    )
-                }
-
-                if (clickedLinkUrl != null) {
-                    val url = clickedLinkUrl!!
-                    AlertDialog(
-                        onDismissRequest = { clickedLinkUrl = null },
-                        title = { Text(stringResource(R.string.dialog_external_link_title)) },
-                        text = { Text(stringResource(R.string.desc_external_link_warning)) },
-                        confirmButton = {
-                            TextButton(
-                                onClick = {
-                                    try {
-                                        uriHandler.openUri(url)
-                                    } catch (e: Exception) {
-                                        Timber.e(e, "Failed to open URI")
-                                    }
-                                    clickedLinkUrl = null
-                                }) { Text(stringResource(R.string.action_visit)) }
-                        },
-                        dismissButton = {
-                            Row {
-                                TextButton(
-                                    onClick = {
-                                        clipboardManager.setText(AnnotatedString(url))
-                                        clickedLinkUrl = null
-                                    }) { Text(stringResource(R.string.action_copy)) }
-                                TextButton(onClick = { clickedLinkUrl = null }) {
-                                    Text(stringResource(R.string.action_cancel))
-                                }
-                            }
-                        })
-                }
-
-                if (showSaveDialog) {
-                    AlertDialog(
-                        onDismissRequest = { showSaveDialog = false },
-                        title = { Text(stringResource(R.string.title_save_to_device)) },
-                        text = { Text(stringResource(R.string.desc_choose_format_save)) },
-                        confirmButton = {
-                            TextButton(
-                                onClick = {
-                                    showSaveDialog = false
-                                    pendingSaveMode = SaveMode.ANNOTATED
-                                    val suggestedName = getSuggestedFilename(
-                                        originalFileName, isAnnotated = true
-                                    )
-                                    saveLauncher.launch(suggestedName)
-                                }) { Text(stringResource(R.string.action_with_annotations)) }
-                        },
-                        dismissButton = {
-                            Row {
-                                TextButton(
-                                    onClick = {
-                                        showSaveDialog = false
-                                        pendingSaveMode = SaveMode.ORIGINAL
-                                        val suggestedName = getSuggestedFilename(
-                                            originalFileName, isAnnotated = false
-                                        )
-                                        saveLauncher.launch(suggestedName)
-                                    }) { Text(stringResource(R.string.action_original)) }
-
-                                Spacer(Modifier.width(8.dp))
-
-                                TextButton(
-                                    onClick = {
-                                        showSaveDialog = false
-                                        pendingSaveMode = null
-                                    }) { Text(stringResource(R.string.action_cancel)) }
-                            }
-                        })
-                }
-
-                if (showShareDialog) {
-                    AlertDialog(
-                        onDismissRequest = { showShareDialog = false },
-                        title = { Text(stringResource(R.string.share_chooser_title)) },
-                        text = { Text(stringResource(R.string.desc_choose_format_share)) },
-                        confirmButton = {
-                            TextButton(
-                                onClick = {
-                                    showShareDialog = false
-                                    isShareLoading = true
-                                    Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
-                                    val filename = getSuggestedFilename(
-                                        originalFileName, isAnnotated = true
-                                    )
-                                    coroutineScope.launch {
-                                        val currentRichTextLayouts = richTextController?.pageLayouts
-
-                                        viewModel.sharePdf(
-                                            activityContext = context,
-                                            sourceUri = effectivePdfUri,
-                                            annotations = allAnnotations,
-                                            richTextPageLayouts = currentRichTextLayouts,
-                                            textBoxes = textBoxes.toList(),
-                                            highlights = userHighlights.toList(),
-                                            includeAnnotations = true,
-                                            filename = filename,
-                                            bookId = currentBookId
-                                        )
-                                        isShareLoading = false
-                                    }
-                                }) { Text(stringResource(R.string.action_with_annotations)) }
-                        },
-                        dismissButton = {
-                            Row {
-                                TextButton(
-                                    onClick = {
-                                        showShareDialog = false
-                                        isShareLoading = true
-                                        val filename = getSuggestedFilename(
-                                            originalFileName, isAnnotated = false
-                                        )
-                                        coroutineScope.launch {
-                                            viewModel.sharePdf(
-                                                activityContext = context,
-                                                sourceUri = pdfUri,
-                                                annotations = allAnnotations,
-                                                includeAnnotations = false,
-                                                filename = filename
-                                            )
-                                            isShareLoading = false
-                                        }
-                                    }) { Text(stringResource(R.string.action_original)) }
-                                Spacer(Modifier.width(8.dp))
-                                TextButton(onClick = { showShareDialog = false }) {
-                                    Text(stringResource(R.string.action_cancel))
-                                }
-                            }
-                        })
-                }
-
-                if (isShareLoading) {
-                    Box(
-                        modifier = Modifier
-                            .fillMaxSize()
-                            .background(Color.Black.copy(alpha = 0.5f))
-                            .clickable(enabled = false) {}, contentAlignment = Alignment.Center
-                    ) {
-                        Surface(
-                            shape = RoundedCornerShape(16.dp),
-                            color = MaterialTheme.colorScheme.surface,
-                            tonalElevation = 6.dp
-                        ) {
-                            Column(
-                                modifier = Modifier.padding(24.dp),
-                                horizontalAlignment = Alignment.CenterHorizontally,
-                                verticalArrangement = Arrangement.Center
-                            ) {
-                                CircularProgressIndicator(
-                                    modifier = Modifier.size(32.dp), strokeWidth = 3.dp
-                                )
-                                Spacer(modifier = Modifier.height(16.dp))
-                                Text(
-                                    text = stringResource(R.string.msg_preparing_pdf),
-                                    style = MaterialTheme.typography.bodyLarge
-                                )
-                            }
-                        }
-                    }
-                }
-
                 val effectiveNavBarPaddingForOverlays = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp
                 val autoScrollPadding by animateDpAsState(
                     targetValue = if (showStandardBars) (56.dp + 16.dp + effectiveNavBarPaddingForOverlays) else (16.dp + effectiveNavBarPaddingForOverlays),
@@ -6485,7 +5950,7 @@ fun PdfViewerScreen(
                         isCollapsed = isTtsCollapsed,
                         onCollapseChange = { isTtsCollapsed = it },
                         onLocateCurrentChunk = {
-                            ttsPageData?.pageIndex?.let { targetPage ->
+                            ttsDisplayPageIndex?.let { targetPage ->
                                 coroutineScope.launch {
                                     if (displayMode == DisplayMode.PAGINATION) {
                                         pagerState.scrollToPage(targetPage)
@@ -6603,24 +6068,758 @@ fun PdfViewerScreen(
                         }
                     )
                 }
-                if (showVisualOptionsSheet) {
-                    PdfVisualOptionsSheet(
-                        systemUiMode = systemUiMode,
-                        onSystemUiModeChange = { mode ->
-                            systemUiMode = mode
-                            savePdfSystemUiMode(context, mode)
-                        },
-                        onDismiss = { showVisualOptionsSheet = false }
-                    )
+
+                CustomTopBanner(bannerMessage = bannerMessage)
+            }
+        }
+    }
+
+    if (showAiHubSheet) {
+        val currentPageForDisplay = if (displayMode == DisplayMode.PAGINATION) {
+            pagerState.currentPage
+        } else {
+            verticalReaderState.currentPage
+        }
+        val bookTitle = documentMetadataTitle ?: originalFileName
+
+        AiHubBottomSheet(
+            bookTitle = bookTitle,
+            currentChapterIndex = currentPageForDisplay,
+            chapterTitle = "Page ${currentPageForDisplay + 1}",
+            summaryCacheManager = summaryCacheManager,
+            summarizationResult = summarizationResult,
+            isSummarizationLoading = isSummarizationLoading,
+            onClearSummary = { summarizationResult = null },
+            onGenerateSummary = { force ->
+                if (BuildConfig.FLAVOR != "oss" && !isProUser && uiState.credits <= 0) {
+                    showInsufficientCreditsDialog = true
+                    showAiHubSheet = false
+                } else {
+                    coroutineScope.launch {
+                        isSummarizationLoading = true
+                        summarizationResult = null
+
+                        val cached = if (!force) summaryCacheManager.getSummary(bookTitle, currentPageForDisplay) else null
+                        if (cached != null) {
+                            summarizationResult = SummarizationResult(summary = cached, isCacheHit = true)
+                            isSummarizationLoading = false
+                            return@launch
+                        }
+
+                        val token = viewModel.getAuthToken()
+                        summarizeCurrentPage(
+                            authToken = token,
+                            onUpdate = { result ->
+                                if (result.error == "INSUFFICIENT_CREDITS") {
+                                    showInsufficientCreditsDialog = true
+                                    showAiHubSheet = false
+                                    isSummarizationLoading = false
+                                } else {
+                                    summarizationResult = result
+                                }
+                            }, onFinish = {
+                                isSummarizationLoading = false
+                                val finalSummary = summarizationResult?.summary
+                                if (!finalSummary.isNullOrBlank() && summarizationResult?.error == null) {
+                                    summaryCacheManager.saveSummary(bookTitle, currentPageForDisplay, "Page ${currentPageForDisplay + 1}", finalSummary)
+                                }
+                            }
+                        )
+                    }
                 }
-                if (showCustomizeToolsSheet) {
-                    PdfCustomizeToolsSheet(
-                        hiddenTools = hiddenTools,
-                        onUpdate = onUpdateHiddenTools,
-                        onDismiss = { showCustomizeToolsSheet = false }
+            },
+            recapResult = null,
+            isRecapLoading = false,
+            onGenerateRecap = null,
+            onDismiss = { showAiHubSheet = false },
+            isMainTtsActive = isTtsSessionActive,
+            getAuthToken = { viewModel.getAuthToken() },
+            credits = uiState.credits,
+            isProUser = isProUser
+        )
+    }
+
+    if (showPermissionRationaleDialog) {
+        AlertDialog(
+            onDismissRequest = { showPermissionRationaleDialog = false },
+            title = { Text(stringResource(R.string.dialog_permission_required)) },
+            text = {
+                Text(
+                    stringResource(R.string.dialog_permission_notification_desc)
+                )
+            },
+            confirmButton = {
+                TextButton(
+                    onClick = {
+                        showPermissionRationaleDialog = false
+                        permissionLauncher.launch(
+                            Manifest.permission.POST_NOTIFICATIONS
+                        )
+                    }) { Text(stringResource(R.string.action_continue)) }
+            },
+            dismissButton = {
+                TextButton(
+                    onClick = {
+                        showPermissionRationaleDialog = false
+                        startTts()
+                    }) { Text(stringResource(R.string.action_not_now)) }
+            })
+    }
+    if (showSummarizationUpsellDialog) {
+        AlertDialog(
+            onDismissRequest = { showSummarizationUpsellDialog = false },
+            icon = {
+                Icon(
+                    painter = painterResource(id = R.drawable.summarize),
+                    contentDescription = null
+                )
+            },
+            title = { Text(stringResource(R.string.dialog_unlock_page_summarization)) },
+            text = {
+                Text(
+                    stringResource(R.string.dialog_unlock_page_summarization_desc)
+                )
+            },
+            confirmButton = {
+                TextButton(
+                    onClick = {
+                        showSummarizationUpsellDialog = false
+                        onNavigateToPro()
+                    }) { Text(stringResource(R.string.action_learn_more)) }
+            },
+            dismissButton = {
+                TextButton(onClick = { showSummarizationUpsellDialog = false }) {
+                    Text(stringResource(R.string.action_not_now))
+                }
+            })
+    }
+
+    if (showInsufficientCreditsDialog) {
+        AlertDialog(
+            onDismissRequest = { showInsufficientCreditsDialog = false },
+            icon = { Icon(painterResource(id = R.drawable.crown), contentDescription = null) },
+            title = { Text(stringResource(R.string.dialog_out_of_credits_title)) },
+            text = { Text(stringResource(R.string.dialog_out_of_credits_desc)) },
+            confirmButton = {
+                TextButton(onClick = {
+                    showInsufficientCreditsDialog = false
+                    onNavigateToPro()
+                }) { Text(stringResource(R.string.action_get_pro_or_add_credits)) }
+            },
+            dismissButton = {
+                TextButton(onClick = { showInsufficientCreditsDialog = false }) {
+                    Text(stringResource(R.string.action_cancel))
+                }
+            }
+        )
+    }
+
+    // --- PANEL POPUP ---
+    if (poppedUpPanelBitmap != null) {
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .background(Color.Black.copy(alpha = 0.85f))
+                .clickable(
+                    interactionSource = remember { MutableInteractionSource() },
+                    indication = null
+                ) {
+                    poppedUpPanelBitmap?.recycle()
+                    poppedUpPanelBitmap = null
+                },
+            contentAlignment = Alignment.Center
+        ) {
+            Image(
+                bitmap = poppedUpPanelBitmap!!.asImageBitmap(),
+                contentDescription = stringResource(R.string.content_desc_annotated_page),
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(16.dp)
+                    .clip(RoundedCornerShape(12.dp)),
+                contentScale = ContentScale.Fit
+            )
+
+            IconButton(
+                onClick = {
+                    poppedUpPanelBitmap?.recycle()
+                    poppedUpPanelBitmap = null
+                },
+                modifier = Modifier
+                    .align(Alignment.TopEnd)
+                    .padding(24.dp)
+                    .background(Color.Black.copy(alpha = 0.6f), CircleShape)
+            ) {
+                Icon(
+                    imageVector = Icons.Default.Close,
+                    contentDescription = stringResource(R.string.content_desc_close_image),
+                    tint = Color.White
+                )
+            }
+        }
+    }
+    // --- END PANEL POPUP ---
+
+    if (showPasswordDialog) {
+        PasswordDialog(
+            isError = isPasswordError,
+            onDismiss = { onNavigateBack() },
+            onConfirm = { password -> documentPassword = password })
+    }
+
+    if (showBubbleZoomDownloadDialog) {
+        AlertDialog(
+            onDismissRequest = { showBubbleZoomDownloadDialog = false },
+            icon = { Icon(Icons.Default.Info, contentDescription = null) },
+            title = { Text(stringResource(R.string.dialog_download_bubble_zoom_model)) },
+            text = {
+                Text(stringResource(R.string.dialog_download_bubble_zoom_model_desc))
+            },
+            confirmButton = {
+                TextButton(onClick = {
+                    showBubbleZoomDownloadDialog = false
+                    viewModel.downloadSpeechBubbleModel(context)
+                }) {
+                    Text(stringResource(R.string.action_download))
+                }
+            },
+            dismissButton = {
+                TextButton(onClick = { showBubbleZoomDownloadDialog = false }) {
+                    Text(stringResource(R.string.action_cancel))
+                }
+            }
+        )
+    }
+
+    if (showNewTabSheet) {
+        ModalBottomSheet(
+            onDismissRequest = { showNewTabSheet = false },
+            sheetState = sheetState,
+            containerColor = MaterialTheme.colorScheme.surface
+        ) {
+            val pdfFiles = remember(uiState.rawLibraryFiles, openTabs) {
+                val openIds = openTabs.map { it.bookId }
+                uiState.rawLibraryFiles
+                    .filter { it.type == FileType.PDF && it.bookId !in openIds }
+                    .sortedByDescending { it.timestamp }
+            }
+
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(bottom = 16.dp)
+            ) {
+                Text(
+                    text = stringResource(R.string.title_add_pdf_to_tab),
+                    style = MaterialTheme.typography.titleLarge,
+                    modifier = Modifier.padding(16.dp)
+                )
+                if (pdfFiles.isEmpty()) {
+                    Text(
+                        stringResource(R.string.msg_no_other_pdfs_found),
+                        modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+                        color = MaterialTheme.colorScheme.onSurfaceVariant
+                    )
+                } else {
+                    LazyColumn(modifier = Modifier.fillMaxWidth()) {
+                        items(pdfFiles, key = { it.bookId }) { file ->
+                            ListItem(
+                                headlineContent = { Text(file.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+                                supportingContent = { file.author?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) } },
+                                modifier = Modifier.clickable {
+                                    coroutineScope.launch {
+                                        sheetState.hide()
+                                        showNewTabSheet = false
+                                        viewModel.switchTab(file.bookId)
+                                    }
+                                }
+                            )
+                            HorizontalDivider()
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    if (showPenPlayground) {
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .background(Color.Black.copy(alpha = 0.6f))
+                .clickable { showPenPlayground = false },
+            contentAlignment = Alignment.Center
+        ) { PenPlayground(onClose = { showPenPlayground = false }) }
+    }
+
+    if (showAiDefinitionPopup) {
+        AiDefinitionPopup(
+            word = selectedTextForAi,
+            result = aiDefinitionResult,
+            isLoading = isAiDefinitionLoading,
+            onDismiss = {
+                showAiDefinitionPopup = false
+                selectedTextForAi = null
+                aiDefinitionResult = null
+            },
+            isMainTtsActive = isTtsSessionActive,
+            onOpenExternalDictionary = {
+                selectedTextForAi?.let { text ->
+                    if (!selectedDictPackage.isNullOrEmpty()) {
+                        ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
+                    } else {
+                        Toast.makeText(context, context.getString(R.string.toast_select_offline_dict_first), Toast.LENGTH_SHORT).show()
+                        showDictionarySettingsSheet = true
+                    }
+                }
+            },
+            getAuthToken = { viewModel.getAuthToken() }
+        )
+    }
+    if (showDictionaryUpsellDialog) {
+        AlertDialog(onDismissRequest = { showDictionaryUpsellDialog = false }, icon = {
+            Icon(
+                painter = painterResource(id = R.drawable.ai),
+                contentDescription = null
+            )
+        }, title = { Text(stringResource(R.string.ai_unlock_smart_dict)) }, text = {
+            Text(
+                stringResource(R.string.ai_unlock_smart_dict_desc)
+            )
+        }, confirmButton = {
+            TextButton(
+                onClick = {
+                    showDictionaryUpsellDialog = false
+                    onNavigateToPro()
+                }) { Text(stringResource(R.string.action_learn_more)) }
+        }, dismissButton = {
+            TextButton(onClick = { showDictionaryUpsellDialog = false }) {
+                Text(stringResource(R.string.action_not_now))
+            }
+        })
+    }
+
+    showReindexDialog?.let { newLanguage ->
+        if (BuildConfig.IS_PRO) {
+            AlertDialog(
+                onDismissRequest = { showReindexDialog = null },
+                icon = { Icon(Icons.Default.Info, contentDescription = null) },
+                title = { Text(stringResource(R.string.title_reindex_document)) },
+                text = {
+                    Text(
+                        stringResource(R.string.desc_reindex_document_warning)
+                    )
+                },
+                confirmButton = {
+                    TextButton(
+                        onClick = {
+                            coroutineScope.launch {
+                                ocrLanguage = newLanguage
+                                saveOcrLanguage(context, newLanguage)
+                                hasSelectedOcrLanguage = true
+
+                                currentBookId?.let { id ->
+                                    isBackgroundIndexing = true
+                                    backgroundIndexingProgress = 0f
+                                    withContext(Dispatchers.IO) {
+                                        pdfTextRepository.clearBookText(id)
+                                        pdfTextRepository.setBookLanguage(id, newLanguage.name)
+                                    }
+                                    isBackgroundIndexing = false
+                                }
+
+                                pendingActionAfterOcrSelection?.invoke()
+                                pendingActionAfterOcrSelection = null
+                                showReindexDialog = null
+                                showOcrLanguageDialog = false
+                            }
+                        }
+                    ) { Text(stringResource(R.string.action_reindex)) }
+                },
+                dismissButton = {
+                    TextButton(onClick = { showReindexDialog = null }) {
+                        Text(stringResource(R.string.action_cancel))
+                    }
+                }
+            )
+        } else {
+            showReindexDialog = null
+        }
+    }
+
+    if (showOcrLanguageDialog && !isOss) {
+        OcrLanguageSelectionDialog(
+            currentLanguage = ocrLanguage,
+            isFirstRun = !hasSelectedOcrLanguage,
+            onDismiss = {
+                showOcrLanguageDialog = false
+                pendingActionAfterOcrSelection = null
+            },
+            onLanguageSelected = { selected ->
+                coroutineScope.launch {
+                    val storedLangName = currentBookId?.let {
+                        pdfTextRepository.getBookLanguage(it)
+                    }
+
+                    val hasIndexedPages = currentBookId?.let {
+                        pdfTextRepository.getIndexedPages(it).isNotEmpty()
+                    } == true
+
+                    if (hasIndexedPages && storedLangName != null && storedLangName != selected.name) {
+                        showReindexDialog = selected
+                        showOcrLanguageDialog = false
+                    } else {
+                        ocrLanguage = selected
+                        saveOcrLanguage(context, selected)
+                        hasSelectedOcrLanguage = true
+
+                        currentBookId?.let {
+                            pdfTextRepository.setBookLanguage(it, selected.name)
+                        }
+
+                        showOcrLanguageDialog = false
+                        pendingActionAfterOcrSelection?.invoke()
+                        pendingActionAfterOcrSelection = null
+                    }
+                }
+            })
+    }
+
+    if (showTtsSettingsSheet) {
+        val bookTitle = documentMetadataTitle ?: originalFileName
+        TtsSettingsSheet(
+            isVisible = true,
+            onDismiss = { showTtsSettingsSheet = false },
+            currentMode = currentTtsMode,
+            onModeChange = { newMode ->
+                currentTtsMode = newMode
+                saveTtsMode(context, newMode)
+                ttsController.changeTtsMode(newMode.name)
+            },
+            currentSpeakerId = ttsState.speakerId,
+            onSpeakerChange = { newSpeaker ->
+                ttsController.changeSpeaker(newSpeaker)
+            },
+            isTtsActive = isTtsSessionActive,
+            getAuthToken = { viewModel.getAuthToken() },
+            bookTitle = bookTitle
+        )
+    }
+
+    if (showDictionarySettingsSheet) {
+        DictionarySettingsDialog(
+            isVisible = true,
+            onDismiss = { showDictionarySettingsSheet = false },
+            isProUser = isProUser,
+            useOnlineDictionary = useOnlineDictionary,
+            onToggleOnlineDictionary = { newState ->
+                useOnlineDictionary = newState
+                saveUseOnlineDict(context, newState)
+            },
+            selectedDictionaryPackageName = selectedDictPackage,
+            onSelectDictionaryPackage = { pkg ->
+                selectedDictPackage = pkg
+                saveExternalDictPackage(context, pkg)
+            },
+            selectedTranslatePackageName = selectedTranslatePackage,
+            onSelectTranslatePackage = { pkg ->
+                selectedTranslatePackage = pkg
+                saveExternalTranslatePackage(context, pkg)
+            },
+            selectedSearchPackageName = selectedSearchPackage,
+            onSelectSearchPackage = { pkg ->
+                selectedSearchPackage = pkg
+                saveExternalSearchPackage(context, pkg)
+            }
+        )
+    }
+
+    if (highlightToNoteId != null) {
+        val targetHighlight = userHighlights.find { it.id == highlightToNoteId }
+        if (targetHighlight != null) {
+            val effectiveBg = if (activeTheme.backgroundColor == Color.Unspecified) MaterialTheme.colorScheme.surface else activeTheme.backgroundColor
+            val effectiveText = if (activeTheme.textColor == Color.Unspecified) MaterialTheme.colorScheme.onSurface else activeTheme.textColor
+
+            PdfAnnotationBottomSheet(
+                highlight = targetHighlight,
+                effectiveBg = effectiveBg,
+                effectiveText = effectiveText,
+                customHighlightColors = customHighlightColors,
+                onPaletteClick = {
+                    highlightColorPickerInitialSlot = targetHighlight.color
+                    showHighlightColorPicker = true
+                },
+                onColorChange = { newColor ->
+                    onHighlightUpdate(
+                        targetHighlight.id,
+                        newColor
+                    )
+                },
+                onDismiss = { highlightToNoteId = null },
+                onSave = { noteText ->
+                    val index =
+                        userHighlights.indexOfFirst { it.id == targetHighlight.id }
+                    if (index != -1) {
+                        userHighlights[index] =
+                            targetHighlight.copy(note = noteText.takeIf { it.isNotBlank() })
+                    }
+                    highlightToNoteId = null
+                },
+                onDelete = {
+                    onHighlightDelete(targetHighlight.id)
+                    highlightToNoteId = null
+                },
+                onCopy = {
+                    val clip = ClipData.newPlainText(
+                        "Copied Text",
+                        targetHighlight.text
+                    )
+                    clipboardManager.setText(
+                        androidx.compose.ui.text.AnnotatedString(
+                            targetHighlight.text
+                        )
+                    )
+                    highlightToNoteId = null
+                },
+                onDictionary = {
+                    onDictionaryLookupStable(targetHighlight.text)
+                    highlightToNoteId = null
+                },
+                onTranslate = {
+                    onTranslateTextStable(targetHighlight.text)
+                    highlightToNoteId = null
+                },
+                onSearch = {
+                    onSearchTextStable(targetHighlight.text)
+                    highlightToNoteId = null
+                }
+            )
+        } else {
+            highlightToNoteId = null
+        }
+    }
+
+    if (showHighlightColorPicker) {
+        HighlightColorPickerDialog(
+            initialColors = customHighlightColors,
+            initialSelection = highlightColorPickerInitialSlot,
+            onDismiss = { showHighlightColorPicker = false },
+            onSave = { newColors ->
+                customHighlightColors = newColors
+                saveCustomHighlightColors(context, newColors)
+                showHighlightColorPicker = false
+            }
+        )
+    }
+
+    if (showThemePanel) {
+        ReaderThemePanel(
+            isVisible = true,
+            currentThemeId = currentThemeId,
+            excludeImages = excludeImages,
+            onExcludeImagesChange = {
+                excludeImages = it
+                com.aryan.reader.saveExcludeImages(context, it)
+            },
+            showExcludeImagesOption = true,
+            builtInThemes = PdfBuiltInThemes,
+            globalTextureTransparency = globalTextureTransparency,
+            onGlobalTextureTransparencyChange = {
+                globalTextureTransparency = it
+                saveGlobalTextureTransparency(context, it)
+            },
+            onThemeSelected = {
+                currentThemeId = it
+                savePdfThemeId(context, it)
+                showThemePanel = false
+            },
+            onDismiss = { showThemePanel = false },
+            customThemes = customThemes,
+            onCustomThemesUpdated = {
+                customThemes = it
+                saveCustomThemes(context, it)
+            }
+        )
+    }
+
+    if (clickedLinkUrl != null) {
+        val url = clickedLinkUrl!!
+        AlertDialog(
+            onDismissRequest = { clickedLinkUrl = null },
+            title = { Text(stringResource(R.string.dialog_external_link_title)) },
+            text = { Text(stringResource(R.string.desc_external_link_warning)) },
+            confirmButton = {
+                TextButton(
+                    onClick = {
+                        try {
+                            uriHandler.openUri(url)
+                        } catch (e: Exception) {
+                            Timber.e(e, "Failed to open URI")
+                        }
+                        clickedLinkUrl = null
+                    }) { Text(stringResource(R.string.action_visit)) }
+            },
+            dismissButton = {
+                Row {
+                    TextButton(
+                        onClick = {
+                            clipboardManager.setText(AnnotatedString(url))
+                            clickedLinkUrl = null
+                        }) { Text(stringResource(R.string.action_copy)) }
+                    TextButton(onClick = { clickedLinkUrl = null }) {
+                        Text(stringResource(R.string.action_cancel))
+                    }
+                }
+            })
+    }
+
+    if (showSaveDialog) {
+        AlertDialog(
+            onDismissRequest = { showSaveDialog = false },
+            title = { Text(stringResource(R.string.title_save_to_device)) },
+            text = { Text(stringResource(R.string.desc_choose_format_save)) },
+            confirmButton = {
+                TextButton(
+                    onClick = {
+                        showSaveDialog = false
+                        pendingSaveMode = SaveMode.ANNOTATED
+                        val suggestedName = getSuggestedFilename(
+                            originalFileName, isAnnotated = true
+                        )
+                        saveLauncher.launch(suggestedName)
+                    }) { Text(stringResource(R.string.action_with_annotations)) }
+            },
+            dismissButton = {
+                Row {
+                    TextButton(
+                        onClick = {
+                            showSaveDialog = false
+                            pendingSaveMode = SaveMode.ORIGINAL
+                            val suggestedName = getSuggestedFilename(
+                                originalFileName, isAnnotated = false
+                            )
+                            saveLauncher.launch(suggestedName)
+                        }) { Text(stringResource(R.string.action_original)) }
+
+                    Spacer(Modifier.width(8.dp))
+
+                    TextButton(
+                        onClick = {
+                            showSaveDialog = false
+                            pendingSaveMode = null
+                        }) { Text(stringResource(R.string.action_cancel)) }
+                }
+            })
+    }
+
+    if (showShareDialog) {
+        AlertDialog(
+            onDismissRequest = { showShareDialog = false },
+            title = { Text(stringResource(R.string.share_chooser_title)) },
+            text = { Text(stringResource(R.string.desc_choose_format_share)) },
+            confirmButton = {
+                TextButton(
+                    onClick = {
+                        showShareDialog = false
+                        isShareLoading = true
+                        Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
+                        val filename = getSuggestedFilename(
+                            originalFileName, isAnnotated = true
+                        )
+                        coroutineScope.launch {
+                            val currentRichTextLayouts = richTextController?.pageLayouts
+
+                            viewModel.sharePdf(
+                                activityContext = context,
+                                sourceUri = effectivePdfUri,
+                                annotations = allAnnotations,
+                                richTextPageLayouts = currentRichTextLayouts,
+                                textBoxes = textBoxes.toList(),
+                                highlights = userHighlights.toList(),
+                                includeAnnotations = true,
+                                filename = filename,
+                                bookId = currentBookId
+                            )
+                            isShareLoading = false
+                        }
+                    }) { Text(stringResource(R.string.action_with_annotations)) }
+            },
+            dismissButton = {
+                Row {
+                    TextButton(
+                        onClick = {
+                            showShareDialog = false
+                            isShareLoading = true
+                            val filename = getSuggestedFilename(
+                                originalFileName, isAnnotated = false
+                            )
+                            coroutineScope.launch {
+                                viewModel.sharePdf(
+                                    activityContext = context,
+                                    sourceUri = pdfUri,
+                                    annotations = allAnnotations,
+                                    includeAnnotations = false,
+                                    filename = filename
+                                )
+                                isShareLoading = false
+                            }
+                        }) { Text(stringResource(R.string.action_original)) }
+                    Spacer(Modifier.width(8.dp))
+                    TextButton(onClick = { showShareDialog = false }) {
+                        Text(stringResource(R.string.action_cancel))
+                    }
+                }
+            })
+    }
+
+    if (isShareLoading) {
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .background(Color.Black.copy(alpha = 0.5f))
+                .clickable(enabled = false) {}, contentAlignment = Alignment.Center
+        ) {
+            Surface(
+                shape = RoundedCornerShape(16.dp),
+                color = MaterialTheme.colorScheme.surface,
+                tonalElevation = 6.dp
+            ) {
+                Column(
+                    modifier = Modifier.padding(24.dp),
+                    horizontalAlignment = Alignment.CenterHorizontally,
+                    verticalArrangement = Arrangement.Center
+                ) {
+                    CircularProgressIndicator(
+                        modifier = Modifier.size(32.dp), strokeWidth = 3.dp
+                    )
+                    Spacer(modifier = Modifier.height(16.dp))
+                    Text(
+                        text = stringResource(R.string.msg_preparing_pdf),
+                        style = MaterialTheme.typography.bodyLarge
                     )
                 }
             }
         }
     }
+
+    if (showVisualOptionsSheet) {
+        PdfVisualOptionsSheet(
+            systemUiMode = systemUiMode,
+            onSystemUiModeChange = { mode ->
+                systemUiMode = mode
+                savePdfSystemUiMode(context, mode)
+            },
+            onDismiss = { showVisualOptionsSheet = false }
+        )
+    }
+    if (showCustomizeToolsSheet) {
+        PdfCustomizeToolsSheet(
+            hiddenTools = hiddenTools,
+            toolOrder = toolOrder,
+            bottomTools = bottomTools,
+            onUpdate = onUpdateHiddenTools,
+            onOrderUpdate = onUpdateToolOrder,
+            onPlacementUpdate = onUpdateBottomTools,
+            onDismiss = { showCustomizeToolsSheet = false }
+        )
+    }
 }
diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfiumEngineProvider.kt b/app/src/main/java/com/aryan/reader/pdf/PdfiumEngineProvider.kt
new file mode 100644
index 0000000..a9662ed
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/pdf/PdfiumEngineProvider.kt
@@ -0,0 +1,120 @@
+package com.aryan.reader.pdf
+
+import com.aryan.reader.shared.pdf.PdfiumBridge
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+internal object PdfiumEngineProvider {
+    private val pdfiumMutex = Mutex()
+
+    val bridge: PdfiumBridge
+        get() = AndroidPdfiumBridge
+
+    val lock: Any = this
+
+    suspend fun  withPdfium(block: suspend () -> T): T =
+        pdfiumMutex.withLock { block() }
+
+    fun  withPdfiumBlocking(block: () -> T): T =
+        runBlocking {
+            pdfiumMutex.withLock { block() }
+        }
+}
+
+private object AndroidPdfiumBridge : PdfiumBridge {
+    override fun getFontSize(textPagePtr: Long, index: Int): Double =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getFontSize(textPagePtr, index)
+        }
+
+    override fun getFontWeight(textPagePtr: Long, index: Int): Int =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getFontWeight(textPagePtr, index)
+        }
+
+    override fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray? =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getPageFontSizes(textPagePtr, count)
+        }
+
+    override fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray? =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getPageFontWeights(textPagePtr, count)
+        }
+
+    override fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray? =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getPageFontFlags(textPagePtr, count)
+        }
+
+    override fun getPageCharBoxes(textPagePtr: Long, count: Int): FloatArray? =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getPageCharBoxes(textPagePtr, count)
+        }
+
+    override fun getAnnotCount(pagePtr: Long): Int =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getAnnotCount(pagePtr)
+        }
+
+    override fun getAnnotSubtype(pagePtr: Long, index: Int): Int =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getAnnotSubtype(pagePtr, index)
+        }
+
+    override fun getAnnotRect(pagePtr: Long, index: Int): FloatArray? =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getAnnotRect(pagePtr, index)
+        }
+
+    override fun getAnnotString(pagePtr: Long, index: Int, key: String): String? =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getAnnotString(pagePtr, index, key)
+        }
+
+    override fun getPageObjectCount(pagePtr: Long): Int =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getPageObjectCount(pagePtr)
+        }
+
+    override fun getPageObjectType(pagePtr: Long, index: Int): Int =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getPageObjectType(pagePtr, index)
+        }
+
+    override fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, index, outRect)
+        }
+
+    override fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray? =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.extractImagePixels(pagePtr, index, dimens)
+        }
+
+    override fun performClick(pagePtr: Long, x: Double, y: Double): Boolean =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.performClick(pagePtr, x, y)
+        }
+
+    override fun getLinkInfoAtPoint(docPtr: Long, pagePtr: Long, x: Double, y: Double): String? =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getLinkInfoAtPoint(docPtr, pagePtr, x, y)
+        }
+
+    override fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getAnnotSubtypeAtPoint(pagePtr, x, y)
+        }
+
+    override fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray? =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.getAnnotRectAtPoint(pagePtr, x, y)
+        }
+
+    override fun checkActionSupport(): Boolean =
+        PdfiumEngineProvider.withPdfiumBlocking {
+            NativePdfiumBridge.checkActionSupport()
+        }
+}
diff --git a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
index 6d85e33..993ebb2 100644
--- a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
@@ -28,6 +28,7 @@ import me.zhanghai.android.libarchive.ArchiveException
 import okhttp3.Request
 import timber.log.Timber
 import java.util.UUID
+import java.util.concurrent.atomic.AtomicBoolean
 import java.util.zip.ZipFile
 import androidx.core.graphics.createBitmap
 
@@ -88,42 +89,97 @@ object DocumentFactory {
             ArchiveDocumentWrapper(cacheFile)
         } else {
             val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF")
-            PdfDocumentWrapper(pdfiumCore.newDocument(pfd, password))
+            PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) })
         }
     }
 }
 
 // ================= PDF IMPLEMENTATION =================
 
+private inline fun closePdfiumResource(tag: String, closeBlock: () -> Unit) {
+    try {
+        closeBlock()
+    } catch (e: IllegalStateException) {
+        if (e.message == "Already closed") {
+            Timber.tag(tag).d(e, "Ignoring duplicate Pdfium close")
+        } else {
+            throw e
+        }
+    }
+}
+
 class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
-    override suspend fun getPageCount() = pdfDocument.getPageCount()
+    private val isClosed = AtomicBoolean(false)
+
+    override suspend fun getPageCount() = PdfiumEngineProvider.withPdfium {
+        pdfDocument.getPageCount()
+    }
+
     override suspend fun openPage(pageIndex: Int): ReaderPage? {
-        val page = pdfDocument.openPage(pageIndex) ?: return null
+        if (isClosed.get()) return null
+        val page = PdfiumEngineProvider.withPdfium {
+            if (isClosed.get()) null else pdfDocument.openPage(pageIndex)
+        } ?: return null
         return PdfPageWrapper(page)
     }
-    override suspend fun getTableOfContents() = pdfDocument.getFixedTableOfContents()
-    override fun close() { pdfDocument.close() }
+
+    override suspend fun getTableOfContents() = PdfiumEngineProvider.withPdfium {
+        pdfDocument.getFixedTableOfContents()
+    }
+
+    override fun close() {
+        if (!isClosed.compareAndSet(false, true)) return
+        PdfiumEngineProvider.withPdfiumBlocking {
+            closePdfiumResource("PdfDocumentWrapper") { pdfDocument.close() }
+        }
+    }
 }
 
 class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
-    override suspend fun getPageWidthPoint() = pdfPage.getPageWidthPoint()
-    override suspend fun getPageHeightPoint() = pdfPage.getPageHeightPoint()
-    override suspend fun getPageRotation() = pdfPage.getPageRotation()
+    private val isClosed = AtomicBoolean(false)
+
+    override suspend fun getPageWidthPoint() = PdfiumEngineProvider.withPdfium {
+        if (isClosed.get()) 0 else pdfPage.getPageWidthPoint()
+    }
+
+    override suspend fun getPageHeightPoint() = PdfiumEngineProvider.withPdfium {
+        if (isClosed.get()) 0 else pdfPage.getPageHeightPoint()
+    }
+
+    override suspend fun getPageRotation() = PdfiumEngineProvider.withPdfium {
+        if (isClosed.get()) 0 else pdfPage.getPageRotation()
+    }
 
     override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
-        pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
+        PdfiumEngineProvider.withPdfium {
+            if (!isClosed.get()) {
+                pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
+            }
+        }
     }
 
     override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) =
-        pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
+        PdfiumEngineProvider.withPdfium {
+            if (isClosed.get()) 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) =
-        pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
+        PdfiumEngineProvider.withPdfium {
+            if (isClosed.get()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
+        }
 
-    override suspend fun openTextPage(): ReaderTextPage = PdfTextPageWrapper(pdfPage.openTextPage())
+    override suspend fun openTextPage(): ReaderTextPage = PdfiumEngineProvider.withPdfium {
+        if (isClosed.get()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage())
+    }
 
     override suspend fun getLinks(): List {
-        return pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
+        return PdfiumEngineProvider.withPdfium {
+            if (isClosed.get()) {
+                emptyList()
+            } else {
+                pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
+            }
+        }
     }
 
     override fun getNativePointer(): Long {
@@ -159,29 +215,80 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
         return 0L
     }
 
-    override fun close() { pdfPage.close() }
+    override fun close() {
+        if (!isClosed.compareAndSet(false, true)) return
+        PdfiumEngineProvider.withPdfiumBlocking {
+            closePdfiumResource("PdfPageWrapper") { pdfPage.close() }
+        }
+    }
 }
 
 class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
-    override suspend fun textPageCountChars() = textPage.textPageCountChars()
-    override suspend fun textPageGetText(startIndex: Int, count: Int) = textPage.textPageGetText(startIndex, count)
-    override suspend fun textPageGetRectsForRanges(ranges: IntArray) = textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
-    override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
-    override suspend fun textPageGetCharBox(index: Int) = textPage.textPageGetCharBox(index)
-    override suspend fun textPageGetUnicode(index: Int): Int {
-        return textPage.textPageGetUnicode(index).code
+    private val isClosed = AtomicBoolean(false)
+
+    override suspend fun textPageCountChars() = PdfiumEngineProvider.withPdfium {
+        if (isClosed.get()) 0 else textPage.textPageCountChars()
     }
-    override suspend fun loadWebLink(): ReaderWebLinks? {
-        val links = textPage.loadWebLink() ?: return null
-        return object : ReaderWebLinks {
-            override suspend fun countWebLinks() = links.countWebLinks()
-            override suspend fun getURL(linkIndex: Int, maxLength: Int) = links.getURL(linkIndex, maxLength)
-            override suspend fun countRects(linkIndex: Int) = links.countRects(linkIndex)
-            override suspend fun getRect(linkIndex: Int, rectIndex: Int) = links.getRect(linkIndex, rectIndex)
-            override fun close() { links.close() }
+
+    override suspend fun textPageGetText(startIndex: Int, count: Int) = PdfiumEngineProvider.withPdfium {
+        if (isClosed.get()) 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) }
+    }
+
+    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)
+    }
+
+    override suspend fun textPageGetCharBox(index: Int) = PdfiumEngineProvider.withPdfium {
+        if (isClosed.get()) null else textPage.textPageGetCharBox(index)
+    }
+
+    override suspend fun textPageGetUnicode(index: Int): Int {
+        return PdfiumEngineProvider.withPdfium {
+            if (isClosed.get()) 0 else textPage.textPageGetUnicode(index).code
+        }
+    }
+
+    override suspend fun loadWebLink(): ReaderWebLinks? {
+        val links = PdfiumEngineProvider.withPdfium {
+            if (isClosed.get()) null else textPage.loadWebLink()
+        } ?: return null
+        return object : ReaderWebLinks {
+            private val isClosed = AtomicBoolean(false)
+
+            override suspend fun countWebLinks() = PdfiumEngineProvider.withPdfium {
+                if (isClosed.get()) 0 else links.countWebLinks()
+            }
+
+            override suspend fun getURL(linkIndex: Int, maxLength: Int) = PdfiumEngineProvider.withPdfium {
+                if (isClosed.get()) null else links.getURL(linkIndex, maxLength)
+            }
+
+            override suspend fun countRects(linkIndex: Int) = PdfiumEngineProvider.withPdfium {
+                if (isClosed.get()) 0 else links.countRects(linkIndex)
+            }
+
+            override suspend fun getRect(linkIndex: Int, rectIndex: Int) = PdfiumEngineProvider.withPdfium {
+                if (isClosed.get()) RectF() else links.getRect(linkIndex, rectIndex)
+            }
+
+            override fun close() {
+                if (!isClosed.compareAndSet(false, true)) return
+                PdfiumEngineProvider.withPdfiumBlocking {
+                    closePdfiumResource("PdfWebLinksWrapper") { links.close() }
+                }
+            }
+        }
+    }
+    override fun close() {
+        if (!isClosed.compareAndSet(false, true)) return
+        PdfiumEngineProvider.withPdfiumBlocking {
+            closePdfiumResource("PdfTextPageWrapper") { textPage.close() }
         }
     }
-    override fun close() { textPage.close() }
 }
 
 // ================= CBZ, CBR, CB7 IMPLEMENTATION =================
diff --git a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt b/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt
index b09b0b8..10d30e7 100644
--- a/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt
+++ b/app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt
@@ -34,6 +34,7 @@ import androidx.paging.PagingConfig
 import androidx.paging.PagingData
 import androidx.paging.flatMap
 import com.aryan.reader.SearchResult
+import com.aryan.reader.pdf.PdfiumEngineProvider
 import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.SpanStyle
 import androidx.compose.ui.text.buildAnnotatedString
@@ -171,13 +172,15 @@ class PdfTextRepository(context: Context) {
             var ocrUsed = false
 
             try {
-                document.openPage(pageIndex)?.use { page ->
-                    page.openTextPage().use { textPage ->
-                        val count = textPage.textPageCountChars()
-                        if (count > 0) {
-                            val nativeText = textPage.textPageGetText(0, count)
-                            if (!nativeText.isNullOrBlank()) {
-                                text = nativeText
+                PdfiumEngineProvider.withPdfium {
+                    document.openPage(pageIndex)?.use { page ->
+                        page.openTextPage().use { textPage ->
+                            val count = textPage.textPageCountChars()
+                            if (count > 0) {
+                                val nativeText = textPage.textPageGetText(0, count)
+                                if (!nativeText.isNullOrBlank()) {
+                                    text = nativeText
+                                }
                             }
                         }
                     }
@@ -187,26 +190,35 @@ class PdfTextRepository(context: Context) {
             }
 
             if (text.isBlank()) {
+                var bitmap: android.graphics.Bitmap? = null
                 try {
-                    document.openPage(pageIndex)?.use { page ->
-                        val targetWidth = 1080
-                        val ptrWidth = page.getPageWidthPoint()
-                        val ptrHeight = page.getPageHeightPoint()
+                    PdfiumEngineProvider.withPdfium {
+                        document.openPage(pageIndex)?.use { page ->
+                            val targetWidth = 1080
+                            val ptrWidth = page.getPageWidthPoint()
+                            val ptrHeight = page.getPageHeightPoint()
 
-                        if (ptrWidth > 0 && ptrHeight > 0) {
-                            val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
-                            val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
+                            if (ptrWidth > 0 && ptrHeight > 0) {
+                                val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
+                                val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
 
-                            val bitmap = createBitmap(targetWidth, targetHeight)
-                            page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false)
-
-                            val visionText = OcrHelper.extractTextFromBitmap(bitmap, onOcrModelDownloading)
+                                bitmap = createBitmap(targetWidth, targetHeight)
+                                page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false)
+                            }
+                        }
+                    }
+                    bitmap?.let {
+                        try {
+                            val visionText = OcrHelper.extractTextFromBitmap(it, onOcrModelDownloading)
                             text = visionText?.text ?: ""
-                            bitmap.recycle()
                             ocrUsed = true
+                        } finally {
+                            it.recycle()
+                            bitmap = null
                         }
                     }
                 } catch (e: Exception) {
+                    bitmap?.recycle()
                     Timber.tag(TAG).e(e, "OCR failed for page $pageIndex")
                 }
             }
@@ -270,11 +282,13 @@ class PdfTextRepository(context: Context) {
     suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
         return withContext(Dispatchers.IO) {
             try {
-                document.openPage(pageIndex)?.use { page ->
-                    page.openTextPage().use { textPage ->
-                        textPage.textPageCountChars() > 0
-                    }
-                } ?: false
+                PdfiumEngineProvider.withPdfium {
+                    document.openPage(pageIndex)?.use { page ->
+                        page.openTextPage().use { textPage ->
+                            textPage.textPageCountChars() > 0
+                        }
+                    } ?: false
+                }
             } catch (_: Exception) {
                 false
             }
@@ -585,4 +599,4 @@ class PdfTextRepository(context: Context) {
 
         return null
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt
index 0dbe2ec..e9a1b1e 100644
--- a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt
+++ b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt
@@ -34,6 +34,7 @@ import java.io.File
 import java.util.Locale
 import java.util.UUID
 import java.util.concurrent.ConcurrentHashMap
+import kotlinx.coroutines.selects.select
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.suspendCancellableCoroutine
@@ -247,11 +248,16 @@ class BaseTtsSynthesizer(private val context: Context) {
 
                     try {
                         withTimeout(startTimeout) {
-                            startSignal.await()
+                            select {
+                                startSignal.onAwait { }
+                                resultDeferred.onAwait { }
+                            }
                         }
                     } catch (_: TimeoutCancellationException) {
-                        Timber.w("BaseTts: ZOMBIE DETECTED. onStart not received within ${startTimeout}ms.")
-                        throw ZombieEngineException()
+                        Timber.w(
+                            "BaseTts: onStart not received within ${startTimeout}ms for $utteranceId. " +
+                                "Continuing to wait for onDone because some engines omit or delay onStart for file synthesis."
+                        )
                     }
 
                     try {
@@ -296,5 +302,4 @@ class BaseTtsSynthesizer(private val context: Context) {
         Timber.d("TextToSpeech engine shut down.")
     }
 
-    private class ZombieEngineException : Exception("Engine failed to start")
 }
diff --git a/app/src/main/java/com/aryan/reader/tts/TtsController.kt b/app/src/main/java/com/aryan/reader/tts/TtsController.kt
index 69cc39e..c98c6ab 100644
--- a/app/src/main/java/com/aryan/reader/tts/TtsController.kt
+++ b/app/src/main/java/com/aryan/reader/tts/TtsController.kt
@@ -39,6 +39,7 @@ import androidx.media3.session.SessionToken
 import com.aryan.reader.BuildConfig
 import com.aryan.reader.epubreader.loadTtsPitch
 import com.aryan.reader.epubreader.loadTtsSpeechRate
+import com.aryan.reader.isByokCloudTtsAvailable
 import com.aryan.reader.tts.TtsPlaybackManager.TtsState
 import com.google.common.util.concurrent.ListenableFuture
 import com.google.common.util.concurrent.MoreExecutors
@@ -72,7 +73,7 @@ fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
     val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
         ?: TtsPlaybackManager.TtsMode.BASE.name
 
-    val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank()
+    val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() || isByokCloudTtsAvailable(context)
 
     return if (isCloudAllowed) {
         try {
@@ -105,8 +106,14 @@ class TtsController(context: Context) : Player.Listener {
     }
 
     fun connect() {
-        if (mediaController != null || controllerFuture != null) return
+        if (mediaController != null || controllerFuture != null) {
+            Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+                "TtsController.connect skipped. hasController=${mediaController != null}, hasFuture=${controllerFuture != null}"
+            )
+            return
+        }
 
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("TtsController.connect building MediaController.")
         val sessionToken = SessionToken(context, ComponentName(context, TtsService::class.java))
         val future = MediaController.Builder(context, sessionToken).buildAsync()
         controllerFuture = future
@@ -129,10 +136,14 @@ class TtsController(context: Context) : Player.Listener {
 
                     mediaController?.addListener(this)
                     Timber.d("MediaController connected.")
+                    Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+                        "MediaController connected. playbackState=${controller.playbackState}, isPlaying=${controller.isPlaying}, mediaItems=${controller.mediaItemCount}, customLayout=${controller.customLayout.size}"
+                    )
                     updateStateFromController()
                     startPolling()
                 } catch (e: Exception) {
                     Timber.w("Failed to connect MediaController: ${e.message}")
+                    Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(e, "MediaController connection failed.")
                     if (controllerFuture == future) {
                         controllerFuture = null
                     }
@@ -158,15 +169,21 @@ class TtsController(context: Context) : Player.Listener {
         chapterTitle: String?,
         coverImageUri: String?,
         chapterIndex: Int? = null,
+        totalChapters: Int? = null,
+        continueSession: Boolean = false,
         ttsMode: TtsPlaybackManager.TtsMode,
         playbackSource: String = "READER",
         authToken: String? = null
     ) {
         if (chunks.isEmpty()) {
             Timber.w("TtsController: start called with empty chunks!")
+            Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsController.start aborted because chunks is empty.")
             return
         }
         Timber.d("UI sending START command with mode: $ttsMode")
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "TtsController.start. hasController=${mediaController != null}, chunks=${chunks.size}, continueSession=$continueSession, source=$playbackSource, mode=$ttsMode, book='${bookTitle.take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters"
+        )
 
         val textList = ArrayList(chunks.map { it.text })
         val cfiList = ArrayList(chunks.map { it.sourceCfi })
@@ -181,6 +198,8 @@ class TtsController(context: Context) : Player.Listener {
             putString(KEY_CHAPTER_TITLE, chapterTitle)
             putString(KEY_COVER_IMAGE_URI, coverImageUri)
             chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
+            totalChapters?.let { putInt(KEY_TOTAL_CHAPTERS, it) }
+            putBoolean(KEY_CONTINUE_SESSION, continueSession)
             putString(KEY_TTS_MODE, ttsMode.name)
             putString(KEY_PLAYBACK_SOURCE, playbackSource)
             putString(KEY_AUTH_TOKEN, authToken)
@@ -188,7 +207,15 @@ class TtsController(context: Context) : Player.Listener {
             putFloat("playback_pitch", loadTtsPitch(context))
         }
         Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}")
-        mediaController?.sendCustomCommand(START_TTS_COMMAND, args)
+        val controller = mediaController
+        if (controller == null) {
+            Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e("Cannot send START command because MediaController is null.")
+        } else {
+            val result = controller.sendCustomCommand(START_TTS_COMMAND, args)
+            Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+                "START command sent. playbackState=${controller.playbackState}, isPlaying=${controller.isPlaying}, mediaItems=${controller.mediaItemCount}, resultDone=${result.isDone}"
+            )
+        }
     }
 
     fun pause() {
@@ -240,6 +267,9 @@ class TtsController(context: Context) : Player.Listener {
     }
 
     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"
+        )
         updateStateFromController()
     }
 
@@ -247,7 +277,9 @@ class TtsController(context: Context) : Player.Listener {
         mediaController?.let { controller ->
             val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY
             val currentMediaItem = controller.currentMediaItem
-            val currentTextFromMediaItem = currentMediaItem?.mediaMetadata?.subtitle?.toString()
+            val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
+            val currentTextFromMediaItem = mediaItemExtras?.getString("ttsText")
+                ?: currentMediaItem?.mediaMetadata?.subtitle?.toString()
             val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING
             val serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId)
             val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false)
@@ -255,9 +287,13 @@ class TtsController(context: Context) : Player.Listener {
             val sessionFinished = customState.getBoolean("sessionFinished", false)
             val playbackSource = customState.getString("playbackSource")
             val serviceBookTitle = customState.getString("bookTitle")
+            val serviceChapterTitle = customState.getString("chapterTitle")
             val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 }
+            val serviceTotalChapters = customState.getInt("totalChapters", -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 mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
             val sourceCfi = mediaItemExtras?.getString("sourceCfi")
             val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
             val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
@@ -279,11 +315,24 @@ class TtsController(context: Context) : Player.Listener {
                 } else {
                     if (isLoading) currentState.bookTitle else serviceBookTitle
                 },
+                chapterTitle = if (isPlaybackActive || isLoading) {
+                    serviceChapterTitle ?: currentState.chapterTitle
+                } else {
+                    serviceChapterTitle
+                },
                 chapterIndex = if (isPlaybackActive || isLoading) {
                     serviceChapterIndex ?: currentState.chapterIndex
                 } else {
                     serviceChapterIndex
                 },
+                totalChapters = if (isPlaybackActive || isLoading) {
+                    serviceTotalChapters ?: currentState.totalChapters
+                } else {
+                    serviceTotalChapters
+                },
+                currentChunkIndex = serviceCurrentChunkIndex,
+                totalChunks = serviceTotalChunks,
+                bookProgressPercent = serviceBookProgressPercent,
                 speakerId = serviceSpeaker,
                 sourceCfi = if (isPlaybackActive) {
                     sourceCfi
diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt
index 60d7dfa..cbb0949 100644
--- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt
+++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt
@@ -48,6 +48,7 @@ import androidx.core.net.toUri
 import com.aryan.reader.paginatedreader.TimedWord
 import com.aryan.reader.paginatedreader.TtsChunk
 import kotlinx.coroutines.delay
+import kotlin.math.roundToInt
 
 val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY)
 val STOP_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.STOP", Bundle.EMPTY)
@@ -57,6 +58,7 @@ private val STATE_UPDATE_COMMAND = SessionCommand("com.aryan.reader.tts.STATE_UP
 val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY)
 val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY)
 val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS", Bundle.EMPTY)
+const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG"
 
 const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
 const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
@@ -71,6 +73,8 @@ const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS"
 const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
 const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN"
 const val KEY_CHAPTER_INDEX = "KEY_CHAPTER_INDEX"
+const val KEY_TOTAL_CHAPTERS = "KEY_TOTAL_CHAPTERS"
+const val KEY_CONTINUE_SESSION = "KEY_CONTINUE_SESSION"
 
 private const val PREFETCH_LOOKAHEAD = 3
 
@@ -102,7 +106,12 @@ class TtsPlaybackManager(
         val currentText: String? = null,
         val errorMessage: String? = null,
         val bookTitle: String? = null,
+        val chapterTitle: String? = null,
         val chapterIndex: Int? = null,
+        val totalChapters: Int? = null,
+        val currentChunkIndex: Int = -1,
+        val totalChunks: Int = 0,
+        val bookProgressPercent: Int? = null,
         val speakerId: String = DEFAULT_SPEAKER_ID,
         val sourceCfi: String? = null,
         val startOffsetInSource: Int = -1,
@@ -124,6 +133,8 @@ class TtsPlaybackManager(
     private var chapterTitle: String? = null
     private var coverImageUri: String? = null
     private var currentTtsMode = TtsMode.CLOUD
+    private var chapterIndex: Int? = null
+    private var totalChapters: Int? = null
 
     init {
         player.addListener(this)
@@ -146,6 +157,9 @@ class TtsPlaybackManager(
         session: MediaSession,
         controller: MediaSession.ControllerInfo
     ): MediaSession.ConnectionResult {
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "MediaSession onConnect. package=${controller.packageName}, uid=${controller.uid}"
+        )
         val availableSessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
             .add(START_TTS_COMMAND)
             .add(STOP_TTS_COMMAND)
@@ -186,6 +200,9 @@ class TtsPlaybackManager(
             START_TTS_COMMAND -> {
                 val chunks = args.getStringArrayList(KEY_TEXT_CHUNKS) ?: emptyList()
                 Timber.d("TtsService: START command received. Size: ${chunks.size}")
+                Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+                    "START command received. chunks=${chunks.size}, continueSession=${args.getBoolean(KEY_CONTINUE_SESSION, false)}, source=${args.getString(KEY_PLAYBACK_SOURCE)}, mode=${args.getString(KEY_TTS_MODE)}, chapterIndex=${args.getInt(KEY_CHAPTER_INDEX, -1)}, totalChapters=${args.getInt(KEY_TOTAL_CHAPTERS, -1)}"
+                )
                 val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
                 val offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
                 val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
@@ -193,6 +210,7 @@ class TtsPlaybackManager(
                 val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
                 val coverImageUri = args.getString(KEY_COVER_IMAGE_URI)
                 val chapterIndex = args.getInt(KEY_CHAPTER_INDEX, -1).takeIf { it >= 0 }
+                val totalChapters = args.getInt(KEY_TOTAL_CHAPTERS, -1).takeIf { it > 0 }
                 val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
                 val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
                 val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
@@ -208,10 +226,11 @@ class TtsPlaybackManager(
 
                 val authToken = args.getString(KEY_AUTH_TOKEN)
                 Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
-                handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, ttsMode, playbackSource, args)
+                handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, totalChapters, ttsMode, playbackSource, args)
             }
             STOP_TTS_COMMAND -> {
                 Timber.d("Received STOP command.")
+                Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("STOP command received.")
                 handleStopTts(userInitiated = true)
             }
             CHANGE_SPEAKER_COMMAND -> {
@@ -342,17 +361,20 @@ class TtsPlaybackManager(
         chapterTitle: String?,
         coverImageUri: String?,
         chapterIndex: Int?,
+        totalChapters: Int?,
         ttsMode: TtsMode,
         playbackSource: String?,
         args: Bundle // Added this parameter
     ) {
         if (chunks.isEmpty()) {
             _ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
+            Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("handleStartTts aborted because chunks is empty.")
             return
         }
 
         // --- YOUR SNIPPET START ---
         val authToken = args.getString(KEY_AUTH_TOKEN)
+        val continueSession = args.getBoolean(KEY_CONTINUE_SESSION, false)
         val speed = args.getFloat("playback_speed", 1f)
         val pitch = args.getFloat("playback_pitch", 1f)
 
@@ -365,27 +387,54 @@ class TtsPlaybackManager(
         }
 
         Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "handleStartTts. continueSession=$continueSession, chunks=${chunks.size}, book='${bookTitle.orEmpty().take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters, mode=$ttsMode, playbackSource=$playbackSource"
+        )
+
+        if (!continueSession) {
+            Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("New TTS session. Calling handleStopTts(clearState=false) before start.")
+            handleStopTts(clearState = false)
+        }
 
-        handleStopTts(clearState = false)
         textChunks = chunks
         currentSpeakerId = speakerId
         currentTtsMode = ttsMode
         this.bookTitle = bookTitle
         this.chapterTitle = chapterTitle
         this.coverImageUri = coverImageUri
+        this.chapterIndex = chapterIndex
+        this.totalChapters = totalChapters
 
-        onResetContext()
         loadedChunks.clear()
         lastPrefetchIndex = -1
 
         _ttsState.value = TtsState(
             isLoading = true,
             bookTitle = bookTitle,
+            chapterTitle = chapterTitle,
             chapterIndex = chapterIndex,
+            totalChapters = totalChapters,
+            currentChunkIndex = -1,
+            totalChunks = chunks.size,
+            bookProgressPercent = calculateBookProgressPercent(-1),
             speakerId = speakerId,
             playbackSource = playbackSource,
-            ttsMode = ttsMode.name
+            ttsMode = ttsMode.name,
+            currentText = if (continueSession) _ttsState.value.currentText else null
         )
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "TTS state set to loading. bookProgress=${_ttsState.value.bookProgressPercent}, currentTextRetained=${_ttsState.value.currentText != null}"
+        )
+
+        if (continueSession) {
+            Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Continuation start. Cancelling prefetch/tracking but keeping player session alive until replacement media is ready.")
+            preparationJob?.cancel()
+            wordTrackingJob?.cancel()
+            prefetchLoopJob?.cancel()
+            prefetchingJobs.values.forEach { it.cancel() }
+            prefetchingJobs.clear()
+            clearPlaylistForContinuation()
+        }
 
         currentAuthToken = authToken
         preparationJob = scope.launch {
@@ -411,6 +460,73 @@ class TtsPlaybackManager(
         Timber.d("Speaker changed to $newSpeakerId (pending next start)")
     }
 
+    private fun currentChunkIndexFromPlayer(): Int {
+        return player.currentMediaItem?.mediaId?.toIntOrNull()
+            ?: player.currentMediaItemIndex
+    }
+
+    private fun calculateBookProgressPercent(chunkIndex: Int): Int? {
+        val chapter = chapterIndex ?: return null
+        val chapterCount = totalChapters?.takeIf { it > 0 } ?: return null
+        val safeChunkProgress = if (textChunks.isNotEmpty() && chunkIndex >= 0) {
+            ((chunkIndex + 1).toDouble() / textChunks.size.toDouble()).coerceIn(0.0, 1.0)
+        } else {
+            0.0
+        }
+        return (((chapter.toDouble() + safeChunkProgress) / chapterCount.toDouble()) * 100.0)
+            .roundToInt()
+            .coerceIn(0, 100)
+    }
+
+    private fun markSessionFinishedNaturally(chunkIndex: Int) {
+        val currentState = _ttsState.value
+        if (currentState.isLoading && currentState.currentChunkIndex == -1) {
+            Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
+                "Ignoring stale streamed completion while a continuation session is loading."
+            )
+            return
+        }
+
+        val safeChunkIndex = if (textChunks.isNotEmpty()) {
+            chunkIndex.coerceIn(0, textChunks.lastIndex)
+        } else {
+            -1
+        }
+
+        Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i(
+            "Setting sessionFinished = true for naturally completed streamed TTS. chunk=$safeChunkIndex, totalChunks=${textChunks.size}"
+        )
+
+        _ttsState.value = _ttsState.value.copy(
+            isPlaying = false,
+            isLoading = false,
+            currentChunkIndex = safeChunkIndex,
+            totalChunks = textChunks.size,
+            bookProgressPercent = calculateBookProgressPercent(safeChunkIndex),
+            currentWordSourceCfi = null,
+            currentWordStartOffset = -1,
+            sessionFinished = true
+        )
+    }
+
+    private fun clearPlaylistForContinuation() {
+        val filesToDelete = audioFiles.values.toList()
+        val streamsToRemove = chunkStreamIds.values.toList()
+        audioFiles.clear()
+        chunkStreamIds.clear()
+        loadedChunks.clear()
+
+        lastPrefetchIndex = -1
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "Cleared continuation temp resources. oldFiles=${filesToDelete.size}, oldStreams=${streamsToRemove.size}"
+        )
+
+        scope.launch(Dispatchers.IO) {
+            filesToDelete.forEach { deleteTempFile(it) }
+            streamsToRemove.forEach { StreamRegistry.remove(it) }
+        }
+    }
+
     private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
         val firstChunk = textChunks.getOrNull(startAtIndex)
         if (firstChunk == null) {
@@ -420,6 +536,9 @@ class TtsPlaybackManager(
 
         val chunkStartTime = System.currentTimeMillis()
         Timber.tag("TTS_CLOUD_DIAG").i("Starting audio generation for first chunk (index=$startAtIndex).")
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "Preparing first chunk. startAtIndex=$startAtIndex, playWhenReady=$playWhenReady"
+        )
 
         val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
         Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
@@ -464,17 +583,30 @@ class TtsPlaybackManager(
                 }
                 player.playWhenReady = playWhenReady
                 Timber.tag("TTS_CLOUD_DIAG").i("ExoPlayer setMediaItem & prepare called in ${System.currentTimeMillis() - prepStartTime}ms")
+                Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+                    "Player prepared for TTS. mediaId=${mediaItem.mediaId}, title='${mediaItem.mediaMetadata.title}', playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}, mediaItems=${player.mediaItemCount}"
+                )
                 _ttsState.value = _ttsState.value.copy(
                     isLoading = false,
                     isPlaying = playWhenReady,
                     currentText = serverText,
+                    chapterTitle = chapterTitle,
+                    chapterIndex = chapterIndex,
+                    totalChapters = totalChapters,
+                    currentChunkIndex = startAtIndex,
+                    totalChunks = textChunks.size,
+                    bookProgressPercent = calculateBookProgressPercent(startAtIndex),
+                    sessionFinished = false,
                     sourceCfi = updatedChunk.sourceCfi,
                     startOffsetInSource = updatedChunk.startOffsetInSource
                 )
             }
             prefetchNextChunkAudio(startAtIndex)
         } else {
-            _ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.")
+            _ttsState.value = _ttsState.value.copy(
+                isLoading = false,
+                errorMessage = ttsAudioData.error ?: "Failed to load audio."
+            )
         }
     }
 
@@ -509,6 +641,9 @@ class TtsPlaybackManager(
 
     private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) {
         Timber.tag("TTS_CLOUD_DIAG").d("handleStopTts called. clearState=$clearState, userInitiated=$userInitiated")
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "handleStopTts. clearState=$clearState, userInitiated=$userInitiated"
+        )
         onResetContext()
         preparationJob?.cancel()
         wordTrackingJob?.cancel()
@@ -527,6 +662,11 @@ class TtsPlaybackManager(
         player.stop()
         player.clearMediaItems()
         textChunks = emptyList()
+        bookTitle = null
+        chapterTitle = null
+        coverImageUri = null
+        chapterIndex = null
+        totalChapters = null
         lastPrefetchIndex = -1
         prefetchLoopJob?.cancel()
         prefetchingJobs.values.forEach { it.cancel() }
@@ -541,17 +681,27 @@ class TtsPlaybackManager(
     override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
         val newPlaylistIndex = player.currentMediaItemIndex
         Timber.tag("TTS_CLOUD_DIAG").d("onMediaItemTransition to playlistIndex: $newPlaylistIndex, mediaId: ${mediaItem?.mediaId}, reason: $reason")
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "onMediaItemTransition. playlistIndex=$newPlaylistIndex, mediaId=${mediaItem?.mediaId}, reason=$reason, title='${mediaItem?.mediaMetadata?.title}', playbackState=${player.playbackState}, isPlaying=${player.isPlaying}"
+        )
         if (newPlaylistIndex == C.INDEX_UNSET) return
 
         val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
 
-        val newText = mediaItem.mediaMetadata.subtitle?.toString()
         val extras = mediaItem.mediaMetadata.extras
+        val newText = extras?.getString("ttsText") ?: mediaItem.mediaMetadata.subtitle?.toString()
         val sourceCfi = extras?.getString("sourceCfi")
         val startOffset = extras?.getInt("startOffset", -1) ?: -1
 
         _ttsState.value = _ttsState.value.copy(
             currentText = newText,
+            chapterTitle = chapterTitle,
+            chapterIndex = chapterIndex,
+            totalChapters = totalChapters,
+            currentChunkIndex = currentChunkIndex,
+            totalChunks = textChunks.size,
+            bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
+            sessionFinished = false,
             sourceCfi = sourceCfi,
             startOffsetInSource = startOffset
         )
@@ -582,6 +732,9 @@ class TtsPlaybackManager(
     }
 
     override fun onIsPlayingChanged(isPlaying: Boolean) {
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "onIsPlayingChanged. isPlaying=$isPlaying, playbackState=${player.playbackState}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
+        )
         var nextState = _ttsState.value.copy(isPlaying = isPlaying)
 
         if (isPlaying) {
@@ -599,14 +752,22 @@ class TtsPlaybackManager(
                 currentWordStartOffset = -1
             )
 
-            val currentChunkIndex = player.currentMediaItemIndex
+            val currentChunkIndex = currentChunkIndexFromPlayer()
             val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1
 
             if (player.playbackState == Player.STATE_ENDED) {
                 Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ExoPlayer STATE_ENDED. currentChunkIndex: $currentChunkIndex, isLastChunk: $isLastChunkInSession, totalChunks: ${textChunks.size}")
+                Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+                    "Player reached ENDED. currentChunkIndex=$currentChunkIndex, isLastChunk=$isLastChunkInSession, totalChunks=${textChunks.size}, sessionFinishedWillBeSet=${isLastChunkInSession || textChunks.isEmpty()}"
+                )
                 if (isLastChunkInSession || textChunks.isEmpty()) {
                     Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true")
-                    nextState = nextState.copy(sessionFinished = true)
+                    nextState = nextState.copy(
+                        currentChunkIndex = currentChunkIndex,
+                        totalChunks = textChunks.size,
+                        bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
+                        sessionFinished = true
+                    )
                 } else {
                     val nextIdx = currentChunkIndex + 1
                     val isPrefetching = prefetchingJobs.containsKey(nextIdx)
@@ -625,6 +786,7 @@ class TtsPlaybackManager(
         if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
             if (!nextState.sessionEndedByStop && !nextState.isLoading && preparationJob?.isActive != true) {
                 Timber.tag("TTS_CLOUD_DIAG").d("Auto-stopping TTS from onIsPlayingChanged (IDLE and not loading)")
+                Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Auto-stopping from IDLE/not-loading path.")
                 handleStopTts(userInitiated = true)
             } else {
                 Timber.tag("TTS_CLOUD_DIAG").d("Ignoring STATE_IDLE in onIsPlayingChanged because isLoading=${nextState.isLoading}, preparationJob.isActive=${preparationJob?.isActive}")
@@ -634,6 +796,7 @@ class TtsPlaybackManager(
 
     override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
         Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}")
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(error, "Player error. code=${error.errorCodeName}, message=${error.message}")
         _ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}")
         handleStopTts(userInitiated = true)
     }
@@ -719,10 +882,13 @@ class TtsPlaybackManager(
                                     player.addMediaItem(insertPosition, nextMediaItem)
                                 }
 
-                                if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && targetIndex == player.currentMediaItemIndex + 1) {
+                                val currentChunkIndex = currentChunkIndexFromPlayer()
+                                val isImmediateNextChunk = targetIndex == currentChunkIndex + 1
+
+                                if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) {
                                     player.seekToNextMediaItem()
                                     player.play()
-                                } else if (wasLoading && targetIndex == player.currentMediaItemIndex + 1) {
+                                } else if (wasLoading && isImmediateNextChunk) {
                                     _ttsState.value = _ttsState.value.copy(isLoading = false)
                                 }
                             }
@@ -768,7 +934,10 @@ class TtsPlaybackManager(
                                     if (player.hasNextMediaItem()) {
                                         player.seekToNextMediaItem()
                                     } else {
-                                        player.stop()
+                                        val finishedChunkIndex = currentMediaItem.mediaId.toIntOrNull()
+                                            ?: currentChunkIndexFromPlayer()
+                                        markSessionFinishedNaturally(finishedChunkIndex)
+                                        player.pause()
                                     }
                                 }
                             }
@@ -811,7 +980,37 @@ class TtsPlaybackManager(
     }
 
     private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem {
+        val progress = calculateBookProgressPercent(index)
+        val chunkLabel = if (textChunks.isNotEmpty()) {
+            "Chunk ${index + 1}/${textChunks.size}"
+        } else {
+            null
+        }
+        val chapterLabel = buildString {
+            val chapter = chapterIndex
+            val chapterCount = totalChapters
+            if (chapter != null && chapterCount != null) {
+                append("Chapter ${chapter + 1} of $chapterCount")
+                if (!chapterTitle.isNullOrBlank()) append(": $chapterTitle")
+            } else if (!chapterTitle.isNullOrBlank()) {
+                append(chapterTitle)
+            }
+            if (progress != null) {
+                if (isNotEmpty()) append(" - ")
+                append("$progress%")
+            }
+            if (chunkLabel != null) {
+                if (isNotEmpty()) append(" - ")
+                append(chunkLabel)
+            }
+        }.ifBlank { chapterTitle ?: chunkLabel ?: "TTS" }
+        val chunkPreview = text
+            .replace(Regex("\\s+"), " ")
+            .trim()
+            .take(180)
+
         val extras = Bundle().apply {
+            putString("ttsText", text)
             putString("sourceCfi", chunk.sourceCfi)
             putInt("startOffset", chunk.startOffsetInSource)
             if (chunk.timedWords.isNotEmpty()) {
@@ -823,9 +1022,11 @@ class TtsPlaybackManager(
         }
 
         val metadata = MediaMetadata.Builder()
-            .setArtist(bookTitle)
-            .setTitle(chapterTitle)
-            .setSubtitle(text)
+            .setTitle(bookTitle ?: chapterLabel)
+            .setDisplayTitle(bookTitle ?: chapterLabel)
+            .setArtist(chapterLabel)
+            .setSubtitle(chunkPreview)
+            .setDescription(chunkPreview)
             .setArtworkUri(coverImageUri?.toUri())
             .setTrackNumber(index + 1)
             .setTotalTrackCount(textChunks.size)
@@ -865,7 +1066,12 @@ class TtsPlaybackManager(
             putBoolean("isLoading", state.isLoading)
             putString("errorMessage", state.errorMessage)
             putString("bookTitle", state.bookTitle)
+            putString("chapterTitle", state.chapterTitle)
             putInt("chapterIndex", state.chapterIndex ?: -1)
+            putInt("totalChapters", state.totalChapters ?: -1)
+            putInt("currentChunkIndex", state.currentChunkIndex)
+            putInt("totalChunks", state.totalChunks)
+            putInt("bookProgressPercent", state.bookProgressPercent ?: -1)
             putString("speakerId", state.speakerId)
             putBoolean("sessionEndedByStop", state.sessionEndedByStop)
             putString("currentWordSourceCfi", state.currentWordSourceCfi)
@@ -905,5 +1111,8 @@ class TtsPlaybackManager(
             else -> "UNKNOWN"
         }
         Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName")
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "onPlaybackStateChanged. state=$stateName, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
+        )
     }
 }
diff --git a/app/src/main/java/com/aryan/reader/tts/TtsService.kt b/app/src/main/java/com/aryan/reader/tts/TtsService.kt
index 69afa67..5558ed3 100644
--- a/app/src/main/java/com/aryan/reader/tts/TtsService.kt
+++ b/app/src/main/java/com/aryan/reader/tts/TtsService.kt
@@ -30,6 +30,9 @@ import androidx.media3.common.util.UnstableApi
 import androidx.media3.exoplayer.ExoPlayer
 import androidx.media3.session.MediaSession
 import androidx.media3.session.MediaSessionService
+import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL
+import com.aryan.reader.isByokCloudTtsAvailable
+import com.aryan.reader.loadAiByokSettings
 import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
@@ -236,16 +239,31 @@ class TtsService : MediaSessionService() {
     private lateinit var cacheManager: TtsCacheManager
 
     override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
+        val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+            ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
+        val playerState = if (::player.isInitialized) {
+            "playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
+        } else {
+            "player=uninitialized"
+        }
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "onUpdateNotification called. startInForegroundRequired=$startInForegroundRequired, hasPostNotifications=$hasNotificationPermission, $playerState"
+        )
+
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
             ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
 
             if (startInForegroundRequired) {
+                Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing while foreground is required. Calling stopSelf().")
                 stopSelf()
+            } else {
+                Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing. Skipping notification update.")
             }
 
             return
         }
 
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.")
         super.onUpdateNotification(session, startInForegroundRequired)
     }
 
@@ -279,7 +297,12 @@ class TtsService : MediaSessionService() {
             data class Error(val message: String) : GeminiWsEvent()
         }
 
-        suspend fun ensureConnected(serverUrl: String, speaker: String, authToken: String?) = connectionMutex.withLock {
+        suspend fun ensureConnected(
+            serverUrl: String,
+            speaker: String,
+            authToken: String?,
+            directGeminiApiKey: String? = null
+        ) = connectionMutex.withLock {
             if (webSocket != null) {
                 if (connectedSpeaker == speaker) {
                     val isSetup = try { setupDeferred.await() } catch(_: Exception) { false }
@@ -290,11 +313,15 @@ class TtsService : MediaSessionService() {
                 webSocket = null
             }
 
-            val sanitizedUrl = serverUrl.removeSuffix("/")
-            val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
-            val url = "$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}"
+            val url = if (!directGeminiApiKey.isNullOrBlank()) {
+                "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$directGeminiApiKey"
+            } else {
+                val sanitizedUrl = serverUrl.removeSuffix("/")
+                val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
+                "$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}"
+            }
 
-            Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: $url")
+            Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: ${if (!directGeminiApiKey.isNullOrBlank()) "Gemini BYOK" else url}")
             val request = Request.Builder().url(url).build()
             val connectedDeferred = CompletableDeferred()
 
@@ -316,7 +343,7 @@ class TtsService : MediaSessionService() {
 
                     val setupMsg = JSONObject().apply {
                         put("setup", JSONObject().apply {
-                            put("model", "models/gemini-3.1-flash-live-preview")
+                            put("model", "models/$GEMINI_CLOUD_TTS_MODEL")
                             put("systemInstruction", JSONObject().apply {
                                 put("parts", org.json.JSONArray().apply {
                                     put(JSONObject().apply {
@@ -552,8 +579,17 @@ class TtsService : MediaSessionService() {
                         TtsAudioData(audioFile = cachedFile, serverText = text, wordTimings = emptyList(), error = null, streamUri = null)
                     } else {
                         try {
-                            liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken)
-                            liveClient.generateChunk(text, cachedFile)
+                            val directGeminiApiKey = if (isByokCloudTtsAvailable(this@TtsService)) {
+                                loadAiByokSettings(this@TtsService).geminiKey
+                            } else {
+                                null
+                            }
+                            if (directGeminiApiKey.isNullOrBlank() && googleCloudWorkerTtsUrl.isBlank()) {
+                                TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = "Cloud TTS is not configured.")
+                            } else {
+                                liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken, directGeminiApiKey)
+                                liveClient.generateChunk(text, cachedFile)
+                            }
                         } catch (e: Exception) {
                             Timber.tag("TTS_CLOUD_DIAG").e(e, "Cloud TTS generation failed")
                             TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = e.message ?: "Failed to connect to TTS service")
@@ -567,6 +603,11 @@ class TtsService : MediaSessionService() {
     override fun onCreate() {
         super.onCreate()
         Timber.d("TtsService created.")
+        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(
+            "TtsService onCreate. sdk=${Build.VERSION.SDK_INT}, hasPostNotifications=$hasNotificationPermission"
+        )
 
         cacheManager = TtsCacheManager(this)
 
@@ -622,6 +663,7 @@ class TtsService : MediaSessionService() {
             .setHandleAudioBecomingNoisy(true)
             .setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory))
             .build()
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("ExoPlayer created for TTS service.")
 
         playbackManager = TtsPlaybackManager(
             player = player,
@@ -634,21 +676,30 @@ class TtsService : MediaSessionService() {
             .build()
 
         mediaSession?.let { playbackManager.setMediaSession(it) }
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("MediaSession created and attached to playback manager. sessionAvailable=${mediaSession != null}")
     }
 
     override fun onTaskRemoved(rootIntent: Intent?) {
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}"
+        )
         if (!player.playWhenReady) {
+            Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().")
             stopSelf()
         }
         Timber.d("onTaskRemoved called, stopping service.")
     }
 
     override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
+            "onGetSession. package=${controllerInfo.packageName}, sessionAvailable=${mediaSession != null}"
+        )
         return mediaSession
     }
 
     override fun onDestroy() {
         Timber.d("TtsService is being destroyed.")
+        Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.")
         baseTtsSynthesizer.shutdown()
         playbackManager.release()
         mediaSession?.run {
@@ -658,4 +709,4 @@ class TtsService : MediaSessionService() {
         }
         super.onDestroy()
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt b/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt
index efb8830..46cc2a4 100644
--- a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt
+++ b/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt
@@ -394,4 +394,4 @@ fun createWavHeaderUnknownLength(sampleRate: Int): ByteArray {
     header.putInt(0x7FFFFFFF - 36)
 
     return header.array()
-}
\ No newline at end of file
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5fa4471..dce0da0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -102,6 +102,7 @@
     
     Upload books from your synced folders to Google Drive.
     Custom Fonts
+    Support the Project
     Help & Feedback
     Sign Out
 
@@ -317,6 +318,15 @@
     Email Support
     Contact us directly via email for any other inquiries.
 
+    
+    Support the Project
+    Help keep Episteme moving
+    Your support helps me keep maintaining and Improving Episteme for everyone!!!
+    Sponsor on GitHub
+    Support development directly through GitHub Sponsors. As a thank you, you get a README shoutout in the project repo.
+    Join on Patreon
+    As a thank you for backing the app, Patreon supporters get extra content and benefits: sneak peeks at what I am working on, early screenshots and updates, votes that help shape how new features should look and work, and a README shoutout in the project repo.
+
     
     
     Unlock Episteme Pro
@@ -573,10 +583,14 @@
     
     Reading Themes
     Presets
+    Textured Presets
     My Themes
+    My Textured Themes
     
     No custom themes yet. Tap \'+\' to create one.
+    No custom textured themes yet.
     New Theme
+    New Textured Theme
     Edit Theme
     Theme Name
     
@@ -587,6 +601,10 @@
     ⚠️ Low contrast! This might cause eye strain.
     Page Color
     Text Color
+    Texture
+    None
+    Upload
+    Texture Transparency
     Live Preview
     
     Reading is dreaming.
@@ -808,7 +826,8 @@
     System UI (Status & Navigation Bars)
     Control the visibility of the device\'s system bars.
     Progress Bar
-    The reading progress and chapter indicator at the bottom of the screen.
+    The reading progress and chapter indicator on the reading screen.
+    Position
     
     Seamless Chapter Transition
     Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation.
@@ -871,6 +890,7 @@
     Paragraph Gap
     Image Size
     Horizontal Margin
+    Vertical Margin
     None
     
     Orig
@@ -1214,6 +1234,7 @@
     Downloading Bubble Zoom model… %1$d%%
     Exit slider navigation
     Jump Back
+    Jump Forward
     Scroll to reading page
     Annotated Page
     Close Image
@@ -1261,9 +1282,9 @@
     Short
     Long
     
-    Spd: %1$sx
+    Speed: %1$sx
     
-    Ptch: %1$sx
+    Pitch: %1$sx
     Play/Pause
     Reset Speed
     Reset Pitch
diff --git a/app/src/oss/java/com/aryan/reader/data/CloudflareRepository.kt b/app/src/oss/java/com/aryan/reader/data/CloudflareRepository.kt
index 4d222f8..5e0b171 100644
--- a/app/src/oss/java/com/aryan/reader/data/CloudflareRepository.kt
+++ b/app/src/oss/java/com/aryan/reader/data/CloudflareRepository.kt
@@ -20,4 +20,4 @@ class CloudflareRepository {
     suspend fun verifyPurchase(purchaseToken: String, productId: String): Result {
         return Result.failure(Exception("Not available in OSS version"))
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt b/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt
new file mode 100644
index 0000000..7aa8a92
--- /dev/null
+++ b/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt
@@ -0,0 +1,28 @@
+package com.aryan.reader
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class FileTypeResolverTest {
+
+    @Test
+    fun `transparent txt suffix preserves supported inner extension`() {
+        assertEquals(FileType.MD, resolveFileTypeFromName("notes.md.txt"))
+        assertEquals(FileType.HTML, resolveFileTypeFromName("chapter.html.txt"))
+        assertEquals(FileType.HTML, resolveFileTypeFromName("snippet.js.txt"))
+        assertEquals(FileType.EPUB, resolveFileTypeFromName("book.epub.txt"))
+    }
+
+    @Test
+    fun `plain txt remains txt when inner extension is unsupported`() {
+        assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
+        assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt"))
+    }
+
+    @Test
+    fun `extension suffix preserves transparent txt wrapper`() {
+        assertEquals(".md.txt", resolveFileExtensionSuffixFromName("notes.md.txt"))
+        assertEquals(".html.txt", resolveFileExtensionSuffixFromName("chapter.html.txt"))
+        assertEquals(".txt", resolveFileExtensionSuffixFromName("notes.txt"))
+    }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 6e1e49a..d61b9aa 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,9 @@
-// Top-level build file where you can add configuration options common to all subprojects/modules.
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
 plugins {
     alias(libs.plugins.android.application) apply false
+    alias(libs.plugins.android.library) apply false
     alias(libs.plugins.kotlin.android) apply false
+    alias(libs.plugins.kotlin.multiplatform) apply false
     alias(libs.plugins.kotlin.compose) apply false
-}
\ No newline at end of file
+    alias(libs.plugins.compose.multiplatform) apply false
+}
diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts
new file mode 100644
index 0000000..1f48b40
--- /dev/null
+++ b/desktopApp/build.gradle.kts
@@ -0,0 +1,55 @@
+import org.gradle.api.tasks.JavaExec
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+
+plugins {
+    alias(libs.plugins.kotlin.multiplatform)
+    alias(libs.plugins.kotlin.compose)
+    alias(libs.plugins.compose.multiplatform)
+}
+
+kotlin {
+    jvm("desktop")
+    jvmToolchain(21)
+
+    sourceSets {
+        val desktopMain by getting {
+            dependencies {
+                implementation(project(":shared"))
+                implementation(compose.desktop.currentOs)
+                implementation(compose.material3)
+                implementation(compose.materialIconsExtended)
+                implementation("io.github.kevinnzou:compose-webview-multiplatform:2.0.3")
+                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+                implementation("net.java.dev.jna:jna:5.17.0")
+            }
+        }
+    }
+}
+
+compose.desktop {
+    application {
+        mainClass = "com.aryan.reader.desktop.MainKt"
+
+        jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
+        jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
+
+        nativeDistributions {
+            targetFormats(TargetFormat.Exe, TargetFormat.Msi)
+            packageName = "Episteme"
+            packageVersion = "1.0.0"
+            description = "Episteme desktop shell"
+            vendor = "Aryan Reader"
+        }
+    }
+}
+
+afterEvaluate {
+    tasks.withType().configureEach {
+        jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
+        jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
+        if (System.getProperty("os.name").contains("Mac")) {
+            jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED")
+            jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
+        }
+    }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubLoader.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubLoader.kt
new file mode 100644
index 0000000..f214ecc
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubLoader.kt
@@ -0,0 +1,282 @@
+package com.aryan.reader.desktop
+
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.sp
+import com.aryan.reader.paginatedreader.CssParser
+import com.aryan.reader.paginatedreader.OptimizedCssRules
+import com.aryan.reader.paginatedreader.UserAgentStylesheet
+import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
+import com.aryan.reader.shared.reader.SharedEpubBook
+import com.aryan.reader.shared.reader.SharedEpubChapter
+import java.io.File
+import java.util.Base64
+import java.util.UUID
+import java.util.zip.ZipFile
+
+object DesktopEpubLoader {
+    fun load(file: File): SharedEpubBook {
+        ZipFile(file).use { zip ->
+            val container = zip.readText("META-INF/container.xml")
+            val opfPath = container
+                .substringAfter("full-path=\"", missingDelimiterValue = "")
+                .substringBefore("\"")
+                .ifBlank { error("EPUB container does not point to an OPF package.") }
+            val opf = zip.readText(opfPath)
+            val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
+                .let { if (it.isBlank()) "" else "$it/" }
+
+            val title = opf.tagText("title").ifBlank { file.nameWithoutExtension }
+            val author = opf.tagText("creator").ifBlank { null }
+            val manifest = parseManifest(opf)
+            val cssByPath = loadCss(zip, manifest, basePath)
+            val cssRules = parseCssRules(cssByPath)
+            val spine = Regex("]*idref=[\"']([^\"']+)[\"'][^>]*/?>")
+                .findAll(opf)
+                .mapNotNull { match -> manifest[match.groupValues[1]] }
+                .toList()
+
+            val chapterPaths = spine.ifEmpty {
+                manifest.values.filter { it.endsWith(".xhtml", ignoreCase = true) || it.endsWith(".html", ignoreCase = true) }
+            }
+
+            val chapters = chapterPaths.mapIndexedNotNull { index, href ->
+                val path = normalizeZipPath(basePath + href)
+                val html = zip.readTextOrNull(path) ?: return@mapIndexedNotNull null
+                val resourceReadyHtml = html.sanitizeReaderHtml().withEmbeddedResources(zip, path)
+                val text = htmlToText(html)
+                val semanticBlocks = runCatching {
+                    htmlToSemanticBlocks(
+                        html = resourceReadyHtml,
+                        cssRules = cssRules,
+                        textStyle = TextStyle(fontSize = 18.sp),
+                        chapterAbsPath = path,
+                        extractionBasePath = "",
+                        density = Density(1f),
+                        fontFamilyMap = emptyMap(),
+                        constraints = Constraints(maxWidth = 980, maxHeight = 720)
+                    )
+                }.getOrElse { emptyList() }
+                if (text.isBlank()) {
+                    null
+                } else {
+                    SharedEpubChapter(
+                        id = "chapter_$index",
+                        title = html.tagText("h1")
+                            .ifBlank { html.tagText("h2") }
+                            .ifBlank { html.tagText("title") }
+                            .ifBlank { "Chapter ${index + 1}" },
+                        plainText = text,
+                        semanticBlocks = semanticBlocks,
+                        htmlContent = resourceReadyHtml.extractBodyOrSelf(),
+                        baseHref = path.substringBeforeLast('/', missingDelimiterValue = "")
+                    )
+                }
+            }
+
+            return SharedEpubBook(
+                id = file.absolutePath,
+                fileName = file.name,
+                title = title,
+                author = author,
+                css = cssByPath,
+                chapters = chapters.ifEmpty {
+                    listOf(
+                        SharedEpubChapter(
+                            id = UUID.randomUUID().toString(),
+                            title = title,
+                            plainText = "This EPUB opened, but no readable spine text was found by the lightweight desktop loader."
+                        )
+                    )
+                }
+            )
+        }
+    }
+
+    private fun parseManifest(opf: String): Map {
+        return Regex("]*>").findAll(opf).mapNotNull { match ->
+            val item = match.value
+            val id = item.attr("id")
+            val href = item.attr("href")
+            if (id.isBlank() || href.isBlank()) null else id to href
+        }.toMap()
+    }
+
+    private fun loadCss(zip: ZipFile, manifest: Map, basePath: String): Map {
+        return manifest.values
+            .filter { it.endsWith(".css", ignoreCase = true) }
+            .mapNotNull { href ->
+                val path = normalizeZipPath(basePath + href)
+                val css = zip.readTextOrNull(path)?.withEmbeddedCssResources(zip, path).orEmpty()
+                if (css.isBlank()) null else path to css
+            }
+            .toMap()
+    }
+
+    private fun parseCssRules(cssByPath: Map): OptimizedCssRules {
+        val constraints = Constraints(maxWidth = 980, maxHeight = 720)
+        val baseRules = CssParser.parse(
+            cssContent = UserAgentStylesheet.default,
+            cssPath = null,
+            baseFontSizeSp = 18f,
+            density = 1f,
+            constraints = constraints,
+            isDarkTheme = false
+        ).rules
+
+        return cssByPath.entries
+            .fold(baseRules) { rules, (path, css) ->
+                if (css.isBlank()) {
+                    rules
+                } else {
+                    rules.merge(
+                        CssParser.parse(
+                            cssContent = css,
+                            cssPath = path,
+                            baseFontSizeSp = 18f,
+                            density = 1f,
+                            constraints = constraints,
+                            isDarkTheme = false
+                        ).rules
+                    )
+                }
+            }
+    }
+
+    private fun ZipFile.readText(path: String): String {
+        val entry = getEntry(path) ?: error("Missing EPUB entry: $path")
+        return getInputStream(entry).bufferedReader().use { it.readText() }
+    }
+
+    private fun ZipFile.readTextOrNull(path: String): String? {
+        val entry = getEntry(path) ?: return null
+        return getInputStream(entry).bufferedReader().use { it.readText() }
+    }
+
+    private fun String.attr(name: String): String {
+        return Regex("""\b$name=["']([^"']+)["']""").find(this)?.groupValues?.get(1).orEmpty()
+    }
+
+    private fun String.tagText(tag: String): String {
+        return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)]+:)?$tag>", RegexOption.IGNORE_CASE)
+            .find(this)
+            ?.groupValues
+            ?.get(1)
+            ?.let(::htmlToText)
+            .orEmpty()
+    }
+
+    private fun normalizeZipPath(path: String): String {
+        val parts = ArrayDeque()
+        path.split('/').forEach { part ->
+            when (part) {
+                "", "." -> Unit
+                ".." -> if (parts.isNotEmpty()) parts.removeLast()
+                else -> parts.addLast(part)
+            }
+        }
+        return parts.joinToString("/")
+    }
+
+    private fun String.withEmbeddedResources(zip: ZipFile, chapterPath: String): String {
+        return replace(Regex("""(?i)\b(src|href)=["']([^"']+)["']""")) { match ->
+            val attr = match.groupValues[1]
+            val raw = match.groupValues[2]
+            if (attr.equals("href", ignoreCase = true) && !raw.looksLikeEmbeddableResource()) {
+                return@replace match.value
+            }
+            val dataUri = zip.toDataUri(raw, chapterPath)
+            if (dataUri != null) "$attr=\"$dataUri\"" else match.value
+        }
+    }
+
+    private fun String.looksLikeEmbeddableResource(): Boolean {
+        return substringBefore('#')
+            .substringBefore('?')
+            .substringAfterLast('.', "")
+            .lowercase() in setOf("css", "jpg", "jpeg", "png", "gif", "svg", "webp", "ttf", "otf", "woff", "woff2")
+    }
+
+    private fun String.sanitizeReaderHtml(): String {
+        return replace(Regex("(?is)"), "")
+            .replace(Regex("(?is)"), "")
+            .replace(Regex("(?is)]*>"), "")
+            .replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
+    }
+
+    private fun String.withEmbeddedCssResources(zip: ZipFile, cssPath: String): String {
+        return replace(Regex("""url\((['"]?)([^)'"]+)\1\)""", RegexOption.IGNORE_CASE)) { match ->
+            val raw = match.groupValues[2].trim()
+            val dataUri = zip.toDataUri(raw, cssPath)
+            if (dataUri != null) "url('$dataUri')" else match.value
+        }
+    }
+
+    private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? {
+        val ref = rawRef.substringBefore('#').trim()
+        if (ref.isBlank() || ref.startsWith("data:", ignoreCase = true)) return null
+        if (ref.startsWith("http://", ignoreCase = true) || ref.startsWith("https://", ignoreCase = true)) return null
+        val base = ownerPath.substringBeforeLast('/', missingDelimiterValue = "")
+        val path = normalizeZipPath(if (base.isBlank()) ref else "$base/$ref")
+        val entry = getEntry(path) ?: return null
+        val bytes = getInputStream(entry).use { it.readBytes() }
+        return "data:${mimeType(path)};base64,${Base64.getEncoder().encodeToString(bytes)}"
+    }
+
+    private fun mimeType(path: String): String {
+        return when (path.substringAfterLast('.', "").lowercase()) {
+            "jpg", "jpeg" -> "image/jpeg"
+            "png" -> "image/png"
+            "gif" -> "image/gif"
+            "svg" -> "image/svg+xml"
+            "webp" -> "image/webp"
+            "ttf" -> "font/ttf"
+            "otf" -> "font/otf"
+            "woff" -> "font/woff"
+            "woff2" -> "font/woff2"
+            "css" -> "text/css"
+            "js" -> "text/javascript"
+            else -> "application/octet-stream"
+        }
+    }
+
+    private fun String.extractBodyOrSelf(): String {
+        return Regex("(?is)]*>(.*?)")
+            .find(this)
+            ?.groupValues
+            ?.get(1)
+            ?.trim()
+            ?: this
+    }
+
+    private fun htmlToText(html: String): String {
+        return html
+            .replace(Regex("(?is)"), "")
+            .replace(Regex("(?is)"), "")
+            .replace(Regex("(?i)"), "\n")
+            .replace(Regex("(?i)"), "\n\n")
+            .replace(Regex("(?i)"), "\n\n")
+            .replace(Regex("<[^>]+>"), " ")
+            .decodeEntities()
+            .replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
+            .replace(Regex(" *\\n *"), "\n")
+            .replace(Regex("\\n{3,}"), "\n\n")
+            .trim()
+    }
+
+    private fun String.decodeEntities(): String {
+        return replace(" ", " ")
+            .replace("&", "&")
+            .replace("<", "<")
+            .replace(">", ">")
+            .replace(""", "\"")
+            .replace("'", "'")
+            .replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
+                match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
+            }
+            .replace(Regex("&#(\\d+);")) { match ->
+                match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
+            }
+    }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt
new file mode 100644
index 0000000..4b92702
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt
@@ -0,0 +1,205 @@
+package com.aryan.reader.desktop
+
+import com.aryan.reader.shared.BookItem
+import com.aryan.reader.shared.BookShelfRef
+import com.aryan.reader.shared.FileType
+import com.aryan.reader.shared.ShelfRecord
+import com.aryan.reader.shared.Tag
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.doubleOrNull
+import kotlinx.serialization.json.floatOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+import java.io.File
+
+data class DesktopLibrarySnapshot(
+    val books: List = emptyList(),
+    val shelfRecords: List = emptyList(),
+    val shelfRefs: List = emptyList(),
+    val tags: List = emptyList()
+)
+
+class DesktopLibraryDatabase(
+    private val databaseFile: File = defaultDatabaseFile()
+) {
+    private val json = Json {
+        prettyPrint = true
+        ignoreUnknownKeys = true
+    }
+
+    fun load(): DesktopLibrarySnapshot {
+        if (!databaseFile.exists()) return DesktopLibrarySnapshot()
+        val root = runCatching {
+            json.parseToJsonElement(databaseFile.readText()).jsonObject
+        }.getOrNull() ?: return DesktopLibrarySnapshot()
+
+        return DesktopLibrarySnapshot(
+            books = root.array("books").mapNotNull { it.asBookItemOrNull() },
+            shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
+            shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
+            tags = root.array("tags").mapNotNull { it.asTagOrNull() }
+        )
+    }
+
+    fun save(snapshot: DesktopLibrarySnapshot) {
+        databaseFile.parentFile?.mkdirs()
+        val root = JsonObject(
+            mapOf(
+                "schemaVersion" to JsonPrimitive(1),
+                "books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
+                "shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
+                "bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
+                "tags" to JsonArray(snapshot.tags.map { it.toJsonObject() })
+            )
+        )
+        databaseFile.writeText(root.toString())
+    }
+
+    companion object {
+        fun defaultDatabaseFile(): File {
+            val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
+                ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
+            return File(baseDir, "Episteme/library.json")
+        }
+    }
+}
+
+private fun JsonObject.array(name: String): List {
+    return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
+}
+
+private fun JsonObject.string(name: String): String? {
+    return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content
+}
+
+private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
+    return this[name]?.jsonPrimitive?.longOrNull ?: fallback
+}
+
+private fun JsonObject.float(name: String): Float? {
+    return this[name]?.jsonPrimitive?.floatOrNull
+}
+
+private fun JsonObject.double(name: String): Double? {
+    return this[name]?.jsonPrimitive?.doubleOrNull
+}
+
+private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
+    return this[name]?.jsonPrimitive?.booleanOrNull ?: fallback
+}
+
+private fun JsonElement.asBookItemOrNull(): BookItem? {
+    val obj = runCatching { jsonObject }.getOrNull() ?: return null
+    val id = obj.string("id") ?: return null
+    val displayName = obj.string("displayName") ?: return null
+    val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
+    return BookItem(
+        id = id,
+        path = obj.string("path"),
+        type = type,
+        displayName = displayName,
+        timestamp = obj.long("timestamp"),
+        title = obj.string("title"),
+        author = obj.string("author"),
+        progressPercentage = obj.float("progressPercentage"),
+        isRecent = obj.boolean("isRecent", true),
+        fileSize = obj.long("fileSize"),
+        sourceFolder = obj.string("sourceFolder"),
+        seriesName = obj.string("seriesName"),
+        seriesIndex = obj.double("seriesIndex"),
+        tags = obj.array("tags").mapNotNull { it.asTagOrNull() }
+    )
+}
+
+private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
+    val obj = runCatching { jsonObject }.getOrNull() ?: return null
+    return ShelfRecord(
+        id = obj.string("id") ?: return null,
+        name = obj.string("name") ?: return null,
+        isSmart = obj.boolean("isSmart", false),
+        smartRulesJson = obj.string("smartRulesJson")
+    )
+}
+
+private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
+    val obj = runCatching { jsonObject }.getOrNull() ?: return null
+    return BookShelfRef(
+        bookId = obj.string("bookId") ?: return null,
+        shelfId = obj.string("shelfId") ?: return null,
+        addedAt = obj.long("addedAt")
+    )
+}
+
+private fun JsonElement.asTagOrNull(): Tag? {
+    val obj = runCatching { jsonObject }.getOrNull() ?: return null
+    return Tag(
+        id = obj.string("id") ?: return null,
+        name = obj.string("name") ?: return null,
+        color = obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
+    )
+}
+
+private fun BookItem.toJsonObject(): JsonObject {
+    return JsonObject(
+        mapOf(
+            "id" to JsonPrimitive(id),
+            "path" to path.asJson(),
+            "type" to JsonPrimitive(type.name),
+            "displayName" to JsonPrimitive(displayName),
+            "timestamp" to JsonPrimitive(timestamp),
+            "title" to title.asJson(),
+            "author" to author.asJson(),
+            "progressPercentage" to progressPercentage.asJson(),
+            "isRecent" to JsonPrimitive(isRecent),
+            "fileSize" to JsonPrimitive(fileSize),
+            "sourceFolder" to sourceFolder.asJson(),
+            "seriesName" to seriesName.asJson(),
+            "seriesIndex" to seriesIndex.asJson(),
+            "tags" to JsonArray(tags.map { it.toJsonObject() })
+        )
+    )
+}
+
+private fun ShelfRecord.toJsonObject(): JsonObject {
+    return JsonObject(
+        mapOf(
+            "id" to JsonPrimitive(id),
+            "name" to JsonPrimitive(name),
+            "isSmart" to JsonPrimitive(isSmart),
+            "smartRulesJson" to smartRulesJson.asJson()
+        )
+    )
+}
+
+private fun BookShelfRef.toJsonObject(): JsonObject {
+    return JsonObject(
+        mapOf(
+            "bookId" to JsonPrimitive(bookId),
+            "shelfId" to JsonPrimitive(shelfId),
+            "addedAt" to JsonPrimitive(addedAt)
+        )
+    )
+}
+
+private fun Tag.toJsonObject(): JsonObject {
+    return JsonObject(
+        mapOf(
+            "id" to JsonPrimitive(id),
+            "name" to JsonPrimitive(name),
+            "color" to color.asJson()
+        )
+    )
+}
+
+private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
+private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
+private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
+private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt
new file mode 100644
index 0000000..8930272
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt
@@ -0,0 +1,239 @@
+package com.aryan.reader.desktop
+
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.toComposeImageBitmap
+import com.aryan.reader.shared.pdf.PdfZoomSpec
+import com.sun.jna.Library
+import com.sun.jna.Memory
+import com.sun.jna.Native
+import com.sun.jna.Pointer
+import java.awt.image.BufferedImage
+import java.io.File
+import java.nio.ByteOrder
+import kotlin.math.roundToInt
+
+data class DesktopPdfDocument(
+    val path: String,
+    val title: String,
+    val pageCount: Int,
+    val pageSizes: List,
+    val textPages: List
+) {
+    fun close() {
+        DesktopPdfium.closeDocument(path)
+    }
+}
+
+data class DesktopPdfPageSize(
+    val width: Float,
+    val height: Float
+)
+
+data class DesktopPdfPageRender(
+    val image: ImageBitmap,
+    val width: Int,
+    val height: Int
+)
+
+object DesktopPdfium {
+    private const val FPDF_ANNOT = 0x01
+    private const val FPDF_LCD_TEXT = 0x02
+    private const val FPDF_RENDER_NO_SMOOTHTEXT = 0x1000
+    private const val FPDF_BITMAP_BGRA = 4
+
+    private val pdfiumDll: File by lazy(::resolvePdfiumDll)
+    private val zoomSpec = PdfZoomSpec()
+    private val api: PdfiumLibrary by lazy {
+        require(pdfiumDll.exists()) {
+            "Missing Pdfium DLL. Expected pdfium-v8-win-x64 under third_party/pdfium/win-x64-v8/bin/pdfium.dll."
+        }
+        Native.load(pdfiumDll.absolutePath, PdfiumLibrary::class.java)
+    }
+
+    private var initialized = false
+    private val openDocuments = LinkedHashMap()
+
+    fun isAvailable(): Boolean = pdfiumDll.exists()
+
+    fun load(file: File, password: String? = null): DesktopPdfDocument {
+        initLibrary()
+        val document = api.FPDF_LoadDocument(file.absolutePath, password)
+            ?: error("Pdfium could not open ${file.name}. It may be encrypted or unsupported.")
+        val pageCount = api.FPDF_GetPageCount(document)
+        openDocuments[file.absolutePath] = document
+
+        val pageSizes = (0 until pageCount).map { pageIndex ->
+            loadPage(document, pageIndex).usePointer { page ->
+                DesktopPdfPageSize(
+                    width = api.FPDF_GetPageWidthF(page),
+                    height = api.FPDF_GetPageHeightF(page)
+                )
+            }
+        }
+
+        val textPages = (0 until pageCount).map { pageIndex ->
+            extractPageText(document, pageIndex)
+        }
+
+        return DesktopPdfDocument(
+            path = file.absolutePath,
+            title = file.nameWithoutExtension,
+            pageCount = pageCount,
+            pageSizes = pageSizes,
+            textPages = textPages
+        )
+    }
+
+    fun closeDocument(path: String) {
+        openDocuments.remove(path)?.let(api::FPDF_CloseDocument)
+    }
+
+    fun renderPage(
+        document: DesktopPdfDocument,
+        pageIndex: Int,
+        scale: Float,
+        renderAnnotations: Boolean = true
+    ): DesktopPdfPageRender {
+        val nativeDocument = openDocuments[document.path] ?: error("PDF document is not open.")
+        val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.")
+        val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
+        val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1)
+        val height = (pageSize.height * safeScale).roundToInt().coerceAtLeast(1)
+        val stride = width * 4
+        val memory = Memory((stride * height).toLong())
+        memory.clear(memory.size())
+
+        val bitmap = api.FPDFBitmap_CreateEx(width, height, FPDF_BITMAP_BGRA, memory, stride)
+            ?: error("Pdfium could not allocate render bitmap.")
+
+        try {
+            api.FPDFBitmap_FillRect(bitmap, 0, 0, width, height, -1)
+            loadPage(nativeDocument, pageIndex).usePointer { page ->
+                val flags = FPDF_LCD_TEXT or
+                    (if (renderAnnotations) FPDF_ANNOT else FPDF_RENDER_NO_SMOOTHTEXT)
+                api.FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, flags)
+            }
+            return DesktopPdfPageRender(
+                image = memory.toBufferedImage(width, height, stride).toComposeImageBitmap(),
+                width = width,
+                height = height
+            )
+        } finally {
+            api.FPDFBitmap_Destroy(bitmap)
+        }
+    }
+
+    private fun extractPageText(document: Pointer, pageIndex: Int): String {
+        return runCatching {
+            loadPage(document, pageIndex).usePointer { page ->
+                val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer ""
+                try {
+                    val charCount = api.FPDFText_CountChars(textPage)
+                    if (charCount <= 0) return@usePointer ""
+                    val buffer = Memory(((charCount + 1) * 2L))
+                    val written = api.FPDFText_GetText(textPage, 0, charCount, buffer)
+                    if (written <= 0) {
+                        ""
+                    } else {
+                        buffer.getCharArray(0, written).concatToString().trimEnd('\u0000')
+                    }
+                } finally {
+                    api.FPDFText_ClosePage(textPage)
+                }
+            }
+        }.getOrDefault("")
+    }
+
+    private fun loadPage(document: Pointer, pageIndex: Int): PointerResource {
+        val page = api.FPDF_LoadPage(document, pageIndex)
+            ?: error("Pdfium could not open page ${pageIndex + 1}.")
+        return PointerResource(page, api::FPDF_ClosePage)
+    }
+
+    private fun initLibrary() {
+        if (!initialized) {
+            api.FPDF_InitLibrary()
+            initialized = true
+        }
+    }
+
+    private fun resolvePdfiumDll(): File {
+        val overridePath = System.getProperty("reader.pdfium.dll")
+            ?: System.getenv("READER_PDFIUM_DLL")
+        if (!overridePath.isNullOrBlank()) {
+            return File(overridePath).absoluteFile
+        }
+
+        val relativePath = listOf("third_party", "pdfium", "win-x64-v8", "bin", "pdfium.dll")
+            .joinToString(File.separator)
+        val roots = generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile }
+            .take(6)
+            .toList()
+
+        return roots
+            .map { File(it, relativePath).absoluteFile }
+            .firstOrNull { it.exists() }
+            ?: File(File(System.getProperty("user.dir")).absoluteFile, relativePath).absoluteFile
+    }
+
+    private fun Memory.toBufferedImage(width: Int, height: Int, stride: Int): BufferedImage {
+        val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
+        val buffer = getByteBuffer(0, size()).order(ByteOrder.LITTLE_ENDIAN)
+        val pixels = IntArray(width * height)
+        for (y in 0 until height) {
+            buffer.position(y * stride)
+            for (x in 0 until width) {
+                val b = buffer.get().toInt() and 0xFF
+                val g = buffer.get().toInt() and 0xFF
+                val r = buffer.get().toInt() and 0xFF
+                val a = buffer.get().toInt() and 0xFF
+                pixels[y * width + x] = (a shl 24) or (r shl 16) or (g shl 8) or b
+            }
+        }
+        image.setRGB(0, 0, width, height, pixels, 0, width)
+        return image
+    }
+
+    private class PointerResource(
+        private val pointer: Pointer,
+        private val closer: (Pointer) -> Unit
+    ) {
+        fun  usePointer(block: (Pointer) -> T): T {
+            try {
+                return block(pointer)
+            } finally {
+                closer(pointer)
+            }
+        }
+    }
+
+    @Suppress("FunctionName")
+    private interface PdfiumLibrary : Library {
+        fun FPDF_InitLibrary()
+        fun FPDF_LoadDocument(filePath: String, password: String?): Pointer?
+        fun FPDF_CloseDocument(document: Pointer)
+        fun FPDF_GetPageCount(document: Pointer): Int
+        fun FPDF_LoadPage(document: Pointer, pageIndex: Int): Pointer?
+        fun FPDF_ClosePage(page: Pointer)
+        fun FPDF_GetPageWidthF(page: Pointer): Float
+        fun FPDF_GetPageHeightF(page: Pointer): Float
+        fun FPDFBitmap_CreateEx(width: Int, height: Int, format: Int, firstScan: Pointer, stride: Int): Pointer?
+        fun FPDFBitmap_FillRect(bitmap: Pointer, left: Int, top: Int, width: Int, height: Int, color: Int)
+        fun FPDFBitmap_Destroy(bitmap: Pointer)
+        fun FPDF_RenderPageBitmap(
+            bitmap: Pointer,
+            page: Pointer,
+            startX: Int,
+            startY: Int,
+            sizeX: Int,
+            sizeY: Int,
+            rotate: Int,
+            flags: Int
+        )
+
+        fun FPDFText_LoadPage(page: Pointer): Pointer?
+        fun FPDFText_ClosePage(textPage: Pointer)
+        fun FPDFText_CountChars(textPage: Pointer): Int
+        fun FPDFText_GetText(textPage: Pointer, startIndex: Int, count: Int, result: Pointer): Int
+    }
+}
diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt
new file mode 100644
index 0000000..1e2c715
--- /dev/null
+++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt
@@ -0,0 +1,2429 @@
+package com.aryan.reader.desktop
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.Canvas
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.LibraryBooks
+import androidx.compose.material.icons.automirrored.filled.MenuBook
+import androidx.compose.material.icons.automirrored.filled.NavigateBefore
+import androidx.compose.material.icons.automirrored.filled.NavigateNext
+import androidx.compose.material.icons.filled.Bookmark
+import androidx.compose.material.icons.filled.BookmarkBorder
+import androidx.compose.material.icons.filled.Brush
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Draw
+import androidx.compose.material.icons.filled.EditNote
+import androidx.compose.material.icons.filled.FormatColorText
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.ImportExport
+import androidx.compose.material.icons.filled.Remove
+import androidx.compose.material.icons.filled.Sync
+import androidx.compose.material.icons.filled.TextFields
+import androidx.compose.material.icons.filled.ZoomIn
+import androidx.compose.material.icons.filled.ZoomOut
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.isSpecified
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.isCtrlPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.isSpecified
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import com.aryan.reader.paginatedreader.SemanticBlock
+import com.aryan.reader.paginatedreader.SemanticFlexContainer
+import com.aryan.reader.paginatedreader.SemanticHeader
+import com.aryan.reader.paginatedreader.SemanticImage
+import com.aryan.reader.paginatedreader.SemanticList
+import com.aryan.reader.paginatedreader.SemanticListItem
+import com.aryan.reader.paginatedreader.SemanticMath
+import com.aryan.reader.paginatedreader.SemanticParagraph
+import com.aryan.reader.paginatedreader.SemanticSpacer
+import com.aryan.reader.paginatedreader.SemanticTable
+import com.aryan.reader.paginatedreader.SemanticTextBlock
+import com.aryan.reader.paginatedreader.SemanticWrappingBlock
+import com.aryan.reader.shared.AppAction
+import com.aryan.reader.shared.BannerMessage
+import com.aryan.reader.shared.BookItem
+import com.aryan.reader.shared.BookShelfRef
+import com.aryan.reader.shared.FileType
+import com.aryan.reader.shared.ImportedBookFile
+import com.aryan.reader.shared.LibraryAction
+import com.aryan.reader.shared.SharedLibraryProjectionInput
+import com.aryan.reader.shared.SharedLibraryStateProjector
+import com.aryan.reader.shared.SharedReaderScreenState
+import com.aryan.reader.shared.Shelf
+import com.aryan.reader.shared.ShelfRecord
+import com.aryan.reader.shared.ShelfType
+import com.aryan.reader.shared.Tag
+import com.aryan.reader.shared.withImportedFiles
+import com.aryan.reader.shared.reduce
+import com.aryan.reader.shared.reader.ReaderEngine
+import com.aryan.reader.shared.reader.ReaderHtmlDocumentBuilder
+import com.aryan.reader.shared.reader.ReaderReadingMode
+import com.aryan.reader.shared.reader.ReaderSessionState
+import com.aryan.reader.shared.reader.SharedReaderTextAlign
+import com.aryan.reader.shared.reader.SampleReaderBooks
+import com.aryan.reader.shared.ui.NonReaderLibraryTab
+import com.aryan.reader.shared.ui.SharedHomeScreen
+import com.aryan.reader.shared.ui.SharedLibraryScreen
+import com.aryan.reader.shared.ui.SharedShelvesScreen
+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.PdfZoomSpec
+import com.aryan.reader.shared.pdf.SharedPdfAnnotation
+import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
+import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
+import dev.datlag.kcef.KCEF
+import com.multiplatform.webview.web.LoadingState
+import com.multiplatform.webview.web.WebView
+import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.awt.FileDialog
+import java.awt.Frame
+import java.io.File
+import kotlin.math.abs
+import kotlin.math.max
+
+fun main() = application {
+    Window(
+        onCloseRequest = ::exitApplication,
+        title = "Episteme",
+    ) {
+        EpistemeDesktopApp()
+    }
+}
+
+private enum class DesktopTab { HOME, LIBRARY, SHELVES, READER }
+
+private data class DesktopWebViewRuntimeState(
+    val initialized: Boolean = false,
+    val restartRequired: Boolean = false,
+    val downloadProgress: Float = -1f,
+    val errorMessage: String? = null
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun EpistemeDesktopApp() {
+    val libraryProjector = remember { SharedLibraryStateProjector() }
+    val readerEngine = remember { ReaderEngine() }
+    val libraryDatabase = remember { DesktopLibraryDatabase() }
+    val initialLibrarySnapshot = remember { libraryDatabase.load() }
+    val scope = rememberCoroutineScope()
+    var webViewRuntimeState by remember { mutableStateOf(DesktopWebViewRuntimeState()) }
+
+    LaunchedEffect(Unit) {
+        withContext(Dispatchers.IO) {
+            KCEF.init(
+                builder = {
+                    installDir(File("kcef-bundle"))
+                    progress {
+                        onDownloading {
+                            webViewRuntimeState = webViewRuntimeState.copy(downloadProgress = max(it, 0f))
+                        }
+                        onInitialized {
+                            webViewRuntimeState = webViewRuntimeState.copy(initialized = true, errorMessage = null)
+                        }
+                    }
+                    settings {
+                        cachePath = File("cache").absolutePath
+                    }
+                },
+                onError = { error ->
+                    webViewRuntimeState = webViewRuntimeState.copy(errorMessage = error?.message ?: error.toString())
+                },
+                onRestartRequired = {
+                    webViewRuntimeState = webViewRuntimeState.copy(restartRequired = true)
+                }
+            )
+        }
+    }
+
+    DisposableEffect(Unit) {
+        onDispose {
+            KCEF.disposeBlocking()
+        }
+    }
+
+    var shelfRecords by remember { mutableStateOf(initialLibrarySnapshot.shelfRecords) }
+    var shelfRefs by remember { mutableStateOf(initialLibrarySnapshot.shelfRefs) }
+    var state by remember {
+        val initialBooks = initialLibrarySnapshot.books
+        val initialTags = initialLibrarySnapshot.tags.ifEmpty { initialBooks.collectTags() }
+        val initialState = SharedReaderScreenState(
+            rawLibraryBooks = initialBooks,
+            recentFilesLimit = 12,
+            allTags = initialTags
+        )
+        mutableStateOf(
+            libraryProjector.project(
+                SharedLibraryProjectionInput(
+                    state = initialState,
+                    booksFromStore = initialState.rawLibraryBooks,
+                    shelfRecords = shelfRecords,
+                    shelfRefs = shelfRefs,
+                    tags = initialState.allTags
+                )
+            )
+        )
+    }
+    var selectedTab by remember { mutableStateOf(DesktopTab.HOME) }
+    var selectedLibraryTab by remember { mutableStateOf(NonReaderLibraryTab.BOOKS) }
+    var activeReaderBookId by remember { mutableStateOf(null) }
+    var readerSession by remember { mutableStateOf(readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook())) }
+    var activePdfDocument by remember { mutableStateOf(null) }
+    var showCreateShelfDialog by remember { mutableStateOf(false) }
+    var shelfToRename by remember { mutableStateOf(null) }
+    var shelfToDelete by remember { mutableStateOf(null) }
+    var showAddToShelfDialog by remember { mutableStateOf(false) }
+    var showTagSelectionDialog by remember { mutableStateOf(false) }
+    var bookInfoDialogFor by remember { mutableStateOf(null) }
+    var bookEditDialogFor by remember { mutableStateOf(null) }
+    val snackbarHostState = remember { SnackbarHostState() }
+
+    fun projectState(
+        next: SharedReaderScreenState,
+        records: List = shelfRecords,
+        refs: List = shelfRefs
+    ): SharedReaderScreenState {
+        return libraryProjector.project(
+            SharedLibraryProjectionInput(
+                state = next,
+                booksFromStore = next.rawLibraryBooks,
+                shelfRecords = records,
+                shelfRefs = refs,
+                tags = next.allTags.ifEmpty { next.rawLibraryBooks.collectTags() }
+            )
+        )
+    }
+
+    fun persistSnapshot(projected: SharedReaderScreenState, records: List = shelfRecords, refs: List = shelfRefs) {
+        scope.launch(Dispatchers.IO) {
+            runCatching {
+                libraryDatabase.save(
+                    DesktopLibrarySnapshot(
+                        books = projected.rawLibraryBooks,
+                        shelfRecords = records,
+                        shelfRefs = refs,
+                        tags = projected.allTags
+                    )
+                )
+            }
+        }
+    }
+
+    fun replaceLibrary(
+        next: SharedReaderScreenState,
+        records: List = shelfRecords,
+        refs: List = shelfRefs
+    ) {
+        shelfRecords = records
+        shelfRefs = refs
+        val projected = projectState(next, records, refs)
+        state = projected
+        persistSnapshot(projected, records, refs)
+    }
+
+    fun updateState(next: SharedReaderScreenState) {
+        val projected = projectState(next)
+        state = projected
+        persistSnapshot(projected)
+    }
+
+    fun importFiles(files: List) {
+        updateState(state.withImportedFiles(files))
+    }
+
+    fun removeSelectedBooks() {
+        if (state.selectedBookIds.isEmpty()) return
+        val selected = state.selectedBookIds
+        replaceLibrary(
+            state.copy(
+                rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in selected },
+                selectedBookIds = emptySet(),
+                bannerMessage = BannerMessage("Removed ${selected.size} book(s) from the desktop library.")
+            ),
+            refs = shelfRefs.filterNot { it.bookId in selected }
+        )
+    }
+
+    fun createShelf(name: String) {
+        val trimmed = name.trim()
+        if (trimmed.isBlank()) return
+        val id = "shelf_${System.currentTimeMillis()}"
+        replaceLibrary(
+            state.copy(bannerMessage = BannerMessage("Created shelf \"$trimmed\".")),
+            records = shelfRecords + ShelfRecord(id = id, name = trimmed)
+        )
+    }
+
+    fun renameShelf(shelf: Shelf, name: String) {
+        val trimmed = name.trim()
+        if (trimmed.isBlank()) return
+        replaceLibrary(
+            state.copy(bannerMessage = BannerMessage("Renamed shelf to \"$trimmed\".")),
+            records = shelfRecords.map { if (it.id == shelf.id) it.copy(name = trimmed) else it }
+        )
+    }
+
+    fun deleteShelf(shelf: Shelf) {
+        replaceLibrary(
+            state.copy(bannerMessage = BannerMessage("Deleted shelf \"${shelf.name}\".")),
+            records = shelfRecords.filterNot { it.id == shelf.id },
+            refs = shelfRefs.filterNot { it.shelfId == shelf.id }
+        )
+    }
+
+    fun addSelectedBooksToShelf(shelfId: String) {
+        val selected = state.selectedBookIds
+        if (selected.isEmpty()) return
+        val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId }
+        val now = System.currentTimeMillis()
+        val additions = selected.mapNotNull { bookId ->
+            if (!existing.add(bookId to shelfId)) null else BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = now)
+        }
+        replaceLibrary(
+            state.copy(
+                selectedBookIds = emptySet(),
+                bannerMessage = BannerMessage("Added ${additions.size} book(s) to shelf.")
+            ),
+            refs = shelfRefs + additions
+        )
+    }
+
+    fun tagSelectedBooks(tagName: String) {
+        val selected = state.selectedBookIds
+        val trimmed = tagName.trim()
+        if (selected.isEmpty() || trimmed.isBlank()) return
+        val existingTag = state.allTags.firstOrNull { it.name.equals(trimmed, ignoreCase = true) }
+        val tag = existingTag ?: Tag(
+            id = trimmed.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_').ifBlank { "tag_${System.currentTimeMillis()}" },
+            name = trimmed,
+            color = 0xFF64B5F6.toInt()
+        )
+        val allTags = (state.allTags + tag).distinctBy { it.id }.sortedBy { it.name.lowercase() }
+        val books = state.rawLibraryBooks.map { book ->
+            if (book.id in selected && book.tags.none { it.id == tag.id }) {
+                book.copy(tags = (book.tags + tag).sortedBy { it.name.lowercase() })
+            } else {
+                book
+            }
+        }
+        replaceLibrary(
+            state.copy(
+                rawLibraryBooks = books,
+                allTags = allTags,
+                selectedBookIds = emptySet(),
+                bannerMessage = BannerMessage("Tagged ${selected.size} book(s) with \"${tag.name}\".")
+            )
+        )
+    }
+
+    fun updateBookMetadata(updated: BookItem) {
+        replaceLibrary(
+            state.copy(
+                rawLibraryBooks = state.rawLibraryBooks.map { if (it.id == updated.id) updated.copy(timestamp = System.currentTimeMillis()) else it },
+                allTags = (state.allTags + updated.tags).distinctBy { it.id }.sortedBy { it.name.lowercase() },
+                bannerMessage = BannerMessage("Updated \"${updated.cardTitleForMessage()}\".")
+            )
+        )
+    }
+
+    fun openReader(book: BookItem) {
+        if (book.type == FileType.PDF) {
+            val path = book.path
+            if (path.isNullOrBlank()) {
+                updateState(state.withBanner("This PDF does not have a local path.", isError = true))
+                return
+            }
+            activePdfDocument?.close()
+            activePdfDocument = null
+            val pdf = runCatching {
+                DesktopPdfium.load(File(path))
+            }.getOrElse { error ->
+                updateState(state.withBanner("Could not open PDF: ${error.message ?: "unknown error"}", isError = true))
+                return
+            }
+
+            activePdfDocument = pdf
+            activeReaderBookId = book.id
+            selectedTab = DesktopTab.READER
+            return
+        }
+
+        if (book.type != FileType.EPUB) {
+            updateState(state.withBanner("${book.type.name} reader support comes later. EPUB and PDF are available on desktop."))
+            return
+        }
+
+        val loadedBook = runCatching {
+            val path = book.path
+            if (path.isNullOrBlank()) {
+                SampleReaderBooks.desktopWelcomeBook()
+            } else {
+                DesktopEpubLoader.load(File(path))
+            }
+        }.getOrElse { error ->
+            updateState(state.withBanner("Could not open EPUB: ${error.message ?: "unknown error"}", isError = true))
+            return
+        }
+
+        activePdfDocument?.close()
+        activePdfDocument = null
+        readerSession = readerEngine.createSession(loadedBook, readerSession.reader.settings)
+        activeReaderBookId = book.id
+        selectedTab = DesktopTab.READER
+    }
+
+    fun importAndOpenEpub() {
+        val file = chooseEpubFile() ?: return
+        importFiles(listOf(file.toImportedBookFile()))
+        openReader(
+            BookItem(
+                id = file.absolutePath,
+                path = file.absolutePath,
+                type = FileType.EPUB,
+                displayName = file.name,
+                timestamp = System.currentTimeMillis(),
+                title = file.nameWithoutExtension,
+                fileSize = file.length()
+            )
+        )
+    }
+
+    fun importAndOpenPdf() {
+        val file = choosePdfFile() ?: return
+        importFiles(listOf(file.toImportedBookFile()))
+        openReader(
+            BookItem(
+                id = file.absolutePath,
+                path = file.absolutePath,
+                type = FileType.PDF,
+                displayName = file.name,
+                timestamp = System.currentTimeMillis(),
+                title = file.nameWithoutExtension,
+                fileSize = file.length()
+            )
+        )
+    }
+
+    DisposableEffect(Unit) {
+        onDispose {
+            activePdfDocument?.close()
+        }
+    }
+
+    LaunchedEffect(state.bannerMessage) {
+        state.bannerMessage?.let { banner ->
+            snackbarHostState.showSnackbar(banner.message)
+            updateState(state.reduce(AppAction.BannerDismissed))
+        }
+    }
+
+    MaterialTheme(
+        colorScheme = lightColorScheme(
+            primary = Color(0xFF006C4C),
+            secondary = Color(0xFF705D49),
+            tertiary = Color(0xFF9C4146),
+            surface = Color(0xFFFCFCF8),
+            surfaceVariant = Color(0xFFE5E8DE)
+        )
+    ) {
+        Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
+            Row(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(padding)
+            ) {
+                NavigationRail(containerColor = MaterialTheme.colorScheme.surface) {
+                    NavigationRailItem(
+                        selected = selectedTab == DesktopTab.HOME,
+                        onClick = { selectedTab = DesktopTab.HOME },
+                        icon = { Icon(Icons.Default.Home, contentDescription = null) },
+                        label = { Text("Home") }
+                    )
+                    NavigationRailItem(
+                        selected = selectedTab == DesktopTab.LIBRARY,
+                        onClick = { selectedTab = DesktopTab.LIBRARY },
+                        icon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) },
+                        label = { Text("Library") }
+                    )
+                    NavigationRailItem(
+                        selected = selectedTab == DesktopTab.SHELVES,
+                        onClick = { selectedTab = DesktopTab.SHELVES },
+                        icon = { Icon(Icons.Default.Folder, contentDescription = null) },
+                        label = { Text("Shelves") }
+                    )
+                    NavigationRailItem(
+                        selected = selectedTab == DesktopTab.READER,
+                        onClick = { selectedTab = DesktopTab.READER },
+                        icon = { Icon(Icons.AutoMirrored.Filled.MenuBook, contentDescription = null) },
+                        label = { Text("Reader") }
+                    )
+                    Spacer(Modifier.weight(1f))
+                    IconButton(
+                        onClick = {
+                            importFiles(chooseFiles())
+                        }
+                    ) {
+                        Icon(Icons.Default.ImportExport, contentDescription = "Import files")
+                    }
+                    IconButton(
+                        onClick = {
+                            updateState(state.reduce(AppAction.BannerShown(BannerMessage("Cloud sync is Android-only for now. Desktop sync will need a separate backend adapter."))))
+                        }
+                    ) {
+                        Icon(Icons.Default.Sync, contentDescription = "Sync")
+                    }
+                }
+
+                Box(Modifier.fillMaxSize()) {
+                    when (selectedTab) {
+                        DesktopTab.HOME -> HomeScreen(
+                            state = state,
+                            onImportBooks = {
+                                importFiles(chooseFiles())
+                            },
+                            onRead = ::openReader,
+                            onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) },
+                            onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) },
+                            onRemoveSelected = ::removeSelectedBooks,
+                            onShowBookInfo = { bookInfoDialogFor = it },
+                            onEditBook = { bookEditDialogFor = it },
+                            onTagSelectedBooks = { showTagSelectionDialog = true },
+                            onAddSelectedBooksToShelf = { showAddToShelfDialog = true }
+                        )
+
+                        DesktopTab.LIBRARY -> LibraryScreen(
+                            state = state,
+                            selectedLibraryTab = selectedLibraryTab,
+                            onLibraryTabChange = { selectedLibraryTab = it },
+                            onStateChange = ::updateState,
+                            onImportBooks = {
+                                importFiles(chooseFiles())
+                            },
+                            onRead = ::openReader,
+                            onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) },
+                            onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) },
+                            onRemoveSelected = ::removeSelectedBooks,
+                            onShowBookInfo = { bookInfoDialogFor = it },
+                            onEditBook = { bookEditDialogFor = it },
+                            onCreateShelf = { showCreateShelfDialog = true },
+                            onRenameShelf = { shelfToRename = it },
+                            onDeleteShelf = { shelfToDelete = it },
+                            onTagSelectedBooks = { showTagSelectionDialog = true },
+                            onAddSelectedBooksToShelf = { showAddToShelfDialog = true }
+                        )
+
+                        DesktopTab.SHELVES -> ShelvesScreen(
+                            shelves = state.shelves,
+                            onRead = ::openReader,
+                            onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) },
+                            selectedBookIds = state.selectedBookIds,
+                            onShowBookInfo = { bookInfoDialogFor = it },
+                            onEditBook = { bookEditDialogFor = it },
+                            onCreateShelf = { showCreateShelfDialog = true },
+                            onRenameShelf = { shelfToRename = it },
+                            onDeleteShelf = { shelfToDelete = it }
+                        )
+
+                        DesktopTab.READER -> {
+                            val pdfDocument = activePdfDocument
+                            if (pdfDocument != null) {
+                                PdfReaderScreen(
+                                    document = pdfDocument,
+                                    onOpenPdf = ::importAndOpenPdf,
+                                    onOpenEpub = ::importAndOpenEpub,
+                                    onProgressChange = { progress ->
+                                        activeReaderBookId?.let { bookId ->
+                                            updateState(
+                                                state.copy(rawLibraryBooks = state.rawLibraryBooks.map { book ->
+                                                    if (book.id == bookId) {
+                                                        book.copy(progressPercentage = progress, timestamp = System.currentTimeMillis())
+                                                    } else {
+                                                        book
+                                                    }
+                                                })
+                                            )
+                                        }
+                                    }
+                                )
+                            } else {
+                                ReaderScreen(
+                                    session = readerSession,
+                                    readerEngine = readerEngine,
+                                    onSessionChange = { updated ->
+                                        readerSession = updated
+                                        activeReaderBookId?.let { bookId ->
+                                            updateState(
+                                                state.copy(rawLibraryBooks = state.rawLibraryBooks.map { book ->
+                                                    if (book.id == bookId) {
+                                                        book.copy(progressPercentage = updated.reader.progress, timestamp = System.currentTimeMillis())
+                                                    } else {
+                                                        book
+                                                    }
+                                                })
+                                            )
+                                        }
+                                    },
+                                    onOpenEpub = ::importAndOpenEpub,
+                                    onOpenPdf = ::importAndOpenPdf,
+                                    webViewRuntimeState = webViewRuntimeState
+                                )
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        if (showCreateShelfDialog) {
+            TextInputDialog(
+                title = "Create shelf",
+                label = "Shelf name",
+                initialValue = "",
+                confirmLabel = "Create",
+                onDismiss = { showCreateShelfDialog = false },
+                onConfirm = { name ->
+                    createShelf(name)
+                    showCreateShelfDialog = false
+                }
+            )
+        }
+
+        shelfToRename?.let { shelf ->
+            TextInputDialog(
+                title = "Rename shelf",
+                label = "Shelf name",
+                initialValue = shelf.name,
+                confirmLabel = "Rename",
+                onDismiss = { shelfToRename = null },
+                onConfirm = { name ->
+                    renameShelf(shelf, name)
+                    shelfToRename = null
+                }
+            )
+        }
+
+        shelfToDelete?.let { shelf ->
+            ConfirmDialog(
+                title = "Delete shelf",
+                body = "Delete \"${shelf.name}\"? Books stay in your library.",
+                confirmLabel = "Delete",
+                onDismiss = { shelfToDelete = null },
+                onConfirm = {
+                    deleteShelf(shelf)
+                    shelfToDelete = null
+                }
+            )
+        }
+
+        if (showAddToShelfDialog) {
+            AddToShelfDialog(
+                shelves = state.shelves.filter { it.type == ShelfType.MANUAL && it.id != "unshelved" },
+                onDismiss = { showAddToShelfDialog = false },
+                onCreateShelf = {
+                    showAddToShelfDialog = false
+                    showCreateShelfDialog = true
+                },
+                onShelfSelected = { shelf ->
+                    addSelectedBooksToShelf(shelf.id)
+                    showAddToShelfDialog = false
+                }
+            )
+        }
+
+        if (showTagSelectionDialog) {
+            TextInputDialog(
+                title = "Tag selected books",
+                label = "Tag name",
+                initialValue = state.allTags.firstOrNull()?.name.orEmpty(),
+                confirmLabel = "Apply",
+                onDismiss = { showTagSelectionDialog = false },
+                onConfirm = { name ->
+                    tagSelectedBooks(name)
+                    showTagSelectionDialog = false
+                }
+            )
+        }
+
+        bookInfoDialogFor?.let { book ->
+            BookInfoDialog(
+                book = book,
+                onDismiss = { bookInfoDialogFor = null },
+                onEdit = {
+                    bookEditDialogFor = book
+                    bookInfoDialogFor = null
+                }
+            )
+        }
+
+        bookEditDialogFor?.let { book ->
+            BookEditDialog(
+                book = book,
+                knownTags = state.allTags,
+                onDismiss = { bookEditDialogFor = null },
+                onSave = { updated ->
+                    updateBookMetadata(updated)
+                    bookEditDialogFor = null
+                }
+            )
+        }
+    }
+}
+
+@Composable
+private fun TextInputDialog(
+    title: String,
+    label: String,
+    initialValue: String,
+    confirmLabel: String,
+    onDismiss: () -> Unit,
+    onConfirm: (String) -> Unit
+) {
+    var value by remember(initialValue) { mutableStateOf(initialValue) }
+    AlertDialog(
+        onDismissRequest = onDismiss,
+        title = { Text(title) },
+        text = {
+            OutlinedTextField(
+                value = value,
+                onValueChange = { value = it },
+                label = { Text(label) },
+                singleLine = true,
+                modifier = Modifier.fillMaxWidth()
+            )
+        },
+        confirmButton = {
+            TextButton(onClick = { onConfirm(value) }, enabled = value.isNotBlank()) {
+                Text(confirmLabel)
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onDismiss) {
+                Text("Cancel")
+            }
+        }
+    )
+}
+
+@Composable
+private fun ConfirmDialog(
+    title: String,
+    body: String,
+    confirmLabel: String,
+    onDismiss: () -> Unit,
+    onConfirm: () -> Unit
+) {
+    AlertDialog(
+        onDismissRequest = onDismiss,
+        title = { Text(title) },
+        text = { Text(body) },
+        confirmButton = {
+            TextButton(onClick = onConfirm) {
+                Text(confirmLabel)
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onDismiss) {
+                Text("Cancel")
+            }
+        }
+    )
+}
+
+@Composable
+private fun AddToShelfDialog(
+    shelves: List,
+    onDismiss: () -> Unit,
+    onCreateShelf: () -> Unit,
+    onShelfSelected: (Shelf) -> Unit
+) {
+    AlertDialog(
+        onDismissRequest = onDismiss,
+        title = { Text("Add to shelf") },
+        text = {
+            if (shelves.isEmpty()) {
+                Text("Create a shelf first, then add selected books to it.")
+            } else {
+                LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+                    items(shelves, key = { it.id }) { shelf ->
+                        Surface(
+                            shape = RoundedCornerShape(8.dp),
+                            color = MaterialTheme.colorScheme.surfaceVariant,
+                            modifier = Modifier.fillMaxWidth().clickable { onShelfSelected(shelf) }
+                        ) {
+                            Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+                                Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(20.dp))
+                                Spacer(Modifier.width(10.dp))
+                                Text(shelf.name, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
+                                Text("${shelf.bookCount}", color = MaterialTheme.colorScheme.onSurfaceVariant)
+                            }
+                        }
+                    }
+                }
+            }
+        },
+        confirmButton = {
+            TextButton(onClick = onCreateShelf) {
+                Text("New shelf")
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onDismiss) {
+                Text("Cancel")
+            }
+        }
+    )
+}
+
+@Composable
+private fun BookInfoDialog(
+    book: BookItem,
+    onDismiss: () -> Unit,
+    onEdit: () -> Unit
+) {
+    AlertDialog(
+        onDismissRequest = onDismiss,
+        title = { Text(book.cardTitleForMessage()) },
+        text = {
+            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                InfoRow("File", book.displayName)
+                InfoRow("Type", book.type.name)
+                InfoRow("Author", book.author.orEmpty().ifBlank { "Unknown" })
+                InfoRow("Path", book.path.orEmpty().ifBlank { "Not available" })
+                InfoRow("Size", book.fileSize.toReadableSize())
+                InfoRow("Progress", "${(book.progressPercentage ?: 0f).toInt()}%")
+                if (!book.seriesName.isNullOrBlank()) {
+                    InfoRow("Series", listOfNotNull(book.seriesName, book.seriesIndex?.toString()).joinToString(" #"))
+                }
+                if (book.tags.isNotEmpty()) {
+                    InfoRow("Tags", book.tags.joinToString { it.name })
+                }
+            }
+        },
+        confirmButton = {
+            TextButton(onClick = onEdit) {
+                Text("Edit")
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onDismiss) {
+                Text("Close")
+            }
+        }
+    )
+}
+
+@Composable
+private fun InfoRow(label: String, value: String) {
+    Column {
+        Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+        Text(value, style = MaterialTheme.typography.bodyMedium)
+    }
+}
+
+@Composable
+private fun BookEditDialog(
+    book: BookItem,
+    knownTags: List,
+    onDismiss: () -> Unit,
+    onSave: (BookItem) -> Unit
+) {
+    var title by remember(book.id) { mutableStateOf(book.title.orEmpty()) }
+    var author by remember(book.id) { mutableStateOf(book.author.orEmpty()) }
+    var seriesName by remember(book.id) { mutableStateOf(book.seriesName.orEmpty()) }
+    var seriesIndex by remember(book.id) { mutableStateOf(book.seriesIndex?.toString().orEmpty()) }
+    var tagText by remember(book.id) { mutableStateOf(book.tags.joinToString(", ") { it.name }) }
+
+    AlertDialog(
+        onDismissRequest = onDismiss,
+        title = { Text("Edit book") },
+        text = {
+            Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+                OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+                OutlinedTextField(value = author, onValueChange = { author = it }, label = { Text("Author") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+                OutlinedTextField(value = seriesName, onValueChange = { seriesName = it }, label = { Text("Series") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+                OutlinedTextField(value = seriesIndex, onValueChange = { seriesIndex = it }, label = { Text("Series index") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+                OutlinedTextField(value = tagText, onValueChange = { tagText = it }, label = { Text("Tags, comma separated") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+                if (knownTags.isNotEmpty()) {
+                    Text("Existing: ${knownTags.joinToString { it.name }}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+                }
+            }
+        },
+        confirmButton = {
+            TextButton(
+                onClick = {
+                    val parsedTags = tagText.split(',')
+                        .map { it.trim() }
+                        .filter { it.isNotBlank() }
+                        .distinctBy { it.lowercase() }
+                        .map { name ->
+                            knownTags.firstOrNull { it.name.equals(name, ignoreCase = true) }
+                                ?: Tag(
+                                    id = name.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_').ifBlank { "tag_${System.currentTimeMillis()}" },
+                                    name = name,
+                                    color = 0xFF64B5F6.toInt()
+                                )
+                        }
+                    onSave(
+                        book.copy(
+                            title = title.trim().ifBlank { null },
+                            author = author.trim().ifBlank { null },
+                            seriesName = seriesName.trim().ifBlank { null },
+                            seriesIndex = seriesIndex.toDoubleOrNull(),
+                            tags = parsedTags
+                        )
+                    )
+                }
+            ) {
+                Text("Save")
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onDismiss) {
+                Text("Cancel")
+            }
+        }
+    )
+}
+
+@Composable
+private fun HomeScreen(
+    state: SharedReaderScreenState,
+    onImportBooks: () -> Unit,
+    onRead: (BookItem) -> Unit,
+    onSelect: (String) -> Unit,
+    onClearSelection: () -> Unit,
+    onRemoveSelected: () -> Unit,
+    onShowBookInfo: (BookItem) -> Unit,
+    onEditBook: (BookItem) -> Unit,
+    onTagSelectedBooks: () -> Unit,
+    onAddSelectedBooksToShelf: () -> Unit
+) {
+    SharedHomeScreen(
+        state = state,
+        onImportBooks = onImportBooks,
+        onOpenBook = onRead,
+        onToggleSelection = onSelect,
+        onClearSelection = onClearSelection,
+        onRemoveSelected = onRemoveSelected,
+        onShowBookInfo = onShowBookInfo,
+        onEditBook = onEditBook,
+        onTagSelectedBooks = onTagSelectedBooks,
+        onAddSelectedBooksToShelf = onAddSelectedBooksToShelf
+    )
+}
+
+@Composable
+private fun LibraryScreen(
+    state: SharedReaderScreenState,
+    selectedLibraryTab: NonReaderLibraryTab,
+    onLibraryTabChange: (NonReaderLibraryTab) -> Unit,
+    onStateChange: (SharedReaderScreenState) -> Unit,
+    onImportBooks: () -> Unit,
+    onRead: (BookItem) -> Unit,
+    onSelect: (String) -> Unit,
+    onClearSelection: () -> Unit,
+    onRemoveSelected: () -> Unit,
+    onShowBookInfo: (BookItem) -> Unit,
+    onEditBook: (BookItem) -> Unit,
+    onCreateShelf: () -> Unit,
+    onRenameShelf: (Shelf) -> Unit,
+    onDeleteShelf: (Shelf) -> Unit,
+    onTagSelectedBooks: () -> Unit,
+    onAddSelectedBooksToShelf: () -> Unit
+) {
+    SharedLibraryScreen(
+        state = state,
+        selectedTab = selectedLibraryTab,
+        onTabChange = onLibraryTabChange,
+        onStateChange = onStateChange,
+        onImportBooks = onImportBooks,
+        onOpenBook = onRead,
+        onToggleSelection = onSelect,
+        onClearSelection = onClearSelection,
+        onRemoveSelected = onRemoveSelected,
+        onShowBookInfo = onShowBookInfo,
+        onEditBook = onEditBook,
+        onCreateShelf = onCreateShelf,
+        onRenameShelf = onRenameShelf,
+        onDeleteShelf = onDeleteShelf,
+        onTagSelectedBooks = onTagSelectedBooks,
+        onAddSelectedBooksToShelf = onAddSelectedBooksToShelf
+    )
+}
+
+@Composable
+private fun ShelvesScreen(
+    shelves: List,
+    selectedBookIds: Set,
+    onRead: (BookItem) -> Unit,
+    onSelect: (String) -> Unit,
+    onShowBookInfo: (BookItem) -> Unit,
+    onEditBook: (BookItem) -> Unit,
+    onCreateShelf: () -> Unit,
+    onRenameShelf: (Shelf) -> Unit,
+    onDeleteShelf: (Shelf) -> Unit
+) {
+    SharedShelvesScreen(
+        shelves = shelves,
+        selectedBookIds = selectedBookIds,
+        onOpenBook = onRead,
+        onToggleSelection = onSelect,
+        onShowBookInfo = onShowBookInfo,
+        onEditBook = onEditBook,
+        onCreateShelf = onCreateShelf,
+        onRenameShelf = onRenameShelf,
+        onDeleteShelf = onDeleteShelf
+    )
+}
+
+@Composable
+private fun PdfReaderScreen(
+    document: DesktopPdfDocument,
+    onOpenPdf: () -> Unit,
+    onOpenEpub: () -> Unit,
+    onProgressChange: (Float) -> Unit
+) {
+    var pageIndex by remember(document.path) { mutableStateOf(0) }
+    val zoomSpec = remember { PdfZoomSpec() }
+    var scale by remember(document.path) { mutableStateOf(zoomSpec.default) }
+    var searchQuery by remember(document.path) { mutableStateOf("") }
+    var activeSearchIndex by remember(document.path) { mutableStateOf(-1) }
+    var renderedPage by remember(document.path) { mutableStateOf(null) }
+    var renderError by remember(document.path) { mutableStateOf(null) }
+    var isRendering by remember(document.path) { mutableStateOf(false) }
+    var renderJob by remember(document.path) { mutableStateOf(null) }
+    var selectedTool by remember(document.path) { mutableStateOf(PdfInkTool.PEN) }
+    var selectedColor by remember(document.path) { mutableStateOf(SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb) }
+    var strokeWidth by remember(document.path) { mutableStateOf(SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth) }
+    var textDraft by remember(document.path) { mutableStateOf("") }
+    var pageCanvasSize by remember(document.path) { mutableStateOf(IntSize.Zero) }
+    var activeStroke by remember(document.path, pageIndex) { mutableStateOf>(emptyList()) }
+    val annotations = remember(document.path) { mutableStateListOf() }
+    val annotationFile = remember(document.path) { desktopPdfAnnotationFile(document.path) }
+
+    LaunchedEffect(document.path) {
+        annotations.clear()
+        if (annotationFile.exists()) {
+            annotations.addAll(
+                withContext(Dispatchers.IO) {
+                    SharedPdfAnnotationSerializer.decode(annotationFile.readText())
+                }
+            )
+        }
+    }
+
+    LaunchedEffect(document.path, annotations.size) {
+        val snapshot = annotations.toList()
+        withContext(Dispatchers.IO) {
+            runCatching {
+                annotationFile.parentFile?.mkdirs()
+                annotationFile.writeText(SharedPdfAnnotationSerializer.encode(snapshot))
+            }
+        }
+    }
+
+    fun applyTool(tool: PdfInkTool) {
+        selectedTool = tool
+        val config = SharedPdfAnnotationDefaults.configFor(tool)
+        selectedColor = config.colorArgb
+        strokeWidth = config.strokeWidth
+    }
+
+    val searchResults = remember(document.path, searchQuery) {
+        val normalized = searchQuery.trim()
+        if (normalized.isBlank()) {
+            emptyList()
+        } else {
+            document.textPages.mapIndexedNotNull { index, text ->
+                val matchIndex = text.indexOf(normalized, ignoreCase = true)
+                if (matchIndex < 0) {
+                    null
+                } else {
+                    ReaderPdfSearchResult(index, text.previewAround(matchIndex, normalized.length))
+                }
+            }
+        }
+    }
+
+    fun goToPage(target: Int) {
+        pageIndex = target.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0))
+        activeStroke = emptyList()
+    }
+
+    fun goToSearchResult(targetIndex: Int) {
+        if (searchResults.isEmpty()) return
+        val normalizedIndex = when {
+            targetIndex < 0 -> searchResults.lastIndex
+            targetIndex > searchResults.lastIndex -> 0
+            else -> targetIndex
+        }
+        activeSearchIndex = normalizedIndex
+        goToPage(searchResults[normalizedIndex].pageIndex)
+    }
+
+    LaunchedEffect(document.path, pageIndex) {
+        onProgressChange(((pageIndex + 1).toFloat() / document.pageCount.coerceAtLeast(1)) * 100f)
+    }
+
+    LaunchedEffect(document.path, pageIndex, scale) {
+        renderJob?.cancel()
+        renderJob = launch {
+            delay(90)
+            isRendering = true
+            renderError = null
+            val safeScale = zoomSpec.safeRenderScale(
+                document.pageSizes[pageIndex].width,
+                document.pageSizes[pageIndex].height,
+                scale
+            )
+            val result = withContext(Dispatchers.IO) {
+                runCatching {
+                    DesktopPdfium.renderPage(document, pageIndex, safeScale)
+                }
+            }
+            renderedPage = result.getOrNull()
+            renderError = result.exceptionOrNull()?.message
+                ?: if (renderedPage == null) "Failed to render page." else null
+            isRendering = false
+        }
+    }
+
+    ScreenScaffold(
+        title = document.title,
+        subtitle = "PDF - Page ${pageIndex + 1} of ${document.pageCount}",
+        trailing = {
+            Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+                TextButton(onClick = onOpenPdf) {
+                    Text("Open PDF")
+                }
+                TextButton(onClick = onOpenEpub) {
+                    Text("Open EPUB")
+                }
+                Text("${(((pageIndex + 1).toFloat() / document.pageCount.coerceAtLeast(1)) * 100f).toInt()}%")
+            }
+        }
+    ) {
+        Row(
+            Modifier
+                .fillMaxSize()
+                .onPreviewKeyEvent { event ->
+                    if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+                    when {
+                        event.key == Key.DirectionLeft -> {
+                            goToPage(pageIndex - 1)
+                            true
+                        }
+                        event.key == Key.DirectionRight -> {
+                            goToPage(pageIndex + 1)
+                            true
+                        }
+                        event.isCtrlPressed && event.key == Key.Equals -> {
+                            scale = zoomSpec.clamp(scale + 0.15f)
+                            true
+                        }
+                        event.isCtrlPressed && event.key == Key.Minus -> {
+                            scale = zoomSpec.clamp(scale - 0.15f)
+                            true
+                        }
+                        else -> false
+                    }
+                }
+                .focusable(),
+            horizontalArrangement = Arrangement.spacedBy(16.dp)
+        ) {
+            Surface(
+                modifier = Modifier
+                    .width(300.dp)
+                    .fillMaxHeight(),
+                color = MaterialTheme.colorScheme.surfaceVariant,
+                shape = RoundedCornerShape(8.dp)
+            ) {
+                LazyColumn(
+                    modifier = Modifier.padding(12.dp),
+                    verticalArrangement = Arrangement.spacedBy(8.dp)
+                ) {
+                    item {
+                        Text("Pages", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+                    }
+                    item {
+                        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+                            TextButton(onClick = { goToPage(pageIndex - 1) }, enabled = pageIndex > 0) {
+                                Text("Prev")
+                            }
+                            TextButton(onClick = { goToPage(pageIndex + 1) }, enabled = pageIndex < document.pageCount - 1) {
+                                Text("Next")
+                            }
+                        }
+                    }
+                    item {
+                        Text("Zoom", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
+                        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+                            IconButton(onClick = { scale = zoomSpec.clamp(scale - 0.15f) }) {
+                                Icon(Icons.Default.ZoomOut, contentDescription = "Zoom out")
+                            }
+                            Text("${(scale * 100).toInt()}%", modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
+                            IconButton(onClick = { scale = zoomSpec.clamp(scale + 0.15f) }) {
+                                Icon(Icons.Default.ZoomIn, contentDescription = "Zoom in")
+                            }
+                        }
+                        Slider(
+                            value = scale,
+                            onValueChange = { scale = zoomSpec.clamp(it) },
+                            valueRange = zoomSpec.min..zoomSpec.max
+                        )
+                    }
+                    item {
+                        HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+                        Text("Annotations", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+                        PdfAnnotationToolDock(
+                            selectedTool = selectedTool,
+                            selectedColor = selectedColor,
+                            strokeWidth = strokeWidth,
+                            onToolSelected = ::applyTool,
+                            onColorSelected = { selectedColor = it },
+                            onStrokeWidthChange = { strokeWidth = it },
+                            onUndo = {
+                                annotations.indexOfLast { it.pageIndex == pageIndex }.takeIf { it >= 0 }?.let {
+                                    annotations.removeAt(it)
+                                }
+                            },
+                            onClearPage = {
+                                annotations.removeAll { it.pageIndex == pageIndex }
+                            }
+                        )
+                    }
+                    if (selectedTool == PdfInkTool.TEXT) {
+                        item {
+                            OutlinedTextField(
+                                value = textDraft,
+                                onValueChange = { textDraft = it },
+                                label = { Text("Text note") },
+                                minLines = 2,
+                                modifier = Modifier.fillMaxWidth()
+                            )
+                            Text(
+                                "Click the page to place the note.",
+                                color = MaterialTheme.colorScheme.onSurfaceVariant,
+                                style = MaterialTheme.typography.bodySmall
+                            )
+                        }
+                    }
+                    item {
+                        HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+                        Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+                        Spacer(Modifier.height(8.dp))
+                        OutlinedTextField(
+                            value = searchQuery,
+                            onValueChange = {
+                                searchQuery = it
+                                activeSearchIndex = -1
+                            },
+                            label = { Text("Find in PDF") },
+                            singleLine = true,
+                            modifier = Modifier.fillMaxWidth()
+                        )
+                    }
+                    if (searchQuery.isNotBlank()) {
+                        item {
+                            Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+                                Text(
+                                    if (searchResults.isEmpty()) "No matches" else "${(activeSearchIndex + 1).coerceAtLeast(0)} of ${searchResults.size}",
+                                    color = MaterialTheme.colorScheme.onSurfaceVariant,
+                                    modifier = Modifier.weight(1f)
+                                )
+                                TextButton(onClick = { goToSearchResult(activeSearchIndex - 1) }, enabled = searchResults.isNotEmpty()) {
+                                    Text("Prev")
+                                }
+                                TextButton(onClick = { goToSearchResult(activeSearchIndex + 1) }, enabled = searchResults.isNotEmpty()) {
+                                    Text("Next")
+                                }
+                            }
+                        }
+                    }
+                    items(searchResults, key = { "${it.pageIndex}_${it.preview}" }) { result ->
+                        Surface(
+                            color = if (result.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
+                            shape = RoundedCornerShape(6.dp),
+                            modifier = Modifier.fillMaxWidth().clickable {
+                                activeSearchIndex = searchResults.indexOf(result)
+                                goToPage(result.pageIndex)
+                            }
+                        ) {
+                            Column(modifier = Modifier.padding(8.dp)) {
+                                Text("Page ${result.pageIndex + 1}", fontWeight = FontWeight.SemiBold)
+                                Text(result.preview, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis)
+                            }
+                        }
+                    }
+                }
+            }
+
+            Box(
+                modifier = Modifier
+                    .weight(1f)
+                    .fillMaxHeight()
+                    .background(Color(0xFFE8E5DC), RoundedCornerShape(8.dp))
+                    .verticalScroll(rememberScrollState())
+                    .padding(24.dp),
+                contentAlignment = Alignment.TopCenter
+            ) {
+                when {
+                    isRendering -> CircularProgressIndicator(modifier = Modifier.padding(48.dp))
+                    renderError != null -> Text(renderError ?: "Failed to render page.", color = MaterialTheme.colorScheme.error)
+                    renderedPage != null -> {
+                        val pageRender = renderedPage!!
+                        Box(
+                            modifier = Modifier
+                                .size(pageRender.width.dp, pageRender.height.dp)
+                                .onSizeChanged { pageCanvasSize = it }
+                                .pointerInput(pageIndex, selectedTool, selectedColor, strokeWidth, textDraft) {
+                                    if (selectedTool == PdfInkTool.TEXT) {
+                                        detectTapGestures(
+                                            onTap = { start ->
+                                                val text = textDraft.trim()
+                                                if (text.isNotEmpty()) {
+                                                    val bounds = pageBoundsFromPoint(start, pageCanvasSize)
+                                                    annotations.add(
+                                                        SharedPdfAnnotation(
+                                                            id = "text_${System.currentTimeMillis()}",
+                                                            pageIndex = pageIndex,
+                                                            kind = PdfAnnotationKind.TEXT,
+                                                            tool = PdfInkTool.TEXT,
+                                                            bounds = bounds,
+                                                            text = text,
+                                                            colorArgb = selectedColor,
+                                                            fontSize = 18f,
+                                                            createdAt = System.currentTimeMillis()
+                                                        )
+                                                    )
+                                                    textDraft = ""
+                                                }
+                                            }
+                                        )
+                                    } else {
+                                        detectDragGestures(
+                                            onDragStart = { start ->
+                                                if (selectedTool != PdfInkTool.ERASER) {
+                                                activeStroke = listOf(start.toPdfPoint(pageCanvasSize))
+                                            }
+                                            },
+                                            onDrag = { change, _ ->
+                                                if (selectedTool == PdfInkTool.ERASER) {
+                                                    val point = change.position
+                                                    annotations.removeAll { it.pageIndex == pageIndex && it.hitTest(point, pageCanvasSize) }
+                                                } else {
+                                                    activeStroke = activeStroke + change.position.toPdfPoint(pageCanvasSize)
+                                                }
+                                            },
+                                            onDragEnd = {
+                                                if (activeStroke.size > 1) {
+                                                    annotations.add(
+                                                        SharedPdfAnnotation(
+                                                            id = "ink_${System.currentTimeMillis()}",
+                                                            pageIndex = pageIndex,
+                                                            kind = PdfAnnotationKind.INK,
+                                                            tool = selectedTool,
+                                                            points = activeStroke,
+                                                            colorArgb = selectedColor,
+                                                            strokeWidth = strokeWidth,
+                                                            createdAt = System.currentTimeMillis()
+                                                        )
+                                                    )
+                                                }
+                                                activeStroke = emptyList()
+                                            },
+                                            onDragCancel = { activeStroke = emptyList() }
+                                        )
+                                    }
+                                }
+                        ) {
+                            Image(
+                                bitmap = pageRender.image,
+                                contentDescription = "PDF page ${pageIndex + 1}"
+                            )
+                            PdfAnnotationOverlay(
+                                annotations = annotations.filter { it.pageIndex == pageIndex },
+                                activeStroke = activeStroke,
+                                canvasSize = pageCanvasSize
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun PdfAnnotationToolDock(
+    selectedTool: PdfInkTool,
+    selectedColor: Int,
+    strokeWidth: Float,
+    onToolSelected: (PdfInkTool) -> Unit,
+    onColorSelected: (Int) -> Unit,
+    onStrokeWidthChange: (Float) -> Unit,
+    onUndo: () -> Unit,
+    onClearPage: () -> Unit
+) {
+    Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+            PdfToolButton(PdfInkTool.PEN, selectedTool, onToolSelected)
+            PdfToolButton(PdfInkTool.HIGHLIGHTER, selectedTool, onToolSelected)
+            PdfToolButton(PdfInkTool.PENCIL, selectedTool, onToolSelected)
+            PdfToolButton(PdfInkTool.FOUNTAIN_PEN, selectedTool, onToolSelected)
+        }
+        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+            PdfToolButton(PdfInkTool.HIGHLIGHTER_ROUND, selectedTool, onToolSelected)
+            PdfToolButton(PdfInkTool.TEXT, selectedTool, onToolSelected)
+            PdfToolButton(PdfInkTool.ERASER, selectedTool, onToolSelected)
+            IconButton(onClick = onUndo) {
+                Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = "Undo annotation")
+            }
+            IconButton(onClick = onClearPage) {
+                Icon(Icons.Default.Delete, contentDescription = "Clear page annotations")
+            }
+        }
+        Text("Color", style = MaterialTheme.typography.labelLarge)
+        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+            val palette = if (selectedTool == PdfInkTool.HIGHLIGHTER || selectedTool == PdfInkTool.HIGHLIGHTER_ROUND) {
+                SharedPdfAnnotationDefaults.highlighterPalette
+            } else {
+                SharedPdfAnnotationDefaults.penPalette
+            }
+            palette.forEach { argb ->
+                Surface(
+                    modifier = Modifier
+                        .size(28.dp)
+                        .border(
+                            width = if (argb == selectedColor) 3.dp else 1.dp,
+                            color = if (argb == selectedColor) MaterialTheme.colorScheme.primary else Color.Black.copy(alpha = 0.25f),
+                            shape = RoundedCornerShape(14.dp)
+                        )
+                        .clickable { onColorSelected(argb) },
+                    color = Color(argb),
+                    shape = RoundedCornerShape(14.dp),
+                    content = {}
+                )
+            }
+        }
+        Text("Thickness ${String.format("%.1f", strokeWidth)}", style = MaterialTheme.typography.labelLarge)
+        Slider(
+            value = strokeWidth,
+            onValueChange = onStrokeWidthChange,
+            valueRange = 1f..28f
+        )
+    }
+}
+
+@Composable
+private fun PdfToolButton(
+    tool: PdfInkTool,
+    selectedTool: PdfInkTool,
+    onToolSelected: (PdfInkTool) -> Unit
+) {
+    val selected = tool == selectedTool
+    val icon = when (tool) {
+        PdfInkTool.PEN -> Icons.Default.Draw
+        PdfInkTool.HIGHLIGHTER -> Icons.Default.Brush
+        PdfInkTool.HIGHLIGHTER_ROUND -> Icons.Default.FormatColorText
+        PdfInkTool.ERASER -> Icons.Default.Remove
+        PdfInkTool.FOUNTAIN_PEN -> Icons.Default.EditNote
+        PdfInkTool.PENCIL -> Icons.Default.Brush
+        PdfInkTool.TEXT -> Icons.Default.TextFields
+    }
+    Surface(
+        color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
+        shape = RoundedCornerShape(8.dp)
+    ) {
+        IconButton(onClick = { onToolSelected(tool) }) {
+            Icon(icon, contentDescription = tool.name.lowercase().replace('_', ' '))
+        }
+    }
+}
+
+@Composable
+private fun PdfAnnotationOverlay(
+    annotations: List,
+    activeStroke: List,
+    canvasSize: IntSize
+) {
+    Canvas(Modifier.fillMaxSize()) {
+        annotations.forEach { annotation ->
+            when (annotation.kind) {
+                PdfAnnotationKind.INK -> {
+                    if (annotation.points.size > 1) {
+                        drawPath(
+                            path = annotation.points.toPath(canvasSize),
+                            color = Color(annotation.colorArgb),
+                            style = Stroke(
+                                width = annotation.strokeWidth,
+                                cap = StrokeCap.Round
+                            )
+                        )
+                    }
+                }
+                PdfAnnotationKind.TEXT -> {
+                    val bounds = annotation.bounds ?: return@forEach
+                    drawRect(
+                        color = Color(annotation.backgroundArgb).copy(alpha = 0.18f),
+                        topLeft = Offset(bounds.left * canvasSize.width, bounds.top * canvasSize.height),
+                        size = androidx.compose.ui.geometry.Size(
+                            (bounds.right - bounds.left) * canvasSize.width,
+                            (bounds.bottom - bounds.top) * canvasSize.height
+                        )
+                    )
+                }
+            }
+        }
+        if (activeStroke.size > 1) {
+            drawPath(
+                path = activeStroke.toPath(canvasSize),
+                color = Color(0xFF1976D2),
+                style = Stroke(width = 2.5f, cap = StrokeCap.Round)
+            )
+        }
+    }
+    annotations.filter { it.kind == PdfAnnotationKind.TEXT && it.text.isNotBlank() }.forEach { annotation ->
+        val bounds = annotation.bounds ?: return@forEach
+        Text(
+            text = annotation.text,
+            color = Color(annotation.colorArgb),
+            fontSize = annotation.fontSize.sp,
+            fontWeight = if (annotation.isBold) FontWeight.Bold else FontWeight.Normal,
+            modifier = Modifier
+                .padding(
+                    start = (bounds.left * canvasSize.width).dp,
+                    top = (bounds.top * canvasSize.height).dp
+                )
+                .background(Color(annotation.backgroundArgb).copy(alpha = 0.18f), RoundedCornerShape(4.dp))
+                .padding(horizontal = 6.dp, vertical = 4.dp)
+        )
+    }
+}
+
+private fun Offset.toPdfPoint(size: IntSize): PdfPagePoint {
+    val width = size.width.coerceAtLeast(1)
+    val height = size.height.coerceAtLeast(1)
+    return PdfPagePoint(
+        x = (x / width).coerceIn(0f, 1f),
+        y = (y / height).coerceIn(0f, 1f),
+        timestamp = System.currentTimeMillis()
+    )
+}
+
+private fun List.toPath(size: IntSize): Path {
+    val path = Path()
+    forEachIndexed { index, point ->
+        val x = point.x * size.width
+        val y = point.y * size.height
+        if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
+    }
+    return path
+}
+
+private fun pageBoundsFromPoint(point: Offset, size: IntSize): PdfPageBounds {
+    val width = size.width.coerceAtLeast(1)
+    val height = size.height.coerceAtLeast(1)
+    val left = (point.x / width).coerceIn(0f, 0.92f)
+    val top = (point.y / height).coerceIn(0f, 0.95f)
+    return PdfPageBounds(
+        left = left,
+        top = top,
+        right = (left + 0.32f).coerceAtMost(1f),
+        bottom = (top + 0.08f).coerceAtMost(1f)
+    )
+}
+
+private fun SharedPdfAnnotation.hitTest(point: Offset, size: IntSize): Boolean {
+    return when (kind) {
+        PdfAnnotationKind.TEXT -> {
+            val bounds = bounds ?: return false
+            val rect = Rect(
+                bounds.left * size.width,
+                bounds.top * size.height,
+                bounds.right * size.width,
+                bounds.bottom * size.height
+            )
+            rect.contains(point)
+        }
+        PdfAnnotationKind.INK -> {
+            points.any {
+                abs((it.x * size.width) - point.x) <= strokeWidth + 8f &&
+                    abs((it.y * size.height) - point.y) <= strokeWidth + 8f
+            }
+        }
+    }
+}
+
+private data class ReaderPdfSearchResult(
+    val pageIndex: Int,
+    val preview: String
+)
+
+private fun desktopPdfAnnotationFile(documentPath: String): File {
+    val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
+        ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
+    val safeName = documentPath.hashCode().toString().replace("-", "n")
+    return File(baseDir, "Episteme/annotations/pdf_$safeName.json")
+}
+
+@Composable
+private fun ReaderScreen(
+    session: ReaderSessionState,
+    readerEngine: ReaderEngine,
+    onSessionChange: (ReaderSessionState) -> Unit,
+    onOpenEpub: () -> Unit,
+    onOpenPdf: () -> Unit,
+    webViewRuntimeState: DesktopWebViewRuntimeState
+) {
+    val readerState = session.reader
+    val page = readerState.currentPage
+    val settings = readerState.settings
+    val background = if (settings.darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5)
+    val foreground = if (settings.darkMode) Color(0xFFE7E3D8) else Color(0xFF24231F)
+    val searchHighlight = if (settings.darkMode) Color(0xFF675A00) else Color(0xFFFFE36E)
+    val textAlign = settings.textAlign.toComposeTextAlign()
+    val fontFamily = settings.fontFamily.toComposeFontFamily()
+    val verticalListState = rememberLazyListState()
+
+    LaunchedEffect(settings.readingMode, page?.chapterIndex) {
+        if (settings.readingMode == ReaderReadingMode.VERTICAL && page != null) {
+            verticalListState.animateScrollToItem(page.chapterIndex)
+        }
+    }
+
+    ScreenScaffold(
+        title = readerState.book.title,
+        subtitle = listOfNotNull(readerState.book.author, page?.chapterTitle).joinToString(" - "),
+        trailing = {
+            Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+                TextButton(onClick = onOpenEpub) {
+                    Text("Open EPUB")
+                }
+                TextButton(onClick = onOpenPdf) {
+                    Text("Open PDF")
+                }
+                Text("${readerState.progress.toInt()}%")
+                IconButton(onClick = { onSessionChange(readerEngine.toggleBookmark(session)) }) {
+                    Icon(
+                        if (session.currentBookmark == null) Icons.Default.BookmarkBorder else Icons.Default.Bookmark,
+                        contentDescription = "Bookmark"
+                    )
+                }
+                TextButton(
+                    onClick = {
+                        onSessionChange(session.copy(reader = readerState.copy(settings = settings.copy(darkMode = !settings.darkMode))))
+                    }
+                ) {
+                    Text(if (settings.darkMode) "Light" else "Dark")
+                }
+            }
+        }
+    ) {
+        Row(
+            horizontalArrangement = Arrangement.spacedBy(16.dp),
+            modifier = Modifier
+                .fillMaxSize()
+                .onPreviewKeyEvent { event ->
+                    if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+                    when {
+                        event.key == Key.DirectionRight || event.key == Key.PageDown -> {
+                            onSessionChange(readerEngine.next(session))
+                            true
+                        }
+
+                        event.key == Key.DirectionLeft || event.key == Key.PageUp -> {
+                            onSessionChange(readerEngine.previous(session))
+                            true
+                        }
+
+                        event.key == Key.MoveHome -> {
+                            onSessionChange(readerEngine.goToPage(session, 0))
+                            true
+                        }
+
+                        event.key == Key.MoveEnd -> {
+                            onSessionChange(readerEngine.goToPage(session, readerState.pages.lastIndex))
+                            true
+                        }
+
+                        event.isCtrlPressed && event.key == Key.G -> {
+                            onSessionChange(readerEngine.nextSearchResult(session))
+                            true
+                        }
+
+                        else -> false
+                    }
+                }
+                .focusable()
+        ) {
+            ReaderSidebar(
+                session = session,
+                onSearchChange = { onSessionChange(readerEngine.search(session, it)) },
+                onPreviousSearchResult = { onSessionChange(readerEngine.previousSearchResult(session)) },
+                onNextSearchResult = { onSessionChange(readerEngine.nextSearchResult(session)) },
+                onGoToChapter = { onSessionChange(readerEngine.goToChapter(session, it)) },
+                onGoToPage = { onSessionChange(readerEngine.goToPage(session, it)) }
+            )
+
+            Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+                ReaderSettingsBar(
+                    session = session,
+                    readerEngine = readerEngine,
+                    onSessionChange = onSessionChange
+                )
+
+                Surface(
+                    color = background,
+                    shape = RoundedCornerShape(8.dp),
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .weight(1f)
+                ) {
+                    val html = if (settings.readingMode == ReaderReadingMode.VERTICAL) {
+                        ReaderHtmlDocumentBuilder.verticalDocument(
+                            book = readerState.book,
+                            settings = settings,
+                            searchQuery = session.searchQuery
+                        )
+                    } else {
+                        ReaderHtmlDocumentBuilder.pageDocument(
+                            book = readerState.book,
+                            page = page,
+                            settings = settings,
+                            searchQuery = session.searchQuery
+                        )
+                    }
+                    if (webViewRuntimeState.initialized) {
+                        DesktopEpubWebView(
+                            html = html,
+                            modifier = Modifier.fillMaxSize()
+                        )
+                    } else {
+                        DesktopWebViewRuntimeIndicator(
+                            state = webViewRuntimeState,
+                            modifier = Modifier.fillMaxSize()
+                        )
+                    }
+                }
+
+                Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                    Slider(
+                        value = if (readerState.pages.size <= 1) 0f else readerState.currentPageIndex.toFloat() / readerState.pages.lastIndex,
+                        onValueChange = { progress -> onSessionChange(readerEngine.goToProgress(session, progress)) },
+                        enabled = readerState.pages.size > 1
+                    )
+                    Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+                        Button(
+                            enabled = readerState.canGoPrevious,
+                            onClick = { onSessionChange(readerEngine.previous(session)) }
+                        ) {
+                            Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = null)
+                            Text("Previous")
+                        }
+                        Spacer(Modifier.weight(1f))
+                        Text(
+                            if (settings.readingMode == ReaderReadingMode.VERTICAL) {
+                                "Continuous mode - page ${readerState.currentPageIndex + 1} of ${readerState.pages.size}"
+                            } else {
+                                "Page ${readerState.currentPageIndex + 1} of ${readerState.pages.size}"
+                            }
+                        )
+                        Spacer(Modifier.weight(1f))
+                        Button(
+                            enabled = readerState.canGoNext,
+                            onClick = { onSessionChange(readerEngine.next(session)) }
+                        ) {
+                            Text("Next")
+                            Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = null)
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun DesktopEpubWebView(
+    html: String,
+    modifier: Modifier = Modifier
+) {
+    key(html) {
+        val state = rememberWebViewStateWithHTMLData(
+            data = html,
+            baseUrl = null,
+            encoding = "utf-8",
+            mimeType = "text/html",
+            historyUrl = null
+        )
+
+        Box(modifier = modifier) {
+            WebView(
+                state = state,
+                modifier = Modifier.fillMaxSize(),
+                captureBackPresses = false
+            )
+
+            val loadingState = state.loadingState
+            if (loadingState is LoadingState.Loading) {
+                LinearProgressIndicator(
+                    progress = { loadingState.progress },
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun DesktopWebViewRuntimeIndicator(
+    state: DesktopWebViewRuntimeState,
+    modifier: Modifier = Modifier
+) {
+    val message = when {
+        state.errorMessage != null -> "Embedded webview could not start: ${state.errorMessage}"
+        state.restartRequired -> "Embedded webview installed. Restart Episteme to finish setup."
+        state.downloadProgress >= 0f -> "Downloading embedded webview ${state.downloadProgress.toInt()}%"
+        else -> "Preparing embedded webview..."
+    }
+
+    Box(
+        modifier = modifier.padding(32.dp),
+        contentAlignment = Alignment.Center
+    ) {
+        Column(
+            horizontalAlignment = Alignment.CenterHorizontally,
+            verticalArrangement = Arrangement.spacedBy(12.dp)
+        ) {
+            if (state.errorMessage == null && !state.restartRequired) {
+                CircularProgressIndicator()
+            }
+            Text(
+                text = message,
+                color = if (state.errorMessage == null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error,
+                textAlign = TextAlign.Center
+            )
+            if (state.downloadProgress in 0f..100f) {
+                LinearProgressIndicator(
+                    progress = { state.downloadProgress / 100f },
+                    modifier = Modifier.width(260.dp)
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun ReaderSettingsBar(
+    session: ReaderSessionState,
+    readerEngine: ReaderEngine,
+    onSessionChange: (ReaderSessionState) -> Unit
+) {
+    val settings = session.reader.settings
+    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+            FilterChip(
+                selected = settings.readingMode == ReaderReadingMode.PAGINATED,
+                onClick = {
+                    onSessionChange(readerEngine.updateSettings(session, settings.copy(readingMode = ReaderReadingMode.PAGINATED)))
+                },
+                label = { Text("Pages") }
+            )
+            FilterChip(
+                selected = settings.readingMode == ReaderReadingMode.VERTICAL,
+                onClick = {
+                    onSessionChange(readerEngine.updateSettings(session, settings.copy(readingMode = ReaderReadingMode.VERTICAL)))
+                },
+                label = { Text("Vertical") }
+            )
+            FilterChip(
+                selected = settings.textAlign == SharedReaderTextAlign.START,
+                onClick = { onSessionChange(readerEngine.updateSettings(session, settings.copy(textAlign = SharedReaderTextAlign.START))) },
+                label = { Text("Left") }
+            )
+            FilterChip(
+                selected = settings.textAlign == SharedReaderTextAlign.JUSTIFY,
+                onClick = { onSessionChange(readerEngine.updateSettings(session, settings.copy(textAlign = SharedReaderTextAlign.JUSTIFY))) },
+                label = { Text("Justify") }
+            )
+            FilterChip(
+                selected = settings.textAlign == SharedReaderTextAlign.CENTER,
+                onClick = { onSessionChange(readerEngine.updateSettings(session, settings.copy(textAlign = SharedReaderTextAlign.CENTER))) },
+                label = { Text("Center") }
+            )
+            listOf("Default", "Serif", "Sans", "Mono").forEach { family ->
+                FilterChip(
+                    selected = settings.fontFamily == family,
+                    onClick = { onSessionChange(readerEngine.updateSettings(session, settings.copy(fontFamily = family))) },
+                    label = { Text(family) }
+                )
+            }
+        }
+
+        Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
+            Text("Font ${settings.fontSize}")
+            Slider(
+                value = settings.fontSize.toFloat(),
+                onValueChange = { value ->
+                    onSessionChange(readerEngine.updateSettings(session, settings.copy(fontSize = value.toInt())))
+                },
+                valueRange = 14f..30f,
+                modifier = Modifier.width(140.dp)
+            )
+            Text("Margin ${settings.margin}")
+            Slider(
+                value = settings.margin.toFloat(),
+                onValueChange = { value ->
+                    onSessionChange(readerEngine.updateSettings(session, settings.copy(margin = value.toInt())))
+                },
+                valueRange = 16f..112f,
+                modifier = Modifier.width(140.dp)
+            )
+            Text("Spacing ${String.format("%.2f", settings.lineSpacing)}")
+            Slider(
+                value = settings.lineSpacing,
+                onValueChange = { value ->
+                    onSessionChange(readerEngine.updateSettings(session, settings.copy(lineSpacing = value)))
+                },
+                valueRange = 1.1f..2.1f,
+                modifier = Modifier.width(140.dp)
+            )
+            Text("Width ${settings.pageWidth}")
+            Slider(
+                value = settings.pageWidth.toFloat(),
+                onValueChange = { value ->
+                    onSessionChange(readerEngine.updateSettings(session, settings.copy(pageWidth = value.toInt())))
+                },
+                valueRange = 520f..1100f,
+                modifier = Modifier.width(140.dp)
+            )
+        }
+    }
+}
+
+private fun String.highlightQuery(query: String, color: Color): AnnotatedString {
+    val normalized = query.trim()
+    if (normalized.length < 2) return AnnotatedString(this)
+
+    return buildAnnotatedString {
+        append(this@highlightQuery)
+        var startIndex = 0
+        while (startIndex < this@highlightQuery.length) {
+            val index = this@highlightQuery.indexOf(normalized, startIndex, ignoreCase = true)
+            if (index < 0) break
+            addStyle(
+                style = SpanStyle(background = color),
+                start = index,
+                end = index + normalized.length
+            )
+            startIndex = index + normalized.length
+        }
+    }
+}
+
+@Composable
+private fun SemanticBlockView(
+    block: SemanticBlock,
+    foreground: Color,
+    searchQuery: String,
+    searchHighlight: Color,
+    fallbackTextAlign: TextAlign,
+    fallbackFontFamily: FontFamily,
+    settings: com.aryan.reader.shared.reader.ReaderSettings
+) {
+    val modifier = Modifier
+        .fillMaxWidth()
+        .padding(
+            start = block.style.blockStyle.margin.left.safeDp(),
+            top = block.style.blockStyle.margin.top.safeDp(),
+            end = block.style.blockStyle.margin.right.safeDp(),
+            bottom = block.style.blockStyle.margin.bottom.safeDp()
+        )
+        .then(
+            if (block.style.blockStyle.backgroundColor.isSpecified) {
+                Modifier.background(block.style.blockStyle.backgroundColor, RoundedCornerShape(4.dp))
+            } else {
+                Modifier
+            }
+        )
+        .padding(
+            start = block.style.blockStyle.padding.left.safeDp(),
+            top = block.style.blockStyle.padding.top.safeDp(),
+            end = block.style.blockStyle.padding.right.safeDp(),
+            bottom = block.style.blockStyle.padding.bottom.safeDp()
+        )
+
+    when (block) {
+        is SemanticHeader -> {
+            Text(
+                text = block.toAnnotatedString(searchQuery, searchHighlight),
+                color = foreground,
+                modifier = modifier,
+                textAlign = block.style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign,
+                style = MaterialTheme.typography.bodyLarge.copy(
+                    fontSize = (settings.fontSize * headerScale(block.level)).sp,
+                    lineHeight = (settings.fontSize * headerScale(block.level) * settings.lineSpacing).sp,
+                    fontWeight = FontWeight.Bold,
+                    fontFamily = fallbackFontFamily
+                )
+            )
+        }
+
+        is SemanticParagraph -> SemanticTextView(block, modifier, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
+        is SemanticListItem -> SemanticTextView(block, modifier, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
+        is SemanticTextBlock -> SemanticTextView(block, modifier, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
+
+        is SemanticList -> {
+            Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
+                block.items.forEachIndexed { index, item ->
+                    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+                        Text(if (block.isOrdered) "${index + 1}." else "•", color = foreground)
+                        SemanticTextView(
+                            block = item,
+                            modifier = Modifier.weight(1f),
+                            foreground = foreground,
+                            searchQuery = searchQuery,
+                            searchHighlight = searchHighlight,
+                            fallbackTextAlign = fallbackTextAlign,
+                            fallbackFontFamily = fallbackFontFamily,
+                            settings = settings
+                        )
+                    }
+                }
+            }
+        }
+
+        is SemanticFlexContainer -> {
+            Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
+                block.children.forEach {
+                    SemanticBlockView(it, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
+                }
+            }
+        }
+
+        is SemanticWrappingBlock -> {
+            Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
+                SemanticBlockView(block.floatedImage, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
+                block.paragraphsToWrap.forEach {
+                    SemanticBlockView(it, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
+                }
+            }
+        }
+
+        is SemanticTable -> {
+            Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
+                block.rows.forEach { row ->
+                    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+                        row.forEach { cell ->
+                            Column(modifier = Modifier.weight(cell.colspan.toFloat().coerceAtLeast(1f))) {
+                                cell.content.forEach {
+                                    SemanticBlockView(it, foreground, searchQuery, searchHighlight, fallbackTextAlign, fallbackFontFamily, settings)
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        is SemanticImage -> {
+            Text(
+                text = block.altText?.takeIf { it.isNotBlank() } ?: block.path.substringAfterLast('/').substringAfterLast('\\'),
+                color = foreground.copy(alpha = 0.7f),
+                modifier = modifier,
+                style = MaterialTheme.typography.bodySmall
+            )
+        }
+
+        is SemanticMath -> {
+            Text(
+                text = block.altText ?: "Equation",
+                color = foreground,
+                modifier = modifier,
+                style = MaterialTheme.typography.bodyMedium
+            )
+        }
+
+        is SemanticSpacer -> Spacer(modifier.height(if (block.isExplicitLineBreak) 8.dp else 16.dp))
+    }
+}
+
+@Composable
+private fun SemanticTextView(
+    block: SemanticTextBlock,
+    modifier: Modifier,
+    foreground: Color,
+    searchQuery: String,
+    searchHighlight: Color,
+    fallbackTextAlign: TextAlign,
+    fallbackFontFamily: FontFamily,
+    settings: com.aryan.reader.shared.reader.ReaderSettings
+) {
+    Text(
+        text = block.toAnnotatedString(searchQuery, searchHighlight),
+        color = foreground,
+        modifier = modifier,
+        textAlign = block.style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: fallbackTextAlign,
+        style = MaterialTheme.typography.bodyLarge.copy(
+            fontSize = settings.fontSize.sp,
+            lineHeight = (settings.fontSize * settings.lineSpacing).sp,
+            fontFamily = fallbackFontFamily
+        )
+    )
+}
+
+private fun SemanticTextBlock.toAnnotatedString(query: String, highlightColor: Color): AnnotatedString {
+    val normalized = query.trim()
+    return buildAnnotatedString {
+        append(text)
+        spans.forEach { span ->
+            val start = span.start.coerceIn(0, text.length)
+            val end = span.end.coerceIn(start, text.length)
+            if (start < end) {
+                addStyle(span.style.spanStyle, start, end)
+            }
+        }
+        if (normalized.length >= 2) {
+            var startIndex = 0
+            while (startIndex < text.length) {
+                val index = text.indexOf(normalized, startIndex, ignoreCase = true)
+                if (index < 0) break
+                addStyle(SpanStyle(background = highlightColor), index, index + normalized.length)
+                startIndex = index + normalized.length
+            }
+        }
+    }
+}
+
+private fun headerScale(level: Int): Float {
+    return when (level) {
+        1 -> 1.5f
+        2 -> 1.35f
+        3 -> 1.2f
+        4 -> 1.1f
+        else -> 1f
+    }
+}
+
+private fun Dp.safeDp(): Dp = if (isSpecified) this else 0.dp
+
+private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign {
+    return when (this) {
+        SharedReaderTextAlign.START -> TextAlign.Start
+        SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify
+        SharedReaderTextAlign.CENTER -> TextAlign.Center
+    }
+}
+
+private fun String.toComposeFontFamily(): FontFamily {
+    return when (this) {
+        "Serif" -> FontFamily.Serif
+        "Sans" -> FontFamily.SansSerif
+        "Mono" -> FontFamily.Monospace
+        else -> FontFamily.Default
+    }
+}
+
+@Composable
+private fun ReaderSidebar(
+    session: ReaderSessionState,
+    onSearchChange: (String) -> Unit,
+    onPreviousSearchResult: () -> Unit,
+    onNextSearchResult: () -> Unit,
+    onGoToChapter: (Int) -> Unit,
+    onGoToPage: (Int) -> Unit
+) {
+    Surface(
+        modifier = Modifier
+            .width(280.dp)
+            .fillMaxHeight(),
+        color = MaterialTheme.colorScheme.surfaceVariant,
+        shape = RoundedCornerShape(8.dp)
+    ) {
+        LazyColumn(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            item {
+                Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+            }
+            items(session.reader.book.chapters.indices.toList()) { index ->
+                val chapter = session.reader.book.chapters[index]
+                val selected = session.reader.currentPage?.chapterIndex == index
+                Surface(
+                    color = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
+                    shape = RoundedCornerShape(6.dp),
+                    modifier = Modifier.fillMaxWidth().clickable { onGoToChapter(index) }
+                ) {
+                    Text(
+                        chapter.title,
+                        modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
+                        maxLines = 2,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                }
+            }
+
+            item {
+                HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+                Text("Bookmarks", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+            }
+            if (session.bookmarks.isEmpty()) {
+                item {
+                    Text("No bookmarks yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
+                }
+            } else {
+                items(session.bookmarks, key = { it.id }) { bookmark ->
+                    Surface(
+                        color = MaterialTheme.colorScheme.surface,
+                        shape = RoundedCornerShape(6.dp),
+                        modifier = Modifier.fillMaxWidth().clickable { onGoToPage(bookmark.pageIndex) }
+                    ) {
+                        Column(modifier = Modifier.padding(8.dp)) {
+                            Text(bookmark.chapterTitle, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+                            Text(bookmark.preview, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
+                        }
+                    }
+                }
+            }
+
+            item {
+                HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+                Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+                Spacer(Modifier.height(8.dp))
+                OutlinedTextField(
+                    value = session.searchQuery,
+                    onValueChange = onSearchChange,
+                    label = { Text("Find in book") },
+                    singleLine = true,
+                    modifier = Modifier.fillMaxWidth()
+                )
+                if (session.searchQuery.isNotBlank() && session.searchResults.isNotEmpty()) {
+                    Row(
+                        modifier = Modifier.fillMaxWidth(),
+                        horizontalArrangement = Arrangement.spacedBy(8.dp),
+                        verticalAlignment = Alignment.CenterVertically
+                    ) {
+                        Text(
+                            "${session.activeSearchResultIndex + 1} of ${session.searchResults.size}",
+                            color = MaterialTheme.colorScheme.onSurfaceVariant,
+                            modifier = Modifier.weight(1f)
+                        )
+                        TextButton(onClick = onPreviousSearchResult) {
+                            Text("Prev")
+                        }
+                        TextButton(onClick = onNextSearchResult) {
+                            Text("Next")
+                        }
+                    }
+                }
+            }
+            if (session.searchQuery.isNotBlank() && session.searchResults.isEmpty()) {
+                item {
+                    Text("No matches", color = MaterialTheme.colorScheme.onSurfaceVariant)
+                }
+            } else {
+                items(session.searchResults, key = { "${it.pageIndex}_${it.preview}" }) { result ->
+                    Surface(
+                        color = MaterialTheme.colorScheme.surface,
+                        shape = RoundedCornerShape(6.dp),
+                        modifier = Modifier.fillMaxWidth().clickable { onGoToPage(result.pageIndex) }
+                    ) {
+                        Column(modifier = Modifier.padding(8.dp)) {
+                            Text("Page ${result.pageIndex + 1} - ${result.chapterTitle}", fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+                            Text(result.preview, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis)
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun ScreenScaffold(
+    title: String,
+    subtitle: String,
+    trailing: @Composable () -> Unit = {},
+    content: @Composable () -> Unit
+) {
+    Column(
+        modifier = Modifier
+            .fillMaxSize()
+            .padding(24.dp),
+        verticalArrangement = Arrangement.spacedBy(18.dp)
+    ) {
+        Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+            Column(modifier = Modifier.weight(1f)) {
+                Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
+                Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+            }
+            trailing()
+        }
+        content()
+    }
+}
+
+private fun chooseFiles(): List {
+    val dialog = FileDialog(null as Frame?, "Import books", FileDialog.LOAD).apply {
+        isMultipleMode = true
+        isVisible = true
+    }
+    return dialog.files.orEmpty().map { it.toImportedBookFile() }
+}
+
+private fun chooseEpubFile(): File? {
+    val dialog = FileDialog(null as Frame?, "Open EPUB", FileDialog.LOAD).apply {
+        file = "*.epub"
+        isVisible = true
+    }
+    val directory = dialog.directory ?: return null
+    val file = dialog.file ?: return null
+    return File(directory, file)
+}
+
+private fun choosePdfFile(): File? {
+    val dialog = FileDialog(null as Frame?, "Open PDF", FileDialog.LOAD).apply {
+        file = "*.pdf"
+        isVisible = true
+    }
+    val directory = dialog.directory ?: return null
+    val file = dialog.file ?: return null
+    return File(directory, file)
+}
+
+private fun SharedReaderScreenState.withBanner(message: String, isError: Boolean = false): SharedReaderScreenState {
+    return reduce(AppAction.BannerShown(BannerMessage(message, isError = isError)))
+}
+
+private fun List.collectTags(): List {
+    return flatMap { it.tags }.distinctBy { it.id }.sortedBy { it.name.lowercase() }
+}
+
+private fun BookItem.cardTitleForMessage(): String {
+    return title?.takeIf { it.isNotBlank() } ?: displayName
+}
+
+private fun Long.toReadableSize(): String {
+    if (this <= 0L) return "Unknown"
+    val units = listOf("B", "KB", "MB", "GB", "TB")
+    var value = this.toDouble()
+    var unitIndex = 0
+    while (value >= 1024.0 && unitIndex < units.lastIndex) {
+        value /= 1024.0
+        unitIndex += 1
+    }
+    return if (unitIndex == 0) {
+        "${this} ${units[unitIndex]}"
+    } else {
+        "${String.format("%.1f", value)} ${units[unitIndex]}"
+    }
+}
+
+private fun File.toImportedBookFile(): ImportedBookFile {
+    return ImportedBookFile(
+        name = name,
+        uriString = null,
+        localPath = absolutePath,
+        size = length()
+    )
+}
+
+private fun String.previewAround(index: Int, queryLength: Int): String {
+    val start = (index - 70).coerceAtLeast(0)
+    val end = (index + queryLength + 100).coerceAtMost(length)
+    val prefix = if (start > 0) "..." else ""
+    val suffix = if (end < length) "..." else ""
+    return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 785219b..c991cf3 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -21,7 +21,7 @@ androidxAnnotationJvm = "1.9.1"
 androidxTestRunner = "1.6.2"
 material3WindowSizeClassAndroid = "1.3.2"
 credentials = "1.5.0"
-
+composeMultiplatform = "1.8.2"
 
 [libraries]
 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -59,8 +59,10 @@ androidx-credentials = { group = "androidx.credentials", name = "credentials", v
 [plugins]
 android-application = { id = "com.android.application", version.ref = "agp" }
 kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
 kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
 kotlin-ksp = { id = "com.google.devtools.ksp", version = "2.3.2" }
+compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
 
 # Add plugins required by pdfiumandroid
 android-library = { id = "com.android.library", version.ref = "agp" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 565b6a3..37f309d 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -17,8 +17,12 @@ dependencyResolutionManagement {
         google()
         mavenCentral()
         maven("https://jitpack.io")
+        maven("https://jogamp.org/deployment/maven")
+        maven("https.jitpack.io")
     }
 }
 
 rootProject.name = "Reader"
-include(":app")
\ No newline at end of file
+include(":app")
+include(":shared")
+include(":desktopApp")
diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts
new file mode 100644
index 0000000..c03c9ba
--- /dev/null
+++ b/shared/build.gradle.kts
@@ -0,0 +1,49 @@
+plugins {
+    alias(libs.plugins.kotlin.multiplatform)
+    id("com.android.library")
+    alias(libs.plugins.kotlin.compose)
+    alias(libs.plugins.compose.multiplatform)
+    id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
+}
+
+kotlin {
+    androidTarget()
+    jvm("desktop")
+    jvmToolchain(21)
+
+    sourceSets {
+        val commonMain by getting
+        val androidMain by getting
+        val desktopMain by getting
+        val readerJvmMain by creating {
+            dependsOn(commonMain)
+            dependencies {
+                implementation("org.jsoup:jsoup:1.17.2")
+            }
+        }
+        androidMain.dependsOn(readerJvmMain)
+        desktopMain.dependsOn(readerJvmMain)
+
+        commonMain.dependencies {
+            implementation(compose.foundation)
+            implementation(compose.material3)
+            implementation(compose.materialIconsExtended)
+            implementation(compose.ui)
+            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
+            implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+            implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.7.3")
+        }
+        commonTest.dependencies {
+            implementation(kotlin("test"))
+        }
+    }
+}
+
+android {
+    namespace = "com.aryan.reader.shared"
+    compileSdk = 36
+
+    defaultConfig {
+        minSdk = 26
+    }
+}
diff --git a/shared/src/androidMain/kotlin/com/aryan/reader/shared/Platform.android.kt b/shared/src/androidMain/kotlin/com/aryan/reader/shared/Platform.android.kt
new file mode 100644
index 0000000..d1f14a5
--- /dev/null
+++ b/shared/src/androidMain/kotlin/com/aryan/reader/shared/Platform.android.kt
@@ -0,0 +1,3 @@
+package com.aryan.reader.shared
+
+actual fun currentTimestamp(): Long = System.currentTimeMillis()
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt
similarity index 92%
rename from app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt
rename to shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt
index 625d9cd..a1b9ccd 100644
--- a/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt
@@ -21,7 +21,6 @@ package com.aryan.reader.paginatedreader
 
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.isSpecified
-import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.text.ParagraphStyle
 import androidx.compose.ui.text.SpanStyle
 import androidx.compose.ui.text.font.FontStyle
@@ -35,23 +34,45 @@ import androidx.compose.ui.unit.TextUnit
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.em
 import androidx.compose.ui.unit.isSpecified
-import timber.log.Timber
-import java.io.File
-import java.util.regex.Pattern
 import kotlin.math.roundToInt
 
 private const val IMPORTANT_SPECIFICITY_BOOST = 10_000
+
+private object ReaderCssLog {
+    fun d(@Suppress("UNUSED_PARAMETER") message: String) = Unit
+    fun w(@Suppress("UNUSED_PARAMETER") message: String) = Unit
+    fun w(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
+    fun e(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
+}
+
 private fun Color.luminance(): Float {
     if (!this.isSpecified) return 0f
     return (0.299f * red + 0.587f * green + 0.114f * blue)
 }
 
+private fun resolveCssRelativePath(cssPath: String, rawSrc: String): String {
+    if (rawSrc.startsWith("/") || rawSrc.contains("://")) return rawSrc
+    val normalizedBase = cssPath.replace('\\', '/').substringBeforeLast('/', "")
+    val parts = ArrayDeque()
+    (if (normalizedBase.isBlank()) rawSrc else "$normalizedBase/$rawSrc")
+        .replace('\\', '/')
+        .split('/')
+        .forEach { part ->
+            when (part) {
+                "", "." -> Unit
+                ".." -> if (parts.isNotEmpty()) parts.removeLast()
+                else -> parts.addLast(part)
+            }
+        }
+    return parts.joinToString("/")
+}
+
 object CssParser {
     private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL)
     private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex()
-    private val ID_SELECTOR_PATTERN = Pattern.compile("#[^\\s,]+")
-    private val CLASS_ATTRIBUTE_SELECTOR_PATTERN = Pattern.compile("\\.[^\\s,]+|\\[[^]]+]|:(?!:)[^\\s,]+")
-    private val TYPE_PSEUDO_ELEMENT_SELECTOR_PATTERN = Pattern.compile("(?
-            Timber.d("CssParser: Found a @font-face block. Parsing its properties.")
+            ReaderCssLog.d("CssParser: Found a @font-face block. Parsing its properties.")
             val properties = match.groupValues[1]
             parseFontFace(properties, cssPath)?.let { fontFaces.add(it) }
         }
@@ -269,10 +269,10 @@ object CssParser {
 
         val fontFamily = propsMap["font-family"]?.removeSurrounding("\"")?.removeSurrounding("'")?.lowercase()
         val srcString = propsMap["src"]
-        Timber.d("Parsing font-face for family: $fontFamily. Raw src string: $srcString")
+        ReaderCssLog.d("Parsing font-face for family: $fontFamily. Raw src string: $srcString")
 
         if (fontFamily == null || srcString == null) {
-            Timber.w("Incomplete @font-face rule: missing font-family or src.")
+            ReaderCssLog.w("Incomplete @font-face rule: missing font-family or src.")
             return null
         }
 
@@ -280,25 +280,25 @@ object CssParser {
 
         val sources = srcString.split(Regex(",(?=\\s*url\\()")).mapNotNull { part ->
             val trimmedPart = part.trim()
-            Timber.d("Processing src part: '$trimmedPart'")
+            ReaderCssLog.d("Processing src part: '$trimmedPart'")
 
             urlWithFormatRegex.find(trimmedPart)?.let {
-                Timber.d("Matched url with format(). URL: ${it.groupValues[2]}, Format: ${it.groupValues[4]}")
+                ReaderCssLog.d("Matched url with format(). URL: ${it.groupValues[2]}, Format: ${it.groupValues[4]}")
                 FontSource(url = it.groupValues[2], format = it.groupValues[4].lowercase().removeSurrounding("'"))
             } ?: URL_REGEX.find(trimmedPart)?.let {
                 val url = it.groupValues[2]
-                Timber.d("Matched url() only. URL: '$url'")
+                ReaderCssLog.d("Matched url() only. URL: '$url'")
                 val format = when {
                     url.startsWith("data:", ignoreCase = true) -> {
                         val mediaType = url.substringAfter("data:").substringBefore(';')
-                        Timber.d("Data URI detected. Media type: '$mediaType'")
+                        ReaderCssLog.d("Data URI detected. Media type: '$mediaType'")
                         when {
                             mediaType.contains("opentype") -> "opentype"
                             mediaType.contains("truetype") -> "truetype"
                             mediaType.contains("woff2") -> "woff2"
                             mediaType.contains("woff") -> "woff"
                             else -> {
-                                Timber.w("Unknown data URI media type: $mediaType")
+                                ReaderCssLog.w("Unknown data URI media type: $mediaType")
                                 null
                             }
                         }
@@ -308,11 +308,11 @@ object CssParser {
                     url.endsWith(".otf", ignoreCase = true) -> "opentype"
                     url.endsWith(".ttf", ignoreCase = true) -> "truetype"
                     else -> {
-                        Timber.w("Could not determine format from URL: $url")
+                        ReaderCssLog.w("Could not determine format from URL: $url")
                         null
                     }
                 }
-                Timber.d("Determined format: '$format'")
+                ReaderCssLog.d("Determined format: '$format'")
                 if (format != null) {
                     FontSource(url = url, format = format)
                 } else {
@@ -322,7 +322,7 @@ object CssParser {
         }
 
         if (sources.isEmpty()) {
-            Timber.w("Could not parse any valid source from @font-face src: $srcString")
+            ReaderCssLog.w("Could not parse any valid source from @font-face src: $srcString")
             return null
         }
 
@@ -337,14 +337,13 @@ object CssParser {
         }!!
 
         val rawSrc = preferredSource.url
-        Timber.d("Selected font source for '$fontFamily': '${preferredSource.url}' with format '${preferredSource.format}'")
+        ReaderCssLog.d("Selected font source for '$fontFamily': '${preferredSource.url}' with format '${preferredSource.format}'")
 
         val finalSrc = if (cssPath != null && !rawSrc.startsWith("data:")) {
             try {
-                val cssParentDir = File(cssPath).parent ?: ""
-                File(cssParentDir, rawSrc).normalize().path
+                resolveCssRelativePath(cssPath, rawSrc)
             } catch (e: Exception) {
-                Timber.e(e, "Could not resolve font path for src '$rawSrc' in css '$cssPath'")
+                ReaderCssLog.e(e, "Could not resolve font path for src '$rawSrc' in css '$cssPath'")
                 rawSrc // Fallback to the raw path on error
             }
         } else {
@@ -1038,10 +1037,20 @@ object CssParser {
                         val r = (colorLong and 0xF00) shr 8
                         val g = (colorLong and 0x0F0) shr 4
                         val b = colorLong and 0x00F
-                        Color(Color(0xFF000000 or ((r * 17) shl 16) or ((g * 17) shl 8) or (b * 17)).toArgb())
+                        Color((r * 17).toInt(), (g * 17).toInt(), (b * 17).toInt(), 255)
                     }
-                    6 -> Color(Color(0xFF000000 or colorLong).toArgb()) // #RRGGBB
-                    8 -> Color(Color(colorLong).toArgb()) // #AARRGGBB
+                    6 -> Color(
+                        ((colorLong shr 16) and 0xFF).toInt(),
+                        ((colorLong shr 8) and 0xFF).toInt(),
+                        (colorLong and 0xFF).toInt(),
+                        255
+                    ) // #RRGGBB
+                    8 -> Color(
+                        ((colorLong shr 16) and 0xFF).toInt(),
+                        ((colorLong shr 8) and 0xFF).toInt(),
+                        (colorLong and 0xFF).toInt(),
+                        ((colorLong shr 24) and 0xFF).toInt()
+                    ) // #AARRGGBB
                     else -> null
                 }
             }
@@ -1086,4 +1095,4 @@ object CssParser {
             else -> BoxBorders()
         }
     }
-}
\ No newline at end of file
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/FontFamilyMapper.kt b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/FontFamilyMapper.kt
new file mode 100644
index 0000000..63e6d13
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/FontFamilyMapper.kt
@@ -0,0 +1,39 @@
+package com.aryan.reader.paginatedreader
+
+import androidx.compose.ui.text.font.FontFamily
+
+/**
+ * Shared mapper for generic CSS font-family names.
+ *
+ * Platform-specific font loaders can still resolve embedded/custom font files and add their own
+ * FontFamily instances, but generic CSS families should behave consistently everywhere.
+ */
+object FontFamilyMapper {
+    private val genericFontMap = mapOf(
+        "serif" to FontFamily.Serif,
+        "sans-serif" to FontFamily.SansSerif,
+        "monospace" to FontFamily.Monospace,
+        "cursive" to FontFamily.Cursive,
+        "default" to FontFamily.Default,
+        "system-ui" to FontFamily.Default,
+        "ui-sans-serif" to FontFamily.Default,
+        "ui-serif" to FontFamily.Default,
+        "ui-monospace" to FontFamily.Default,
+        "ui-rounded" to FontFamily.Default
+    )
+
+    fun nameToFontFamily(name: String): FontFamily? {
+        return genericFontMap[name.trim().lowercase()]
+    }
+
+    fun fontFamilyToName(fontFamily: FontFamily): String? {
+        return when (fontFamily) {
+            FontFamily.Serif -> "serif"
+            FontFamily.SansSerif -> "sans-serif"
+            FontFamily.Monospace -> "monospace"
+            FontFamily.Cursive -> "cursive"
+            FontFamily.Default -> "default"
+            else -> null
+        }
+    }
+}
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderData.kt b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/PaginatedReaderData.kt
similarity index 100%
rename from app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderData.kt
rename to shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/PaginatedReaderData.kt
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/SemanticModel.kt b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/SemanticModel.kt
similarity index 100%
rename from app/src/main/java/com/aryan/reader/paginatedreader/SemanticModel.kt
rename to shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/SemanticModel.kt
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/StyleUtils.kt b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/StyleUtils.kt
similarity index 100%
rename from app/src/main/java/com/aryan/reader/paginatedreader/StyleUtils.kt
rename to shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/StyleUtils.kt
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/UserAgentStylesheet.kt b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/UserAgentStylesheet.kt
similarity index 100%
rename from app/src/main/java/com/aryan/reader/paginatedreader/UserAgentStylesheet.kt
rename to shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/UserAgentStylesheet.kt
diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/serialization/ComposeTypeSerializers.kt b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/serialization/ComposeTypeSerializers.kt
similarity index 100%
rename from app/src/main/java/com/aryan/reader/paginatedreader/serialization/ComposeTypeSerializers.kt
rename to shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/serialization/ComposeTypeSerializers.kt
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt
new file mode 100644
index 0000000..63cf1c7
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt
@@ -0,0 +1,38 @@
+package com.aryan.reader.shared
+
+sealed interface LibraryAction {
+    data class SearchChanged(val query: String) : LibraryAction
+    data class SortChanged(val sortOrder: SortOrder) : LibraryAction
+    data class FiltersChanged(val filters: LibraryFilters) : LibraryAction
+    data class BookSelectionToggled(val bookId: String) : LibraryAction
+    data object SelectionCleared : LibraryAction
+    data class ShelfSelectionToggled(val shelfId: String) : LibraryAction
+    data object ShelfSelectionCleared : LibraryAction
+    data class LibraryPageChanged(val page: Int) : LibraryAction
+    data class RecentLimitChanged(val limit: Int) : LibraryAction
+}
+
+sealed interface ReaderAction {
+    data object NextPage : ReaderAction
+    data object PreviousPage : ReaderAction
+    data class GoToPage(val pageIndex: Int) : ReaderAction
+    data class GoToProgress(val progress: Float) : ReaderAction
+    data class GoToChapter(val chapterIndex: Int) : ReaderAction
+    data class SearchChanged(val query: String) : ReaderAction
+    data object NextSearchResult : ReaderAction
+    data object PreviousSearchResult : ReaderAction
+    data object ToggleBookmark : ReaderAction
+    data class RenderModeChanged(val renderMode: RenderMode) : ReaderAction
+    data class ThemeChanged(val theme: ReaderTheme) : ReaderAction
+    data class FormatChanged(val settings: FormatSettings) : ReaderAction
+}
+
+sealed interface AppAction {
+    data class BannerShown(val message: BannerMessage) : AppAction
+    data object BannerDismissed : AppAction
+    data class NavigationRequested(val event: NavigationEvent) : AppAction
+    data class AppThemeChanged(val mode: AppThemeMode) : AppAction
+    data class AppContrastChanged(val option: AppContrastOption) : AppAction
+    data class SyncEnabledChanged(val enabled: Boolean) : AppAction
+    data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt
new file mode 100644
index 0000000..70f298d
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt
@@ -0,0 +1,122 @@
+package com.aryan.reader.shared
+
+import androidx.compose.ui.graphics.Color
+
+data class BannerMessage(
+    val message: String,
+    val isError: Boolean = false,
+    val isPersistent: Boolean = false
+)
+
+data class ImportResult(
+    val uriString: String,
+    val bookId: String,
+    val type: FileType,
+    val bundleId: String? = null
+)
+
+data class UserData(
+    val uid: String,
+    val displayName: String?,
+    val photoUrl: String?,
+    val email: String?
+)
+
+data class NavigationEvent(
+    val route: String,
+    val bookId: String? = null,
+    val uriString: String? = null
+)
+
+enum class AppThemeMode {
+    SYSTEM,
+    LIGHT,
+    DARK
+}
+
+enum class AppContrastOption(val value: Double) {
+    STANDARD(0.0),
+    MEDIUM(0.5),
+    HIGH(1.0)
+}
+
+data class CustomAppTheme(
+    val id: String,
+    val name: String,
+    val seedColor: Color
+)
+
+data class DeviceItem(
+    val deviceId: String,
+    val deviceName: String,
+    val lastSeenEpochMillis: Long?
+)
+
+data class DeviceLimitReachedState(
+    val isLimitReached: Boolean = false,
+    val registeredDevices: List = emptyList()
+)
+
+data class SharedReaderScreenState(
+    val selectedBookId: String? = null,
+    val selectedUriString: String? = null,
+    val selectedFileType: FileType? = null,
+    val isLoading: Boolean = false,
+    val errorMessage: String? = null,
+    val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
+    val sortOrder: SortOrder = SortOrder.RECENT,
+    val shelves: List = emptyList(),
+    val viewingShelfId: String? = null,
+    val isAddingBooksToShelf: Boolean = false,
+    val showCreateShelfDialog: Boolean = false,
+    val mainScreenStartPage: Int = 0,
+    val libraryScreenStartPage: Int = 0,
+    val showRenameShelfDialogFor: String? = null,
+    val showDeleteShelfDialogFor: String? = null,
+    val addBooksSource: AddBooksSource = AddBooksSource.UNSHELVED,
+    val booksSelectedForAdding: Set = emptySet(),
+    val booksAvailableForAdding: List = emptyList(),
+    val selectedBookIds: Set = emptySet(),
+    val selectedShelfIds: Set = emptySet(),
+    val currentUser: UserData? = null,
+    val isAuthMenuExpanded: Boolean = false,
+    val isProUser: Boolean = false,
+    val credits: Int = 0,
+    val isSyncEnabled: Boolean = false,
+    val isFolderSyncEnabled: Boolean = false,
+    val bannerMessage: BannerMessage? = null,
+    val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(),
+    val isReplacingDevice: Boolean = false,
+    val isRequestingDrivePermission: Boolean = false,
+    val downloadingBookIds: Set = emptySet(),
+    val uploadingBookIds: Set = emptySet(),
+    val syncedFolders: List = emptyList(),
+    val lastFolderScanTime: Long? = null,
+    val hasUnreadFeedback: Boolean = false,
+    val searchQuery: String = "",
+    val isSearchActive: Boolean = false,
+    val isRefreshing: Boolean = false,
+    val reflowProgress: Float? = null,
+    val recentBooks: List = emptyList(),
+    val libraryBooks: List = emptyList(),
+    val rawLibraryBooks: List = emptyList(),
+    val pinnedHomeBookIds: Set = emptySet(),
+    val pinnedLibraryBookIds: Set = emptySet(),
+    val libraryFilters: LibraryFilters = LibraryFilters(),
+    val recentFilesLimit: Int = 0,
+    val isTabsEnabled: Boolean = false,
+    val openTabIds: List = emptyList(),
+    val openTabs: List = emptyList(),
+    val activeTabBookId: String? = null,
+    val showExternalFileSavePromptFor: String? = null,
+    val externalFileBehavior: String = "ASK",
+    val useStrictFileFilter: Boolean = false,
+    val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
+    val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
+    val appTextDimFactorLight: Float = 1.0f,
+    val appTextDimFactorDark: Float = 1.0f,
+    val appSeedColor: Color? = null,
+    val customAppThemes: List = emptyList(),
+    val allTags: List = emptyList(),
+    val showTagSelectionDialogFor: Set = emptySet()
+)
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt
new file mode 100644
index 0000000..414ccea
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt
@@ -0,0 +1,139 @@
+package com.aryan.reader.shared
+
+enum class FileType {
+    PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, UNKNOWN
+}
+
+val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
+
+val EPUB_READER_FILE_TYPES = setOf(
+    FileType.EPUB,
+    FileType.MOBI,
+    FileType.MD,
+    FileType.TXT,
+    FileType.HTML,
+    FileType.FB2,
+    FileType.DOCX,
+    FileType.ODT,
+    FileType.FODT
+)
+
+enum class AddBooksSource {
+    UNSHELVED,
+    ALL_BOOKS
+}
+
+enum class RenderMode {
+    VERTICAL_SCROLL,
+    PAGINATED
+}
+
+enum class SortOrder {
+    RECENT,
+    TITLE_ASC,
+    AUTHOR_ASC,
+    PERCENT_ASC,
+    PERCENT_DESC,
+    SIZE_ASC,
+    SIZE_DESC
+}
+
+enum class ReadStatusFilter {
+    ALL,
+    UNREAD,
+    IN_PROGRESS,
+    COMPLETED
+}
+
+enum class ShelfType {
+    MANUAL,
+    SMART,
+    TAG,
+    SERIES,
+    FOLDER
+}
+
+data class Tag(
+    val id: String,
+    val name: String,
+    val color: Int? = null
+)
+
+data class SyncedFolder(
+    val uriString: String,
+    val name: String,
+    val lastScanTime: Long,
+    val allowedFileTypes: Set = FileType.entries.toSet()
+)
+
+data class BookItem(
+    val id: String,
+    val path: String?,
+    val type: FileType,
+    val displayName: String,
+    val timestamp: Long,
+    val title: String? = null,
+    val author: String? = null,
+    val progressPercentage: Float? = null,
+    val isRecent: Boolean = true,
+    val fileSize: Long = 0L,
+    val sourceFolder: String? = null,
+    val seriesName: String? = null,
+    val seriesIndex: Double? = null,
+    val tags: List = emptyList()
+)
+
+data class Shelf(
+    val id: String,
+    val name: String,
+    val type: ShelfType,
+    val books: List,
+    val directBooks: List = books,
+    val parentShelfId: String? = null,
+    val childShelfIds: List = emptyList(),
+    val depth: Int = 0,
+    val sortKey: String = name.lowercase()
+) {
+    val bookCount: Int get() = books.size
+    val topBook: BookItem? get() = books.maxByOrNull { it.timestamp }
+    val directBookCount: Int get() = directBooks.size
+    val childShelfCount: Int get() = childShelfIds.size
+}
+
+data class LibraryFilters(
+    val fileTypes: Set = emptySet(),
+    val sourceFolders: Set = emptySet(),
+    val readStatus: ReadStatusFilter = ReadStatusFilter.ALL,
+    val tagIds: Set = emptySet()
+) {
+    val isActive: Boolean
+        get() = fileTypes.isNotEmpty() ||
+            sourceFolders.isNotEmpty() ||
+            readStatus != ReadStatusFilter.ALL ||
+            tagIds.isNotEmpty()
+}
+
+data class LibraryState(
+    val books: List = emptyList(),
+    val searchQuery: String = "",
+    val sortOrder: SortOrder = SortOrder.RECENT,
+    val filters: LibraryFilters = LibraryFilters(),
+    val selectedBookIds: Set = emptySet(),
+    val recentLimit: Int = 12,
+    val message: String? = null
+)
+
+data class HomeScreenModel(
+    val recentBooks: List,
+    val selectedBooks: List,
+    val isEmpty: Boolean
+)
+
+data class LibraryScreenModel(
+    val books: List,
+    val shelves: List,
+    val selectedBooks: List,
+    val filters: LibraryFilters,
+    val searchQuery: String,
+    val sortOrder: SortOrder
+)
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt
new file mode 100644
index 0000000..791b5f4
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt
@@ -0,0 +1,173 @@
+package com.aryan.reader.shared
+
+class LibraryProjector {
+    fun home(state: LibraryState): HomeScreenModel {
+        val recentBooks = sortBooks(state.books.filter { it.isRecent }, state.sortOrder)
+            .take(state.recentLimit)
+        return HomeScreenModel(
+            recentBooks = recentBooks,
+            selectedBooks = state.books.filter { it.id in state.selectedBookIds },
+            isEmpty = recentBooks.isEmpty()
+        )
+    }
+
+    fun library(state: LibraryState): LibraryScreenModel {
+        val searched = filterBySearch(state.books, state.searchQuery)
+        val filtered = applyFilters(searched, state.filters)
+        val sorted = sortBooks(filtered, state.sortOrder)
+
+        return LibraryScreenModel(
+            books = sorted,
+            shelves = buildShelves(state.books),
+            selectedBooks = state.books.filter { it.id in state.selectedBookIds },
+            filters = state.filters,
+            searchQuery = state.searchQuery,
+            sortOrder = state.sortOrder
+        )
+    }
+
+    fun withImportedFiles(state: LibraryState, files: List): LibraryState {
+        if (files.isEmpty()) return state
+        val now = currentTimestamp()
+        val existingIds = state.books.mapTo(mutableSetOf()) { it.id }
+        val imported = files.mapIndexedNotNull { index, file ->
+            val id = file.path ?: file.name
+            if (!existingIds.add(id)) {
+                null
+            } else {
+                BookItem(
+                    id = id,
+                    path = file.path,
+                    type = file.name.toFileType(),
+                    displayName = file.name,
+                    timestamp = now + index,
+                    title = file.name.substringBeforeLast('.'),
+                    fileSize = file.size,
+                    sourceFolder = file.path?.parentPath()
+                )
+            }
+        }
+        return state.copy(
+            books = imported + state.books,
+            message = if (imported.isEmpty()) "Those files are already in the desktop library." else "Imported ${imported.size} file(s). Reader support comes later."
+        )
+    }
+
+    fun sortBooks(books: List, sortOrder: SortOrder): List {
+        return when (sortOrder) {
+            SortOrder.RECENT -> books.sortedByDescending { it.timestamp }
+            SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
+            SortOrder.AUTHOR_ASC -> books.sortedBy { it.author?.lowercase() ?: "" }
+            SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f }
+            SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f }
+            SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize }
+            SortOrder.SIZE_DESC -> books.sortedByDescending { it.fileSize }
+        }
+    }
+
+    fun filterBySearch(books: List, query: String): List {
+        val normalized = query.trim()
+        if (normalized.isBlank()) return books
+        return books.filter { book ->
+            book.displayName.contains(normalized, ignoreCase = true) ||
+                book.title?.contains(normalized, ignoreCase = true) == true ||
+                book.author?.contains(normalized, ignoreCase = true) == true ||
+                book.tags.any { it.name.contains(normalized, ignoreCase = true) }
+        }
+    }
+
+    fun applyFilters(books: List, filters: LibraryFilters): List {
+        return books.filter { book ->
+            val matchesType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes
+            val matchesFolder = filters.sourceFolders.isEmpty() || book.sourceFolder in filters.sourceFolders
+            val progress = book.progressPercentage ?: 0f
+            val matchesStatus = when (filters.readStatus) {
+                ReadStatusFilter.ALL -> true
+                ReadStatusFilter.UNREAD -> progress == 0f
+                ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
+                ReadStatusFilter.COMPLETED -> progress >= 100f
+            }
+            val matchesTags = filters.tagIds.isEmpty() || book.tags.any { it.id in filters.tagIds }
+            matchesType && matchesFolder && matchesStatus && matchesTags
+        }
+    }
+
+    fun buildShelves(books: List): List {
+        val seriesShelves = books
+            .filter { !it.seriesName.isNullOrBlank() }
+            .groupBy { it.seriesName.orEmpty() }
+            .filter { it.value.size > 1 }
+            .map { (series, seriesBooks) ->
+                Shelf(
+                    id = "series_$series",
+                    name = series,
+                    type = ShelfType.SERIES,
+                    books = seriesBooks.sortedBy { it.seriesIndex ?: 999.0 }
+                )
+            }
+
+        val folderShelves = books
+            .filter { it.sourceFolder != null }
+            .groupBy { it.sourceFolder.orEmpty() }
+            .map { (folder, folderBooks) ->
+                Shelf(
+                    id = "folder_$folder",
+                    name = folder.folderDisplayName(),
+                    type = ShelfType.FOLDER,
+                    books = sortBooks(folderBooks, SortOrder.TITLE_ASC)
+                )
+            }
+
+        val tagShelves = books
+            .flatMap { book -> book.tags.map { tag -> tag to book } }
+            .groupBy({ it.first }, { it.second })
+            .map { (tag, taggedBooks) ->
+                Shelf(
+                    id = "tag_${tag.id}",
+                    name = tag.name,
+                    type = ShelfType.TAG,
+                    books = sortBooks(taggedBooks, SortOrder.TITLE_ASC)
+                )
+            }
+
+        return (seriesShelves + folderShelves + tagShelves)
+            .sortedWith(compareBy({ it.type.ordinal }, { it.name.lowercase() }))
+    }
+}
+
+private fun String.parentPath(): String? {
+    val normalized = replace('\\', '/')
+    val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "")
+    return parent.ifBlank { null }
+}
+
+private fun String.folderDisplayName(): String {
+    return replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { "Local Folder" }
+}
+
+data class ImportedFile(
+    val name: String,
+    val path: String?,
+    val size: Long
+)
+
+expect fun currentTimestamp(): Long
+
+fun String.toFileType(): FileType {
+    return when (substringAfterLast('.', "").lowercase()) {
+        "pdf" -> FileType.PDF
+        "epub" -> FileType.EPUB
+        "mobi" -> FileType.MOBI
+        "md" -> FileType.MD
+        "txt" -> FileType.TXT
+        "html", "htm" -> FileType.HTML
+        "fb2" -> FileType.FB2
+        "cbz" -> FileType.CBZ
+        "cbr" -> FileType.CBR
+        "cb7" -> FileType.CB7
+        "docx" -> FileType.DOCX
+        "odt" -> FileType.ODT
+        "fodt" -> FileType.FODT
+        else -> FileType.UNKNOWN
+    }
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt
new file mode 100644
index 0000000..888997a
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt
@@ -0,0 +1,324 @@
+package com.aryan.reader.shared
+
+data class ShelfRecord(
+    val id: String,
+    val name: String,
+    val isSmart: Boolean = false,
+    val smartRulesJson: String? = null
+)
+
+data class BookShelfRef(
+    val bookId: String,
+    val shelfId: String,
+    val addedAt: Long
+)
+
+fun interface SharedFolderPathResolver {
+    fun relativeFolderSegments(item: BookItem): List
+}
+
+object EmptySharedFolderPathResolver : SharedFolderPathResolver {
+    override fun relativeFolderSegments(item: BookItem): List = emptyList()
+}
+
+data class SharedLibraryProjectionInput(
+    val state: SharedReaderScreenState,
+    val booksFromStore: List,
+    val shelfRecords: List,
+    val shelfRefs: List,
+    val tags: List
+)
+
+class SharedLibraryStateProjector(
+    private val folderPathResolver: SharedFolderPathResolver = EmptySharedFolderPathResolver
+) {
+    fun project(input: SharedLibraryProjectionInput): SharedReaderScreenState {
+        val current = input.state
+        val allLibraryBooks = input.booksFromStore
+        val queried = filterBySearch(allLibraryBooks, current.searchQuery)
+        val filtered = applyLibraryFilters(queried, current.libraryFilters)
+        val sortedLibraryBooks = sortBooks(filtered, current.sortOrder)
+        val visibleRecentBooks = sortBooks(
+            allLibraryBooks.filter { it.isRecent },
+            current.sortOrder
+        ).take(if (current.recentFilesLimit > 0) current.recentFilesLimit else Int.MAX_VALUE)
+        val openTabs = current.openTabIds.mapNotNull { tabId -> allLibraryBooks.find { it.id == tabId } }
+        val shelfProjection = buildShelves(
+            allLibraryBooks = allLibraryBooks,
+            shelfRecords = input.shelfRecords,
+            shelfRefs = input.shelfRefs,
+            tags = input.tags,
+            sortOrder = current.sortOrder,
+            syncedFolders = current.syncedFolders
+        )
+        val validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id }
+        val viewingShelfId = current.viewingShelfId?.takeIf { it in validShelfIds }
+        val selectedShelfIds = current.selectedShelfIds.filterTo(mutableSetOf()) { it in validShelfIds }
+        val booksAvailableForAdding = if (current.isAddingBooksToShelf && viewingShelfId != null) {
+            val currentShelfBookIds = shelfProjection.shelves
+                .find { it.id == viewingShelfId }
+                ?.books
+                ?.map { it.id }
+                ?.toSet()
+                ?: emptySet()
+            when (current.addBooksSource) {
+                AddBooksSource.UNSHELVED -> shelfProjection.unshelvedBooks
+                AddBooksSource.ALL_BOOKS -> allLibraryBooks.filter { it.id !in currentShelfBookIds }
+            }
+        } else {
+            emptyList()
+        }
+
+        return current.copy(
+            recentBooks = visibleRecentBooks,
+            libraryBooks = sortedLibraryBooks,
+            rawLibraryBooks = allLibraryBooks,
+            viewingShelfId = viewingShelfId,
+            isAddingBooksToShelf = current.isAddingBooksToShelf && viewingShelfId != null,
+            selectedShelfIds = selectedShelfIds,
+            selectedBookIds = current.selectedBookIds.filterTo(mutableSetOf()) { selectedId ->
+                allLibraryBooks.any { it.id == selectedId }
+            },
+            shelves = shelfProjection.shelves,
+            openTabs = openTabs,
+            booksAvailableForAdding = booksAvailableForAdding,
+            allTags = input.tags
+        )
+    }
+
+    private fun buildShelves(
+        allLibraryBooks: List,
+        shelfRecords: List,
+        shelfRefs: List,
+        tags: List,
+        sortOrder: SortOrder,
+        syncedFolders: List
+    ): ShelfProjection {
+        val shelves = mutableListOf()
+        val shelvedBookIds = mutableSetOf()
+        val booksById = allLibraryBooks.associateBy { it.id }
+
+        shelfRecords.forEach { shelf ->
+            val bookIds = shelfRefs
+                .filter { it.shelfId == shelf.id }
+                .sortedBy { it.addedAt }
+                .map { it.bookId }
+            val books = bookIds.mapNotNull { booksById[it] }
+            shelves.add(Shelf(shelf.id, shelf.name, ShelfType.MANUAL, sortBooks(books, sortOrder)))
+            shelvedBookIds.addAll(bookIds)
+        }
+
+        val tagShelves = tags.mapNotNull { tag ->
+            val taggedBooks = allLibraryBooks.filter { book -> book.tags.any { it.id == tag.id } }
+            if (taggedBooks.isEmpty()) {
+                null
+            } else {
+                Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortBooks(taggedBooks, sortOrder))
+            }
+        }
+        shelves.addAll(tagShelves)
+
+        val seriesShelves = allLibraryBooks
+            .filter { !it.seriesName.isNullOrBlank() }
+            .groupBy { it.seriesName.orEmpty() }
+            .filter { it.value.size >= 2 }
+            .map { (series, books) ->
+                val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 }
+                shelvedBookIds.addAll(books.map { it.id })
+                Shelf("series_$series", series, ShelfType.SERIES, sortedSeries)
+            }
+        shelves.addAll(seriesShelves)
+
+        val folderShelves = buildFolderShelves(allLibraryBooks, syncedFolders, sortOrder)
+        folderShelves.forEach { shelf -> shelvedBookIds.addAll(shelf.books.map { it.id }) }
+        shelves.addAll(folderShelves)
+
+        val unshelvedBooks = allLibraryBooks.filter { it.id !in shelvedBookIds }
+        shelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortBooks(unshelvedBooks, sortOrder)))
+
+        shelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey }))
+        return ShelfProjection(shelves = shelves, unshelvedBooks = unshelvedBooks)
+    }
+
+    private fun buildFolderShelves(
+        allLibraryBooks: List,
+        syncedFolders: List,
+        sortOrder: SortOrder
+    ): List {
+        val folderNamesByUri = syncedFolders.associate { it.uriString to it.name }
+        return allLibraryBooks
+            .filter { it.sourceFolder != null }
+            .groupBy { it.sourceFolder.orEmpty() }
+            .flatMap { (folderUri, books) ->
+                val rootName = folderNamesByUri[folderUri] ?: folderUri.folderDisplayName()
+                val rootShelfId = "folder_$folderUri"
+                val rootAccumulator = FolderShelfAccumulator(
+                    id = rootShelfId,
+                    name = rootName,
+                    depth = 0,
+                    parentShelfId = null,
+                    sortPath = ""
+                )
+                val nestedShelves = linkedMapOf()
+                books.forEach { book ->
+                    rootAccumulator.books.add(book)
+                    val segments = folderPathResolver.relativeFolderSegments(book)
+                    if (segments.isEmpty()) rootAccumulator.directBooks.add(book)
+                    var currentPath = ""
+                    var parentShelfId = rootShelfId
+                    segments.forEachIndexed { index, segment ->
+                        currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment"
+                        val shelfId = "folder_$folderUri::$currentPath"
+                        val accumulator = nestedShelves.getOrPut(currentPath) {
+                            val newShelf = FolderShelfAccumulator(
+                                id = shelfId,
+                                name = segment,
+                                depth = index + 1,
+                                parentShelfId = parentShelfId,
+                                sortPath = currentPath.lowercase()
+                            )
+                            if (parentShelfId == rootShelfId) {
+                                rootAccumulator.childShelfIds.add(shelfId)
+                            } else {
+                                nestedShelves.values.find { it.id == parentShelfId }?.childShelfIds?.add(shelfId)
+                            }
+                            newShelf
+                        }
+                        accumulator.books.add(book)
+                        if (index == segments.lastIndex) accumulator.directBooks.add(book)
+                        parentShelfId = shelfId
+                    }
+                }
+
+                val rootShelf = Shelf(
+                    id = rootShelfId,
+                    name = rootName,
+                    type = ShelfType.FOLDER,
+                    books = sortBooks(books, sortOrder),
+                    directBooks = sortBooks(rootAccumulator.directBooks, sortOrder),
+                    childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() },
+                    depth = 0,
+                    sortKey = "folder:${rootName.lowercase()}:"
+                )
+
+                val childShelves = nestedShelves.values.sortedBy { it.sortPath }.map { shelf ->
+                    Shelf(
+                        id = shelf.id,
+                        name = shelf.name,
+                        type = ShelfType.FOLDER,
+                        books = sortBooks(shelf.books, sortOrder),
+                        directBooks = sortBooks(shelf.directBooks, sortOrder),
+                        parentShelfId = shelf.parentShelfId,
+                        childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() },
+                        depth = shelf.depth,
+                        sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}"
+                    )
+                }
+
+                listOf(rootShelf) + childShelves
+            }
+    }
+
+    private data class ShelfProjection(
+        val shelves: List,
+        val unshelvedBooks: List
+    )
+
+    private data class FolderShelfAccumulator(
+        val id: String,
+        val name: String,
+        val depth: Int,
+        val parentShelfId: String?,
+        val sortPath: String,
+        val books: MutableList = mutableListOf(),
+        val directBooks: MutableList = mutableListOf(),
+        val childShelfIds: MutableList = mutableListOf()
+    )
+}
+
+private fun String.folderDisplayName(): String {
+    return replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { "Local Folder" }
+}
+
+fun filterBySearch(books: List, searchQuery: String): List {
+    val query = searchQuery.trim()
+    return if (query.isBlank()) {
+        books
+    } else {
+        books.filter { book ->
+            book.displayName.contains(query, ignoreCase = true) ||
+                book.title?.contains(query, ignoreCase = true) == true ||
+                book.author?.contains(query, ignoreCase = true) == true ||
+                book.tags.any { tag -> tag.name.contains(query, ignoreCase = true) }
+        }
+    }
+}
+
+fun applyLibraryFilters(books: List, filters: LibraryFilters): List {
+    return books.filter { book ->
+        val matchType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes
+        val matchFolder = filters.sourceFolders.isEmpty() || book.sourceFolder in filters.sourceFolders
+        val progress = book.progressPercentage ?: 0f
+        val matchStatus = when (filters.readStatus) {
+            ReadStatusFilter.ALL -> true
+            ReadStatusFilter.UNREAD -> progress == 0f
+            ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
+            ReadStatusFilter.COMPLETED -> progress >= 100f
+        }
+        val matchTags = filters.tagIds.isEmpty() || book.tags.any { it.id in filters.tagIds }
+        matchType && matchFolder && matchStatus && matchTags
+    }
+}
+
+fun sortBooks(books: List, sortOrder: SortOrder): List {
+    return when (sortOrder) {
+        SortOrder.RECENT -> books.sortedByDescending { it.timestamp }
+        SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
+        SortOrder.AUTHOR_ASC -> books.sortedBy { it.author?.lowercase() ?: "" }
+        SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f }
+        SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f }
+        SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize }
+        SortOrder.SIZE_DESC -> books.sortedByDescending { it.fileSize }
+    }
+}
+
+fun SharedReaderScreenState.withImportedFiles(
+    files: List,
+    now: Long = currentTimestamp()
+): SharedReaderScreenState {
+    if (files.isEmpty()) return this
+    val existingIds = rawLibraryBooks.mapTo(mutableSetOf()) { it.id }
+    val imported = files.mapIndexedNotNull { index, file ->
+        val id = file.localPath ?: file.uriString ?: file.name
+        if (!existingIds.add(id)) {
+            null
+        } else {
+            BookItem(
+                id = id,
+                path = file.localPath ?: file.uriString,
+                type = file.name.toFileType(),
+                displayName = file.name,
+                timestamp = now + index,
+                title = file.name.substringBeforeLast('.'),
+                fileSize = file.size,
+                sourceFolder = file.localPath?.parentPath()
+            )
+        }
+    }
+    return copy(
+        rawLibraryBooks = imported + rawLibraryBooks,
+        bannerMessage = BannerMessage(
+            if (imported.isEmpty()) {
+                "Those files are already in the library."
+            } else {
+                "Imported ${imported.size} file(s)."
+            }
+        )
+    )
+}
+
+private fun String.parentPath(): String? {
+    val normalized = replace('\\', '/')
+    val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "")
+    return parent.ifBlank { null }
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/PdfReaderModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/PdfReaderModels.kt
new file mode 100644
index 0000000..36e226f
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/PdfReaderModels.kt
@@ -0,0 +1,81 @@
+package com.aryan.reader.shared
+
+enum class SaveMode {
+    ORIGINAL,
+    ANNOTATED
+}
+
+enum class SearchHighlightMode {
+    FOCUSED,
+    ALL
+}
+
+enum class DockLocation {
+    TOP,
+    BOTTOM,
+    FLOATING
+}
+
+enum class PdfDisplayMode {
+    PAGINATION,
+    VERTICAL_SCROLL
+}
+
+data class PdfTocEntry(
+    val title: String,
+    val pageIndex: Int,
+    val nestLevel: Int
+)
+
+data class PdfLink(
+    val uri: String?,
+    val destPageIndex: Int?,
+    val bounds: PageRect
+)
+
+data class PagePoint(
+    val x: Float,
+    val y: Float
+)
+
+data class PageRect(
+    val left: Float,
+    val top: Float,
+    val right: Float,
+    val bottom: Float
+)
+
+data class PdfTextRect(
+    val rect: PageRect
+)
+
+interface SharedReaderDocument : AutoCloseable {
+    suspend fun getPageCount(): Int
+    suspend fun openPage(pageIndex: Int): SharedReaderPage?
+    suspend fun getTableOfContents(): List
+}
+
+interface SharedReaderPage : AutoCloseable {
+    suspend fun getPageWidthPoint(): Int
+    suspend fun getPageHeightPoint(): Int
+    suspend fun getPageRotation(): Int
+    suspend fun openTextPage(): SharedReaderTextPage
+    suspend fun getLinks(): List
+}
+
+interface SharedReaderTextPage : AutoCloseable {
+    suspend fun textPageCountChars(): Int
+    suspend fun textPageGetText(startIndex: Int, count: Int): String?
+    suspend fun textPageGetRectsForRanges(ranges: IntArray): List?
+    suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double): Int
+    suspend fun textPageGetCharBox(index: Int): PageRect?
+    suspend fun textPageGetUnicode(index: Int): Int
+    suspend fun loadWebLink(): SharedReaderWebLinks?
+}
+
+interface SharedReaderWebLinks : AutoCloseable {
+    suspend fun countWebLinks(): Int
+    suspend fun getURL(linkIndex: Int, maxLength: Int): String?
+    suspend fun countRects(linkIndex: Int): Int
+    suspend fun getRect(linkIndex: Int, rectIndex: Int): PageRect
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt
new file mode 100644
index 0000000..46aa1af
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt
@@ -0,0 +1,51 @@
+package com.aryan.reader.shared
+
+import androidx.compose.ui.graphics.Color
+
+data class EpubBookmark(
+    val cfi: String,
+    val chapterTitle: String,
+    val label: String? = null,
+    val snippet: String,
+    val pageInChapter: Int?,
+    val totalPagesInChapter: Int?,
+    val chapterIndex: Int
+)
+
+enum class HighlightColor(val id: String, val color: Color, val cssClass: String) {
+    YELLOW("yellow", Color(0xFFFBC02D), "user-highlight-yellow"),
+    GREEN("green", Color(0xFF388E3C), "user-highlight-green"),
+    BLUE("blue", Color(0xFF1976D2), "user-highlight-blue"),
+    RED("red", Color(0xFFD32F2F), "user-highlight-red"),
+    PURPLE("purple", Color(0xFF7B1FA2), "user-highlight-purple"),
+    ORANGE("orange", Color(0xFFF57C00), "user-highlight-orange"),
+    CYAN("cyan", Color(0xFF0097A7), "user-highlight-cyan"),
+    MAGENTA("magenta", Color(0xFFC2185B), "user-highlight-magenta"),
+    LIME("lime", Color(0xFFAFB42B), "user-highlight-lime"),
+    PINK("pink", Color(0xFFE91E63), "user-highlight-pink"),
+    TEAL("teal", Color(0xFF00796B), "user-highlight-teal"),
+    INDIGO("indigo", Color(0xFF303F9F), "user-highlight-indigo"),
+    BLACK("black", Color(0xFF424242), "user-highlight-black"),
+    WHITE("white", Color(0xFFF5F5F5), "user-highlight-white")
+}
+
+data class UserHighlight(
+    val id: String,
+    val cfi: String,
+    val text: String,
+    val color: HighlightColor,
+    val chapterIndex: Int,
+    val note: String? = null
+)
+
+fun escapeJsString(value: String): String {
+    return value
+        .replace("\\", "\\\\")
+        .replace("'", "\\'")
+        .replace("\"", "\\\"")
+        .replace("\n", "\\n")
+        .replace("\r", "\\r")
+        .replace("\t", "\\t")
+        .replace("\u2028", "\\u2028")
+        .replace("\u2029", "\\u2029")
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt
new file mode 100644
index 0000000..a5a22e3
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt
@@ -0,0 +1,67 @@
+package com.aryan.reader.shared
+
+import androidx.compose.ui.graphics.Color
+
+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")
+}
+
+enum class ReaderTextAlign(val id: String, val cssValue: String, val displayName: String) {
+    DEFAULT("default", "", "Default"),
+    LEFT("left", "left", "Left"),
+    JUSTIFY("justify", "justify", "Justify")
+}
+
+enum class SystemUiMode(val id: Int, val title: String) {
+    DEFAULT(0, "Always Show"),
+    SYNC(1, "Sync with Menus"),
+    HIDDEN(2, "Always Hide")
+}
+
+enum class PageInfoMode(val id: Int, val title: String) {
+    DEFAULT(0, "Always Show"),
+    SYNC(1, "Sync with Menus"),
+    HIDDEN(2, "Always Hide")
+}
+
+data class FormatSettings(
+    val fontSize: Float,
+    val lineHeight: Float,
+    val paragraphGap: Float,
+    val imageSize: Float,
+    val horizontalMargin: Float,
+    val font: ReaderFont,
+    val customPath: String?,
+    val textAlign: ReaderTextAlign
+)
+
+enum class ReaderTexture(val id: String, val displayName: String) {
+    PAPER("paper", "Paper"),
+    CANVAS("canvas", "Canvas"),
+    EINK("eink", "E-Ink"),
+    SLATE("slate", "Slate")
+}
+
+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 BuiltInReaderThemes = 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)
+)
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderSearchModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderSearchModels.kt
new file mode 100644
index 0000000..329e707
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderSearchModels.kt
@@ -0,0 +1,29 @@
+package com.aryan.reader.shared
+
+data class SearchResult(
+    val locationInSource: Int,
+    val locationTitle: String,
+    val snippet: String,
+    val query: String,
+    val occurrenceIndexInLocation: Int,
+    val chunkIndex: Int
+)
+
+data class AiDefinitionResult(
+    val definition: String? = null,
+    val error: String? = null
+)
+
+data class SummarizationResult(
+    val summary: String? = null,
+    val error: String? = null,
+    val cost: Double? = null,
+    val freeRemaining: Int? = null
+)
+
+data class RecapResult(
+    val recap: String? = null,
+    val error: String? = null,
+    val cost: Double? = null,
+    val freeRemaining: Int? = null
+)
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt
new file mode 100644
index 0000000..887e198
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt
@@ -0,0 +1,58 @@
+package com.aryan.reader.shared
+
+import kotlinx.coroutines.flow.Flow
+
+data class ImportedBookFile(
+    val name: String,
+    val uriString: String?,
+    val localPath: String?,
+    val size: Long
+)
+
+interface BookRepository {
+    fun observeBooks(): Flow>
+    suspend fun getBook(bookId: String): BookItem?
+    suspend fun upsertBook(book: BookItem)
+    suspend fun removeBooks(bookIds: Set)
+}
+
+interface LibraryRepository {
+    fun observeLibraryState(): Flow
+    suspend fun updateSortOrder(sortOrder: SortOrder)
+    suspend fun updateFilters(filters: LibraryFilters)
+    suspend fun updateSearchQuery(query: String)
+}
+
+interface SettingsRepository {
+    fun observeAppThemeMode(): Flow
+    fun observeReaderTheme(): Flow
+    suspend fun setAppThemeMode(mode: AppThemeMode)
+    suspend fun setReaderTheme(theme: ReaderTheme)
+}
+
+interface FileImporter {
+    suspend fun importFiles(files: List): List
+}
+
+interface ReaderDocumentLoader {
+    suspend fun canLoad(type: FileType): Boolean
+    suspend fun loadDocument(book: BookItem): SharedReaderDocument
+}
+
+interface SyncAdapter {
+    val isAvailable: Boolean
+    suspend fun syncNow()
+}
+
+interface AiAdapter {
+    val isAvailable: Boolean
+    suspend fun define(text: String, context: String? = null): AiDefinitionResult
+    suspend fun summarize(text: String): SummarizationResult
+    suspend fun recap(textBeforeCurrentLocation: String): RecapResult
+}
+
+interface TtsAdapter {
+    val isAvailable: Boolean
+    suspend fun speak(text: String)
+    suspend fun stop()
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SampleLibrary.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SampleLibrary.kt
new file mode 100644
index 0000000..5a847c6
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SampleLibrary.kt
@@ -0,0 +1,66 @@
+package com.aryan.reader.shared
+
+fun sampleLibraryState(): LibraryState {
+    val reference = Tag("reference", "Reference", 0xFF9575CD.toInt())
+    val reading = Tag("reading", "Reading", 0xFF81C784.toInt())
+    val now = currentTimestamp()
+
+    return LibraryState(
+        books = listOf(
+            BookItem(
+                id = "sample_pdf",
+                path = null,
+                type = FileType.PDF,
+                displayName = "Designing Data-Intensive Applications.pdf",
+                timestamp = now - 1_000,
+                title = "Designing Data-Intensive Applications",
+                author = "Martin Kleppmann",
+                progressPercentage = 42f,
+                fileSize = 18_400_000,
+                sourceFolder = "Samples",
+                tags = listOf(reference, reading)
+            ),
+            BookItem(
+                id = "sample_epub",
+                path = null,
+                type = FileType.EPUB,
+                displayName = "The Pragmatic Programmer.epub",
+                timestamp = now - 2_000,
+                title = "The Pragmatic Programmer",
+                author = "Andrew Hunt, David Thomas",
+                progressPercentage = 12f,
+                fileSize = 4_200_000,
+                sourceFolder = "Samples",
+                tags = listOf(reading)
+            ),
+            BookItem(
+                id = "sample_doc",
+                path = null,
+                type = FileType.DOCX,
+                displayName = "Research Notes.docx",
+                timestamp = now - 3_000,
+                title = "Research Notes",
+                author = "Local",
+                progressPercentage = 0f,
+                fileSize = 920_000,
+                sourceFolder = "Samples"
+            )
+        )
+    )
+}
+
+fun sampleReaderScreenState(): SharedReaderScreenState {
+    val library = sampleLibraryState()
+    val tags = library.books.flatMap { it.tags }.distinctBy { it.id }
+    return SharedReaderScreenState(
+        rawLibraryBooks = library.books,
+        recentBooks = library.books.filter { it.isRecent },
+        libraryBooks = library.books,
+        selectedBookIds = library.selectedBookIds,
+        searchQuery = library.searchQuery,
+        sortOrder = library.sortOrder,
+        libraryFilters = library.filters,
+        recentFilesLimit = library.recentLimit,
+        allTags = tags
+    )
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ScreenProjectors.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ScreenProjectors.kt
new file mode 100644
index 0000000..eda6178
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ScreenProjectors.kt
@@ -0,0 +1,53 @@
+package com.aryan.reader.shared
+
+data class SharedHomeScreenModel(
+    val recentBooks: List,
+    val openTabs: List,
+    val selectedBooks: List,
+    val isContextualModeActive: Boolean,
+    val deviceLimitState: DeviceLimitReachedState,
+    val isEmpty: Boolean,
+    val isLibraryEmpty: Boolean
+)
+
+fun SharedReaderScreenState.toHomeScreenModel(): SharedHomeScreenModel {
+    val homeRecentBooks = recentBooks.filter { it.isRecent }
+    return SharedHomeScreenModel(
+        recentBooks = homeRecentBooks,
+        openTabs = openTabs,
+        selectedBooks = rawLibraryBooks.filter { it.id in selectedBookIds },
+        isContextualModeActive = selectedBookIds.isNotEmpty(),
+        deviceLimitState = deviceLimitState,
+        isEmpty = homeRecentBooks.isEmpty() && (!isTabsEnabled || openTabs.isEmpty()),
+        isLibraryEmpty = rawLibraryBooks.isEmpty()
+    )
+}
+
+data class SharedLibraryScreenModel(
+    val selectedBooks: List,
+    val isContextualModeActive: Boolean,
+    val selectedShelves: Set,
+    val isShelfContextualModeActive: Boolean,
+    val sortOrder: SortOrder,
+    val shelves: List,
+    val rawLibraryBooks: List,
+    val containsFolderItemsInSelection: Boolean,
+    val isSearchActive: Boolean,
+    val searchQuery: String
+)
+
+fun SharedReaderScreenState.toLibraryScreenModel(): SharedLibraryScreenModel {
+    val selected = rawLibraryBooks.filter { it.id in selectedBookIds }
+    return SharedLibraryScreenModel(
+        selectedBooks = selected,
+        isContextualModeActive = selectedBookIds.isNotEmpty(),
+        selectedShelves = selectedShelfIds,
+        isShelfContextualModeActive = selectedShelfIds.isNotEmpty(),
+        sortOrder = sortOrder,
+        shelves = shelves,
+        rawLibraryBooks = rawLibraryBooks,
+        containsFolderItemsInSelection = selected.any { it.sourceFolder != null },
+        isSearchActive = isSearchActive,
+        searchQuery = searchQuery
+    )
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFormatters.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFormatters.kt
new file mode 100644
index 0000000..8f3c9a1
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFormatters.kt
@@ -0,0 +1,48 @@
+package com.aryan.reader.shared
+
+import kotlin.math.log10
+import kotlin.math.pow
+import kotlin.math.roundToInt
+
+fun formatFileSize(bytes: Long): String {
+    if (bytes <= 0) return "Unknown"
+    val units = arrayOf("B", "KB", "MB", "GB", "TB")
+    val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt().coerceIn(units.indices)
+    return formatDecimal(bytes / 1024.0.pow(digitGroups.toDouble()), 2) + " " + units[digitGroups]
+}
+
+fun progressPercentValue(progressPercentage: Float?): Int {
+    return (progressPercentage ?: 0f).coerceIn(0f, 100f).roundToInt()
+}
+
+fun progressFraction(progressPercentage: Float?): Float {
+    return progressPercentValue(progressPercentage) / 100f
+}
+
+fun BookItem.cardTitle(): String {
+    return title?.takeIf { it.isNotBlank() } ?: displayName
+}
+
+fun BookItem.cardAuthor(fallback: String = "No author listed"): String {
+    return author
+        ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
+        ?: fallback
+}
+
+fun BookItem.isOpdsStream(): Boolean {
+    return path?.startsWith("opds-pse://") == true
+}
+
+private fun formatDecimal(value: Double, decimals: Int): String {
+    val factor = 10.0.pow(decimals)
+    val rounded = (value * factor).roundToInt() / factor
+    val text = rounded.toString()
+    val dotIndex = text.indexOf('.')
+    if (dotIndex < 0) return text + "." + "0".repeat(decimals)
+    val currentDecimals = text.length - dotIndex - 1
+    return if (currentDecimals >= decimals) {
+        text.take(dotIndex + 1 + decimals)
+    } else {
+        text + "0".repeat(decimals - currentDecimals)
+    }
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt
new file mode 100644
index 0000000..94f8a1f
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt
@@ -0,0 +1,62 @@
+package com.aryan.reader.shared
+
+fun LibraryState.reduce(action: LibraryAction): LibraryState {
+    return when (action) {
+        is LibraryAction.SearchChanged -> copy(searchQuery = action.query)
+        is LibraryAction.SortChanged -> copy(sortOrder = action.sortOrder)
+        is LibraryAction.FiltersChanged -> copy(filters = action.filters)
+        is LibraryAction.BookSelectionToggled -> {
+            val selected = if (action.bookId in selectedBookIds) {
+                selectedBookIds - action.bookId
+            } else {
+                selectedBookIds + action.bookId
+            }
+            copy(selectedBookIds = selected)
+        }
+        LibraryAction.SelectionCleared -> copy(selectedBookIds = emptySet())
+        is LibraryAction.ShelfSelectionToggled -> this
+        LibraryAction.ShelfSelectionCleared -> this
+        is LibraryAction.LibraryPageChanged -> this
+        is LibraryAction.RecentLimitChanged -> copy(recentLimit = action.limit)
+    }
+}
+
+fun SharedReaderScreenState.reduce(action: LibraryAction): SharedReaderScreenState {
+    return when (action) {
+        is LibraryAction.SearchChanged -> copy(searchQuery = action.query)
+        is LibraryAction.SortChanged -> copy(sortOrder = action.sortOrder)
+        is LibraryAction.FiltersChanged -> copy(libraryFilters = action.filters)
+        is LibraryAction.BookSelectionToggled -> {
+            val selected = if (action.bookId in selectedBookIds) {
+                selectedBookIds - action.bookId
+            } else {
+                selectedBookIds + action.bookId
+            }
+            copy(selectedBookIds = selected)
+        }
+        LibraryAction.SelectionCleared -> copy(selectedBookIds = emptySet())
+        is LibraryAction.ShelfSelectionToggled -> {
+            val selected = if (action.shelfId in selectedShelfIds) {
+                selectedShelfIds - action.shelfId
+            } else {
+                selectedShelfIds + action.shelfId
+            }
+            copy(selectedShelfIds = selected)
+        }
+        LibraryAction.ShelfSelectionCleared -> copy(selectedShelfIds = emptySet())
+        is LibraryAction.LibraryPageChanged -> copy(libraryScreenStartPage = action.page)
+        is LibraryAction.RecentLimitChanged -> copy(recentFilesLimit = action.limit)
+    }
+}
+
+fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
+    return when (action) {
+        is AppAction.BannerShown -> copy(bannerMessage = action.message)
+        AppAction.BannerDismissed -> copy(bannerMessage = null)
+        is AppAction.NavigationRequested -> this
+        is AppAction.AppThemeChanged -> copy(appThemeMode = action.mode)
+        is AppAction.AppContrastChanged -> copy(appContrastOption = action.option)
+        is AppAction.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled)
+        is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled)
+    }
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt
new file mode 100644
index 0000000..9b6c840
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt
@@ -0,0 +1,140 @@
+package com.aryan.reader.shared.pdf
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlin.math.roundToInt
+
+enum class PdfAnnotationKind {
+    INK,
+    TEXT
+}
+
+enum class PdfInkTool {
+    PEN,
+    HIGHLIGHTER,
+    HIGHLIGHTER_ROUND,
+    ERASER,
+    FOUNTAIN_PEN,
+    PENCIL,
+    TEXT
+}
+
+@Serializable
+data class PdfPagePoint(
+    val x: Float,
+    val y: Float,
+    val timestamp: Long = 0L
+)
+
+@Serializable
+data class PdfPageBounds(
+    val left: Float,
+    val top: Float,
+    val right: Float,
+    val bottom: Float
+)
+
+@Serializable
+data class SharedPdfAnnotation(
+    val id: String,
+    val pageIndex: Int,
+    val kind: PdfAnnotationKind,
+    val tool: PdfInkTool = PdfInkTool.PEN,
+    val points: List = emptyList(),
+    val bounds: PdfPageBounds? = null,
+    val text: String = "",
+    val colorArgb: Int,
+    val backgroundArgb: Int = 0x00FFFFFF,
+    val strokeWidth: Float = 2f,
+    val fontSize: Float = 16f,
+    val isBold: Boolean = false,
+    val isItalic: Boolean = false,
+    val createdAt: Long = 0L
+)
+
+data class PdfToolConfig(
+    val colorArgb: Int,
+    val strokeWidth: Float
+)
+
+object SharedPdfAnnotationDefaults {
+    val penPalette: List = listOf(
+        0xFF111111.toInt(),
+        0xFFD32F2F.toInt(),
+        0xFF1976D2.toInt(),
+        0xFF388E3C.toInt(),
+        0xFFFFFFFF.toInt()
+    )
+
+    val highlighterPalette: List = listOf(
+        0x8CFF9800.toInt(),
+        0x8CFFEB3B.toInt(),
+        0x8C81C784.toInt(),
+        0x8C64B5F6.toInt(),
+        0x8CE1BEE7.toInt()
+    )
+
+    fun configFor(tool: PdfInkTool): PdfToolConfig {
+        return when (tool) {
+            PdfInkTool.PEN -> PdfToolConfig(0xFF111111.toInt(), 2.5f)
+            PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF111111.toInt(), 3.5f)
+            PdfInkTool.PENCIL -> PdfToolConfig(0xFF616161.toInt(), 1.8f)
+            PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFFEB3B.toInt(), 12f)
+            PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFF9800.toInt(), 16f)
+            PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 18f)
+            PdfInkTool.TEXT -> PdfToolConfig(0xFF111111.toInt(), 1f)
+        }
+    }
+}
+
+@Serializable
+data class SharedPdfAnnotationStore(
+    val version: Int = 1,
+    val annotations: List = emptyList()
+)
+
+object SharedPdfAnnotationSerializer {
+    private val json = Json {
+        ignoreUnknownKeys = true
+        prettyPrint = true
+        encodeDefaults = true
+    }
+
+    fun encode(annotations: List): String {
+        return json.encodeToString(SharedPdfAnnotationStore(annotations = annotations))
+    }
+
+    fun decode(raw: String): List {
+        if (raw.isBlank()) return emptyList()
+        return runCatching {
+            json.decodeFromString(raw).annotations
+        }.getOrElse {
+            runCatching { json.decodeFromString>(raw) }.getOrDefault(emptyList())
+        }
+    }
+}
+
+data class PdfZoomSpec(
+    val min: Float = 0.65f,
+    val max: Float = 3.0f,
+    val default: Float = 1.35f,
+    val maxRenderPixels: Int = 18_000_000
+) {
+    fun clamp(value: Float): Float = value.coerceIn(min, max)
+
+    fun safeRenderScale(pageWidth: Float, pageHeight: Float, requestedScale: Float): Float {
+        val clamped = clamp(requestedScale)
+        val pixelCount = pageWidth * pageHeight * clamped * clamped
+        if (pixelCount <= maxRenderPixels) return clamped
+        val fitScale = kotlin.math.sqrt(maxRenderPixels / (pageWidth * pageHeight))
+        return fitScale.coerceAtMost(clamped).coerceAtLeast(0.1f)
+    }
+
+    fun renderSize(pageWidth: Float, pageHeight: Float, requestedScale: Float): Pair {
+        val renderScale = safeRenderScale(pageWidth, pageHeight, requestedScale)
+        return (pageWidth * renderScale).roundToInt().coerceAtLeast(1) to
+            (pageHeight * renderScale).roundToInt().coerceAtLeast(1)
+    }
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfiumBridge.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfiumBridge.kt
new file mode 100644
index 0000000..8ae6e7d
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfiumBridge.kt
@@ -0,0 +1,63 @@
+package com.aryan.reader.shared.pdf
+
+/**
+ * Platform-neutral surface for Pdfium functions that are not exposed by the
+ * higher-level document/page API. Platform implementations can back this with
+ * Android JNI, Windows Pdfium binaries, or another native binding.
+ */
+interface PdfiumBridge {
+    fun getFontSize(textPagePtr: Long, index: Int): Double
+    fun getFontWeight(textPagePtr: Long, index: Int): Int
+
+    fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray?
+    fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray?
+    fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray?
+    fun getPageCharBoxes(textPagePtr: Long, count: Int): FloatArray?
+
+    fun getAnnotCount(pagePtr: Long): Int
+    fun getAnnotSubtype(pagePtr: Long, index: Int): Int
+    fun getAnnotRect(pagePtr: Long, index: Int): FloatArray?
+    fun getAnnotString(pagePtr: Long, index: Int, key: String): String?
+
+    fun getPageObjectCount(pagePtr: Long): Int
+    fun getPageObjectType(pagePtr: Long, index: Int): Int
+    fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean
+    fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray?
+
+    fun performClick(pagePtr: Long, x: Double, y: Double): Boolean
+    fun getLinkInfoAtPoint(docPtr: Long, pagePtr: Long, x: Double, y: Double): String?
+
+    fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int
+    fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
+    fun checkActionSupport(): Boolean
+}
+
+object PdfiumAnnotationSubtype {
+    const val TEXT = 1
+    const val LINK = 2
+    const val HIGHLIGHT = 8
+    const val INK = 12
+    const val WIDGET = 19
+}
+
+object NoOpPdfiumBridge : PdfiumBridge {
+    override fun getFontSize(textPagePtr: Long, index: Int): Double = 0.0
+    override fun getFontWeight(textPagePtr: Long, index: Int): Int = 0
+    override fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray? = null
+    override fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray? = null
+    override fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray? = null
+    override fun getPageCharBoxes(textPagePtr: Long, count: Int): FloatArray? = null
+    override fun getAnnotCount(pagePtr: Long): Int = 0
+    override fun getAnnotSubtype(pagePtr: Long, index: Int): Int = 0
+    override fun getAnnotRect(pagePtr: Long, index: Int): FloatArray? = null
+    override fun getAnnotString(pagePtr: Long, index: Int, key: String): String? = null
+    override fun getPageObjectCount(pagePtr: Long): Int = 0
+    override fun getPageObjectType(pagePtr: Long, index: Int): Int = 0
+    override fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean = false
+    override fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray? = null
+    override fun performClick(pagePtr: Long, x: Double, y: Double): Boolean = false
+    override fun getLinkInfoAtPoint(docPtr: Long, pagePtr: Long, x: Double, y: Double): String? = null
+    override fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int = -1
+    override fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray? = null
+    override fun checkActionSupport(): Boolean = false
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt
new file mode 100644
index 0000000..b5b7457
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt
@@ -0,0 +1,163 @@
+package com.aryan.reader.shared.reader
+
+data class ReaderBookmark(
+    val id: String,
+    val pageIndex: Int,
+    val chapterTitle: String,
+    val preview: String
+)
+
+data class ReaderSearchResult(
+    val pageIndex: Int,
+    val chapterTitle: String,
+    val preview: String
+)
+
+data class ReaderSessionState(
+    val reader: PaginatedReaderState,
+    val bookmarks: List = emptyList(),
+    val searchQuery: String = "",
+    val searchResults: List = emptyList(),
+    val activeSearchResultIndex: Int = -1
+) {
+    val currentBookmark: ReaderBookmark?
+        get() = bookmarks.firstOrNull { it.pageIndex == reader.currentPageIndex }
+
+    val activeSearchResult: ReaderSearchResult?
+        get() = searchResults.getOrNull(activeSearchResultIndex)
+}
+
+class ReaderEngine(
+    private val paginator: SimplePaginator = SimplePaginator()
+) {
+    fun createSession(
+        book: SharedEpubBook,
+        settings: ReaderSettings = ReaderSettings()
+    ): ReaderSessionState {
+        return ReaderSessionState(
+            reader = PaginatedReaderState(
+                book = book,
+                pages = paginator.paginate(book, settings),
+                settings = settings
+            )
+        )
+    }
+
+    fun next(state: ReaderSessionState): ReaderSessionState {
+        if (!state.reader.canGoNext) return state
+        return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex + 1))
+    }
+
+    fun previous(state: ReaderSessionState): ReaderSessionState {
+        if (!state.reader.canGoPrevious) return state
+        return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex - 1))
+    }
+
+    fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
+        val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
+        return state.copy(
+            reader = state.reader.copy(currentPageIndex = target),
+            activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target }
+        )
+    }
+
+    fun goToProgress(state: ReaderSessionState, progress: Float): ReaderSessionState {
+        if (state.reader.pages.isEmpty()) return state
+        val target = ((state.reader.pages.lastIndex) * progress.coerceIn(0f, 1f)).toInt()
+        return goToPage(state, target)
+    }
+
+    fun goToChapter(state: ReaderSessionState, chapterIndex: Int): ReaderSessionState {
+        val pageIndex = state.reader.pages.indexOfFirst { it.chapterIndex == chapterIndex }
+        return if (pageIndex >= 0) goToPage(state, pageIndex) else state
+    }
+
+    fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState {
+        return state.copy(reader = paginator.repaginate(state.reader, settings))
+    }
+
+    fun toggleBookmark(state: ReaderSessionState): ReaderSessionState {
+        val page = state.reader.currentPage ?: return state
+        val existing = state.bookmarks.firstOrNull { it.pageIndex == state.reader.currentPageIndex }
+        val updated = if (existing != null) {
+            state.bookmarks - existing
+        } else {
+            state.bookmarks + ReaderBookmark(
+                id = "${state.reader.book.id}_${state.reader.currentPageIndex}",
+                pageIndex = state.reader.currentPageIndex,
+                chapterTitle = page.chapterTitle,
+                preview = page.text.preview()
+            )
+        }
+        return state.copy(bookmarks = updated.sortedBy { it.pageIndex })
+    }
+
+    fun search(state: ReaderSessionState, query: String): ReaderSessionState {
+        val normalized = query.trim()
+        val results = if (normalized.isBlank()) {
+            emptyList()
+        } else {
+            state.reader.pages.mapNotNull { page ->
+                val index = page.text.indexOf(normalized, ignoreCase = true)
+                if (index < 0) {
+                    null
+                } else {
+                    ReaderSearchResult(
+                        pageIndex = page.pageIndex,
+                        chapterTitle = page.chapterTitle,
+                        preview = page.text.previewAround(index, normalized.length)
+                    )
+                }
+            }
+        }
+        val activeIndex = results.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex }
+            .takeIf { it >= 0 }
+            ?: if (results.isNotEmpty()) 0 else -1
+        val updated = state.copy(
+            searchQuery = query,
+            searchResults = results,
+            activeSearchResultIndex = activeIndex
+        )
+        return updated.activeSearchResult?.let { goToPage(updated, it.pageIndex) } ?: updated
+    }
+
+    fun nextSearchResult(state: ReaderSessionState): ReaderSessionState {
+        if (state.searchResults.isEmpty()) return state
+        val nextIndex = if (state.activeSearchResultIndex < state.searchResults.lastIndex) {
+            state.activeSearchResultIndex + 1
+        } else {
+            0
+        }
+        return state.copy(
+            reader = state.reader.copy(currentPageIndex = state.searchResults[nextIndex].pageIndex),
+            activeSearchResultIndex = nextIndex
+        )
+    }
+
+    fun previousSearchResult(state: ReaderSessionState): ReaderSessionState {
+        if (state.searchResults.isEmpty()) return state
+        val nextIndex = if (state.activeSearchResultIndex > 0) {
+            state.activeSearchResultIndex - 1
+        } else {
+            state.searchResults.lastIndex
+        }
+        return state.copy(
+            reader = state.reader.copy(currentPageIndex = state.searchResults[nextIndex].pageIndex),
+            activeSearchResultIndex = nextIndex
+        )
+    }
+}
+
+private fun String.preview(): String {
+    return trim()
+        .replace(Regex("\\s+"), " ")
+        .take(140)
+}
+
+private fun String.previewAround(index: Int, queryLength: Int): String {
+    val start = (index - 70).coerceAtLeast(0)
+    val end = (index + queryLength + 100).coerceAtMost(length)
+    val prefix = if (start > 0) "..." else ""
+    val suffix = if (end < length) "..." else ""
+    return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix
+}
diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt
new file mode 100644
index 0000000..05f5faf
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt
@@ -0,0 +1,219 @@
+package com.aryan.reader.shared.reader
+
+import com.aryan.reader.paginatedreader.SemanticBlock
+import com.aryan.reader.paginatedreader.SemanticFlexContainer
+import com.aryan.reader.paginatedreader.SemanticHeader
+import com.aryan.reader.paginatedreader.SemanticImage
+import com.aryan.reader.paginatedreader.SemanticList
+import com.aryan.reader.paginatedreader.SemanticListItem
+import com.aryan.reader.paginatedreader.SemanticMath
+import com.aryan.reader.paginatedreader.SemanticParagraph
+import com.aryan.reader.paginatedreader.SemanticSpacer
+import com.aryan.reader.paginatedreader.SemanticTable
+import com.aryan.reader.paginatedreader.SemanticTextBlock
+import com.aryan.reader.paginatedreader.SemanticWrappingBlock
+
+object ReaderHtmlDocumentBuilder {
+    fun verticalDocument(book: SharedEpubBook, settings: ReaderSettings, searchQuery: String = ""): String {
+        val body = book.chapters.mapIndexed { index, chapter ->
+            """
+            
+

${chapter.title.escapeHtml()}

+ ${chapter.toHtml(searchQuery)} +
+ """.trimIndent() + }.joinToString("\n") + return document( + title = book.title, + settings = settings, + bookCss = book.css.values.joinToString("\n"), + body = body, + searchQuery = searchQuery + ) + } + + fun pageDocument(book: SharedEpubBook, page: ReaderPage?, settings: ReaderSettings, searchQuery: String = ""): String { + val chapter = page?.let { book.chapters.getOrNull(it.chapterIndex) } + val body = if (page == null || chapter == null) { + "
" + } else { + val blocks = chapter.semanticBlocks + .filter { block -> + val start = (block as? SemanticTextBlock)?.startCharOffsetInSource ?: return@filter false + start in page.startOffset..page.endOffset + } + .takeIf { it.isNotEmpty() } + ?.joinToString("\n") { it.toHtml(searchQuery) } + ?: page.text.textToParagraphHtml(searchQuery) + """ +
+

${page.chapterTitle.escapeHtml()}

+ $blocks +
+ """.trimIndent() + } + return document( + title = book.title, + settings = settings, + bookCss = book.css.values.joinToString("\n"), + body = body, + searchQuery = searchQuery + ) + } + + private fun document( + title: String, + settings: ReaderSettings, + bookCss: String, + body: String, + searchQuery: String + ): String { + val bg = if (settings.darkMode) "#171A17" else "#FFFCF5" + val fg = if (settings.darkMode) "#E7E3D8" else "#24231F" + val highlight = if (settings.darkMode) "#675A00" else "#FFE36E" + val align = when (settings.textAlign) { + SharedReaderTextAlign.START -> "left" + SharedReaderTextAlign.JUSTIFY -> "justify" + SharedReaderTextAlign.CENTER -> "center" + } + val family = when (settings.fontFamily) { + "Serif" -> "Georgia, 'Times New Roman', serif" + "Sans" -> "Inter, Segoe UI, Arial, sans-serif" + "Mono" -> "'Roboto Mono', Consolas, monospace" + else -> "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" + } + return """ + + + + + + ${title.escapeHtml()} + + + + $body + + + """.trimIndent() + } + + private fun SharedEpubChapter.toHtml(searchQuery: String): String { + htmlContent.takeIf { it.isNotBlank() }?.let { return it } + semanticBlocks.takeIf { it.isNotEmpty() }?.let { blocks -> + return blocks.joinToString("\n") { it.toHtml(searchQuery) } + } + return plainText.textToParagraphHtml(searchQuery) + } + + private fun SemanticBlock.toHtml(searchQuery: String): String { + return when (this) { + is SemanticHeader -> "${text.highlightAndEscape(searchQuery)}" + is SemanticParagraph -> "

${text.highlightAndEscape(searchQuery)}

" + is SemanticListItem -> "
  • ${text.highlightAndEscape(searchQuery)}
  • " + is SemanticList -> { + val tag = if (isOrdered) "ol" else "ul" + "<$tag>${items.joinToString("") { it.toHtml(searchQuery) }}" + } + is SemanticImage -> "
    \"${altText.orEmpty().escapeHtml()}\"
    " + is SemanticMath -> svgContent ?: "
    ${altText.orEmpty().highlightAndEscape(searchQuery)}
    " + is SemanticSpacer -> if (isExplicitLineBreak) "
    " else "
    " + is SemanticTable -> rows.joinToString("", "", "
    ") { row -> + row.joinToString("", "", "") { cell -> + val tag = if (cell.isHeader) "th" else "td" + "<$tag colspan=\"${cell.colspan.coerceAtLeast(1)}\">${cell.content.joinToString("") { it.toHtml(searchQuery) }}" + } + } + is SemanticFlexContainer -> children.joinToString("", "
    ", "
    ") { it.toHtml(searchQuery) } + is SemanticWrappingBlock -> floatedImage.toHtml(searchQuery) + paragraphsToWrap.joinToString("") { it.toHtml(searchQuery) } + is SemanticTextBlock -> "

    ${text.highlightAndEscape(searchQuery)}

    " + } + } + + private fun String.textToParagraphHtml(searchQuery: String): String { + return split(Regex("\\n\\s*\\n")) + .filter { it.isNotBlank() } + .joinToString("\n") { "

    ${it.trim().highlightAndEscape(searchQuery)}

    " } + .ifBlank { "

    " } + } + + private fun String.highlightAndEscape(searchQuery: String): String { + val escaped = escapeHtml() + val query = searchQuery.trim() + if (query.length < 2) return escaped + return escaped.replace(Regex(Regex.escape(query.escapeHtml()), RegexOption.IGNORE_CASE)) { + "${it.value}" + } + } + + private fun String.escapeHtml(): String { + return replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt new file mode 100644 index 0000000..97740cc --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt @@ -0,0 +1,102 @@ +package com.aryan.reader.shared.reader + +import com.aryan.reader.paginatedreader.SemanticBlock + +data class SharedEpubBook( + val id: String, + val fileName: String, + val title: String, + val author: String? = null, + val chapters: List, + val css: Map = emptyMap() +) + +data class SharedEpubChapter( + val id: String, + val title: String, + val plainText: String, + val semanticBlocks: List = emptyList(), + val htmlContent: String = "", + val baseHref: String? = null +) + +data class ReaderLocator( + val chapterIndex: Int = 0, + val charOffset: Int = 0 +) + +enum class ReaderReadingMode { + PAGINATED, + VERTICAL +} + +enum class SharedReaderTextAlign { + START, + JUSTIFY, + CENTER +} + +data class ReaderSettings( + val fontSize: Int = 18, + val lineSpacing: Float = 1.45f, + val margin: Int = 48, + val darkMode: Boolean = false, + val readingMode: ReaderReadingMode = ReaderReadingMode.PAGINATED, + val textAlign: SharedReaderTextAlign = SharedReaderTextAlign.START, + val pageWidth: Int = 760, + val fontFamily: String = "Default" +) + +data class ReaderPage( + val pageIndex: Int, + val chapterIndex: Int, + val chapterTitle: String, + val text: String, + val startOffset: Int, + val endOffset: Int +) + +data class PaginatedReaderState( + val book: SharedEpubBook, + val pages: List, + val currentPageIndex: Int = 0, + val settings: ReaderSettings = ReaderSettings() +) { + val currentPage: ReaderPage? get() = pages.getOrNull(currentPageIndex) + val progress: Float get() = if (pages.isEmpty()) 0f else ((currentPageIndex + 1).toFloat() / pages.size) * 100f + val canGoPrevious: Boolean get() = currentPageIndex > 0 + val canGoNext: Boolean get() = currentPageIndex < pages.lastIndex +} + +object SampleReaderBooks { + fun desktopWelcomeBook(): SharedEpubBook { + return SharedEpubBook( + id = "desktop_welcome", + fileName = "Desktop Welcome.epub", + title = "Episteme Desktop Reader", + author = "Episteme", + chapters = listOf( + SharedEpubChapter( + id = "intro", + title = "A Careful First Page", + plainText = """ + This is the first desktop paginated reader milestone. + + It intentionally starts with the quiet parts: page state, chapter navigation, font sizing, margins, light and dark reading surfaces, progress, and a JVM EPUB loader. The Android reader remains where it is, which keeps the mobile app protected while Windows grows its own platform layer. + + The next pieces can be added one by one: persisted locations, bookmarks, highlights, table of contents polish, keyboard shortcuts, and eventually the richer pagination engine from Android once its platform-specific parts are behind interfaces. + """.trimIndent() + ), + SharedEpubChapter( + id = "scope", + title = "What Works Here", + plainText = """ + The desktop shell can import EPUB files and extract readable spine text using the JDK zip APIs. It does not try to render complex CSS, images, MathML, or annotations yet. + + That limitation is deliberate. A plain paginated reader gives us a working Windows loop without pulling Android WebView, SAF, Room, PDF, or existing reader screens into the first KMP step. + """.trimIndent() + ) + ) + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SimplePaginator.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SimplePaginator.kt new file mode 100644 index 0000000..bbad7ae --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SimplePaginator.kt @@ -0,0 +1,109 @@ +package com.aryan.reader.shared.reader + +class SimplePaginator { + fun paginate( + book: SharedEpubBook, + settings: ReaderSettings, + viewportWidth: Int = 980, + viewportHeight: Int = 720 + ): List { + val charsPerPage = estimateCharsPerPage(settings, viewportWidth, viewportHeight) + return book.chapters.flatMapIndexed { chapterIndex, chapter -> + paginateChapter( + chapter = chapter, + chapterIndex = chapterIndex, + firstPageIndex = 0, + charsPerPage = charsPerPage + ) + }.mapIndexed { pageIndex, page -> page.copy(pageIndex = pageIndex) } + } + + fun repaginate( + state: PaginatedReaderState, + settings: ReaderSettings, + viewportWidth: Int = 980, + viewportHeight: Int = 720 + ): PaginatedReaderState { + val current = state.currentPage + val pages = paginate(state.book, settings, viewportWidth, viewportHeight) + val newIndex = if (current == null) { + 0 + } else { + pages.indexOfFirst { + it.chapterIndex == current.chapterIndex && it.startOffset <= current.startOffset && it.endOffset >= current.startOffset + }.takeIf { it >= 0 } ?: 0 + } + return state.copy(pages = pages, currentPageIndex = newIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)), settings = settings) + } + + private fun paginateChapter( + chapter: SharedEpubChapter, + chapterIndex: Int, + firstPageIndex: Int, + charsPerPage: Int + ): List { + val normalized = chapter.plainText + .replace("\r\n", "\n") + .replace(Regex("\\n{3,}"), "\n\n") + .trim() + + if (normalized.isBlank()) { + return listOf( + ReaderPage( + pageIndex = firstPageIndex, + chapterIndex = chapterIndex, + chapterTitle = chapter.title, + text = "", + startOffset = 0, + endOffset = 0 + ) + ) + } + + val pages = mutableListOf() + var start = 0 + while (start < normalized.length) { + val rawEnd = (start + charsPerPage).coerceAtMost(normalized.length) + val end = findPageBreak(normalized, start, rawEnd) + pages.add( + ReaderPage( + pageIndex = firstPageIndex + pages.size, + chapterIndex = chapterIndex, + chapterTitle = chapter.title, + text = normalized.substring(start, end).trim(), + startOffset = start, + endOffset = end + ) + ) + start = end + while (start < normalized.length && normalized[start].isWhitespace()) { + start++ + } + } + return pages + } + + private fun findPageBreak(text: String, start: Int, rawEnd: Int): Int { + if (rawEnd >= text.length) return text.length + val paragraphBreak = text.lastIndexOf("\n\n", rawEnd).takeIf { it > start + 300 } + if (paragraphBreak != null) return paragraphBreak + val sentenceBreak = text.lastIndexOfAny(charArrayOf('.', '!', '?'), rawEnd - 1).takeIf { it > start + 300 } + if (sentenceBreak != null) return sentenceBreak + 1 + val wordBreak = text.lastIndexOf(' ', rawEnd - 1).takeIf { it > start + 120 } + return wordBreak ?: rawEnd + } + + private fun estimateCharsPerPage( + settings: ReaderSettings, + viewportWidth: Int, + viewportHeight: Int + ): Int { + val usableWidth = (viewportWidth - settings.margin * 2).coerceAtLeast(360) + val usableHeight = (viewportHeight - settings.margin * 2).coerceAtLeast(360) + val averageCharWidth = settings.fontSize * 0.55f + val lineHeight = settings.fontSize * settings.lineSpacing + val charsPerLine = (usableWidth / averageCharWidth).toInt().coerceAtLeast(35) + val linesPerPage = (usableHeight / lineHeight).toInt().coerceAtLeast(12) + return (charsPerLine * linesPerPage).coerceIn(900, 4_500) + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt new file mode 100644 index 0000000..0c25eea --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt @@ -0,0 +1,887 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.LibraryBooks +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Book +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.LibraryFilters +import com.aryan.reader.shared.LibraryAction +import com.aryan.reader.shared.ReadStatusFilter +import com.aryan.reader.shared.Shelf +import com.aryan.reader.shared.ShelfType +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.SortOrder +import com.aryan.reader.shared.cardAuthor +import com.aryan.reader.shared.cardTitle +import com.aryan.reader.shared.isOpdsStream +import com.aryan.reader.shared.progressPercentValue +import com.aryan.reader.shared.reduce +import com.aryan.reader.shared.toHomeScreenModel + +enum class NonReaderLibraryTab { + BOOKS, + SHELVES, + FOLDERS +} + +@Composable +fun SharedHomeScreen( + state: SharedReaderScreenState, + onImportBooks: () -> Unit, + onOpenBook: (BookItem) -> Unit, + onToggleSelection: (String) -> Unit, + onClearSelection: () -> Unit, + onRemoveSelected: () -> Unit, + onShowBookInfo: (BookItem) -> Unit = {}, + onEditBook: (BookItem) -> Unit = {}, + onTagSelectedBooks: () -> Unit = {}, + onAddSelectedBooksToShelf: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val model = state.toHomeScreenModel() + NonReaderScreenScaffold( + title = "Home", + subtitle = "Recent books and quick access", + modifier = modifier, + trailing = { + Button(onClick = onImportBooks) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Import") + } + } + ) { + if (model.isContextualModeActive) { + SelectionToolbar( + count = model.selectedBooks.size, + onClear = onClearSelection, + onRemove = onRemoveSelected, + onTag = onTagSelectedBooks, + onAddToShelf = onAddSelectedBooksToShelf + ) + } + + if (model.isEmpty) { + SharedEmptyState( + icon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null, modifier = Modifier.size(56.dp)) }, + title = "No recent files", + body = if (model.isLibraryEmpty) "Import a few books to populate your library." else "Open books from the library and they will appear here.", + actionLabel = "Import books", + onAction = onImportBooks, + modifier = Modifier.weight(1f) + ) + } else { + BookGrid( + books = model.recentBooks, + selectedBookIds = state.selectedBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +fun SharedLibraryScreen( + state: SharedReaderScreenState, + selectedTab: NonReaderLibraryTab, + onTabChange: (NonReaderLibraryTab) -> Unit, + onStateChange: (SharedReaderScreenState) -> Unit, + onImportBooks: () -> Unit, + onOpenBook: (BookItem) -> Unit, + onToggleSelection: (String) -> Unit, + onClearSelection: () -> Unit, + onRemoveSelected: () -> Unit, + onShowBookInfo: (BookItem) -> Unit = {}, + onEditBook: (BookItem) -> Unit = {}, + onCreateShelf: () -> Unit = {}, + onRenameShelf: (Shelf) -> Unit = {}, + onDeleteShelf: (Shelf) -> Unit = {}, + onTagSelectedBooks: () -> Unit = {}, + onAddSelectedBooksToShelf: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val books = state.libraryBooks + val shelves = state.shelves + val folderShelves = remember(shelves) { shelves.filter { it.type == ShelfType.FOLDER } } + NonReaderScreenScaffold( + title = "Library", + subtitle = "Search, sort, filter, and organize local metadata", + modifier = modifier, + trailing = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + SortMenu(sortOrder = state.sortOrder, onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) }) + Button(onClick = onCreateShelf) { + Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Shelf") + } + Button(onClick = onImportBooks) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Import") + } + } + } + ) { + if (state.selectedBookIds.isNotEmpty()) { + SelectionToolbar( + count = state.selectedBookIds.size, + onClear = onClearSelection, + onRemove = onRemoveSelected, + onTag = onTagSelectedBooks, + onAddToShelf = onAddSelectedBooksToShelf + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + NonReaderLibraryTab.entries.forEach { tab -> + FilterChip( + selected = selectedTab == tab, + onClick = { onTabChange(tab) }, + leadingIcon = { + Icon( + imageVector = when (tab) { + NonReaderLibraryTab.BOOKS -> Icons.Default.Book + NonReaderLibraryTab.SHELVES -> Icons.AutoMirrored.Filled.LibraryBooks + NonReaderLibraryTab.FOLDERS -> Icons.Default.Folder + }, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + }, + label = { Text(tab.label) } + ) + } + } + + when (selectedTab) { + NonReaderLibraryTab.BOOKS -> { + LibrarySearchAndFilters( + state = state, + onStateChange = onStateChange + ) + + if (books.isEmpty()) { + SharedEmptyState( + icon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(56.dp)) }, + title = if (state.rawLibraryBooks.isEmpty()) "Your library is empty" else "No books match", + body = if (state.rawLibraryBooks.isEmpty()) "Import books to begin building your desktop library." else "Adjust search, sort, or filters to see more books.", + actionLabel = if (state.rawLibraryBooks.isEmpty()) "Import books" else "Clear filters", + onAction = { + if (state.rawLibraryBooks.isEmpty()) { + onImportBooks() + } else { + onStateChange(state.reduce(LibraryAction.SearchChanged("")).reduce(LibraryAction.FiltersChanged(LibraryFilters()))) + } + }, + modifier = Modifier.weight(1f) + ) + } else { + BookGrid( + books = books, + selectedBookIds = state.selectedBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + modifier = Modifier.weight(1f) + ) + } + } + + NonReaderLibraryTab.SHELVES -> ShelfCollection( + shelves = shelves, + selectedBookIds = state.selectedBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onRenameShelf = onRenameShelf, + onDeleteShelf = onDeleteShelf, + emptyTitle = "No shelves yet", + emptyBody = "Series, tags, and imported metadata will appear here.", + modifier = Modifier.weight(1f) + ) + + NonReaderLibraryTab.FOLDERS -> ShelfCollection( + shelves = folderShelves, + selectedBookIds = state.selectedBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + emptyTitle = "No folders yet", + emptyBody = "Imported folder metadata will appear here when available.", + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +fun SharedShelvesScreen( + shelves: List, + selectedBookIds: Set, + onOpenBook: (BookItem) -> Unit, + onToggleSelection: (String) -> Unit, + onShowBookInfo: (BookItem) -> Unit = {}, + onEditBook: (BookItem) -> Unit = {}, + onCreateShelf: () -> Unit = {}, + onRenameShelf: (Shelf) -> Unit = {}, + onDeleteShelf: (Shelf) -> Unit = {}, + modifier: Modifier = Modifier +) { + NonReaderScreenScaffold( + title = "Shelves", + subtitle = "Series, folders, and tags from library metadata", + modifier = modifier, + trailing = { + Button(onClick = onCreateShelf) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Shelf") + } + } + ) { + ShelfCollection( + shelves = shelves, + selectedBookIds = selectedBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onRenameShelf = onRenameShelf, + onDeleteShelf = onDeleteShelf, + emptyTitle = "No shelves yet", + emptyBody = "Add metadata or import folders later to populate shelves.", + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun NonReaderScreenScaffold( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + trailing: @Composable () -> Unit = {}, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + trailing() + } + content() + } +} + +@Composable +private fun SelectionToolbar( + count: Int, + onClear: () -> Unit, + onRemove: () -> Unit, + onTag: () -> Unit = {}, + onAddToShelf: () -> Unit = {} +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("$count selected", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.weight(1f)) + TextButton(onClick = onTag) { + Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Tag") + } + TextButton(onClick = onAddToShelf) { + Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Shelf") + } + TextButton(onClick = onClear) { + Text("Clear") + } + TextButton(onClick = onRemove) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Remove") + } + } + } +} + +@Composable +private fun LibrarySearchAndFilters( + state: SharedReaderScreenState, + onStateChange: (SharedReaderScreenState) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = state.searchQuery, + onValueChange = { onStateChange(state.reduce(LibraryAction.SearchChanged(it))) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + label = { Text("Search books, authors, or tags") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AssistChip( + onClick = {}, + label = { Text("Filters") }, + leadingIcon = { Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp)) } + ) + listOf(FileType.PDF, FileType.EPUB, FileType.MOBI, FileType.DOCX, FileType.TXT).forEach { type -> + FilterChip( + selected = type in state.libraryFilters.fileTypes, + onClick = { + val updated = if (type in state.libraryFilters.fileTypes) state.libraryFilters.fileTypes - type else state.libraryFilters.fileTypes + type + onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated)))) + }, + label = { Text(type.name) } + ) + } + ReadStatusFilter.entries.filterNot { it == ReadStatusFilter.ALL }.forEach { status -> + FilterChip( + selected = state.libraryFilters.readStatus == status, + onClick = { + onStateChange( + state.reduce( + LibraryAction.FiltersChanged( + state.libraryFilters.copy( + readStatus = if (state.libraryFilters.readStatus == status) ReadStatusFilter.ALL else status + ) + ) + ) + ) + }, + label = { Text(status.label) } + ) + } + state.allTags.forEach { tag -> + FilterChip( + selected = tag.id in state.libraryFilters.tagIds, + onClick = { + val updated = if (tag.id in state.libraryFilters.tagIds) state.libraryFilters.tagIds - tag.id else state.libraryFilters.tagIds + tag.id + onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(tagIds = updated)))) + }, + leadingIcon = { Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) }, + label = { Text(tag.name) } + ) + } + if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) { + TextButton(onClick = { onStateChange(state.reduce(LibraryAction.SearchChanged("")).reduce(LibraryAction.FiltersChanged(LibraryFilters()))) }) { + Text("Clear") + } + } + } + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun BookGrid( + books: List, + selectedBookIds: Set, + onOpenBook: (BookItem) -> Unit, + onToggleSelection: (String) -> Unit, + onShowBookInfo: (BookItem) -> Unit, + onEditBook: (BookItem) -> Unit, + modifier: Modifier = Modifier +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(340.dp), + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(books, key = { it.id }) { book -> + BookCard( + book = book, + selected = book.id in selectedBookIds, + onOpen = { onOpenBook(book) }, + onToggleSelection = { onToggleSelection(book.id) }, + onShowInfo = { onShowBookInfo(book) }, + onEdit = { onEditBook(book) } + ) + } + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun BookCard( + book: BookItem, + selected: Boolean, + onOpen: () -> Unit, + onToggleSelection: () -> Unit, + onShowInfo: () -> Unit, + onEdit: () -> Unit +) { + Card( + colors = CardDefaults.cardColors( + containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface + ), + border = if (selected) BorderStroke(1.dp, MaterialTheme.colorScheme.primary) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 156.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable(onClick = onOpen, onLongClick = onToggleSelection) + .padding(14.dp), + verticalAlignment = Alignment.Top + ) { + BookCover(book = book, selected = selected) + Spacer(Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = book.cardTitle(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = book.cardAuthor(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Row { + IconButton(onClick = onShowInfo, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Info, contentDescription = "Info") + } + IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + IconButton(onClick = onToggleSelection, modifier = Modifier.size(36.dp)) { + Icon( + imageVector = if (selected) Icons.Default.Check else Icons.AutoMirrored.Filled.List, + contentDescription = if (selected) "Clear selection" else "Select" + ) + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + TypeBadge(book.type) + if (book.sourceFolder != null) { + StatusBadge(Icons.Default.Folder, "Folder") + } + if (book.isOpdsStream()) { + StatusBadge(Icons.Default.Cloud, "Stream") + } + } + + ProgressSection(book.progressPercentage) + + if (book.tags.isNotEmpty()) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + items(book.tags, key = { it.id }) { tag -> + TagChip(tag.name, tag.color) + } + } + } + } + } + } +} + +@Composable +private fun BookCover(book: BookItem, selected: Boolean) { + val color = fileTypeColor(book.type) + Surface( + modifier = Modifier.size(width = 64.dp, height = 94.dp), + color = color, + contentColor = Color.White, + shape = RoundedCornerShape(7.dp), + tonalElevation = 2.dp + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Default.Book, contentDescription = null, modifier = Modifier.size(30.dp)) + if (selected) { + Surface( + modifier = Modifier.align(Alignment.TopEnd).padding(6.dp), + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.padding(3.dp).size(12.dp)) + } + } + Text( + text = book.type.name, + style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp), + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp) + ) + } + } +} + +@Composable +private fun TypeBadge(type: FileType) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) { + Text( + type.name, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp) + ) + } +} + +@Composable +private fun StatusBadge(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) { + Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, contentDescription = null, modifier = Modifier.size(13.dp)) + Spacer(Modifier.width(4.dp)) + Text(label, style = MaterialTheme.typography.labelSmall) + } + } +} + +@Composable +private fun TagChip(name: String, color: Int?) { + val tagColor = Color(color ?: 0xFF64B5F6.toInt()) + Surface( + shape = RoundedCornerShape(50), + color = tagColor.copy(alpha = 0.14f), + contentColor = tagColor + ) { + Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(12.dp)) + Spacer(Modifier.width(4.dp)) + Text(name, style = MaterialTheme.typography.labelSmall, maxLines = 1) + } + } +} + +@Composable +private fun ProgressSection(progressPercentage: Float?) { + val percent = progressPercentValue(progressPercentage) + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Progress", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.weight(1f)) + Text("$percent%", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(5.dp)) + LinearProgressIndicator( + progress = { percent / 100f }, + modifier = Modifier.fillMaxWidth().height(5.dp).clip(RoundedCornerShape(50)), + trackColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) + ) + } +} + +@Composable +private fun ShelfCollection( + shelves: List, + selectedBookIds: Set, + onOpenBook: (BookItem) -> Unit, + onToggleSelection: (String) -> Unit, + onShowBookInfo: (BookItem) -> Unit, + onEditBook: (BookItem) -> Unit, + onRenameShelf: (Shelf) -> Unit = {}, + onDeleteShelf: (Shelf) -> Unit = {}, + emptyTitle: String, + emptyBody: String, + modifier: Modifier = Modifier +) { + if (shelves.isEmpty()) { + SharedEmptyState( + icon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(56.dp)) }, + title = emptyTitle, + body = emptyBody, + modifier = modifier + ) + return + } + + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + items(shelves, key = { it.id }) { shelf -> + ShelfSection( + shelf = shelf, + selectedBookIds = selectedBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onRenameShelf = onRenameShelf, + onDeleteShelf = onDeleteShelf + ) + } + } +} + +@Composable +private fun ShelfSection( + shelf: Shelf, + selectedBookIds: Set, + onOpenBook: (BookItem) -> Unit, + onToggleSelection: (String) -> Unit, + onShowBookInfo: (BookItem) -> Unit, + onEditBook: (BookItem) -> Unit, + onRenameShelf: (Shelf) -> Unit, + onDeleteShelf: (Shelf) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = when (shelf.type) { + ShelfType.FOLDER -> Icons.Default.Folder + ShelfType.TAG -> Icons.Default.Tag + else -> Icons.AutoMirrored.Filled.LibraryBooks + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(8.dp)) + Text(shelf.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.width(8.dp)) + AssistChip(onClick = {}, label = { Text("${shelf.bookCount}") }) + if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") { + Spacer(Modifier.weight(1f)) + IconButton(onClick = { onRenameShelf(shelf) }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Edit, contentDescription = "Rename shelf", modifier = Modifier.size(18.dp)) + } + IconButton(onClick = { onDeleteShelf(shelf) }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Delete, contentDescription = "Delete shelf", modifier = Modifier.size(18.dp)) + } + } + } + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(shelf.books, key = { it.id }) { book -> + Box(modifier = Modifier.width(360.dp)) { + BookCard( + book = book, + selected = book.id in selectedBookIds, + onOpen = { onOpenBook(book) }, + onToggleSelection = { onToggleSelection(book.id) }, + onShowInfo = { onShowBookInfo(book) }, + onEdit = { onEditBook(book) } + ) + } + } + } + } +} + +@Composable +private fun SortMenu( + sortOrder: SortOrder, + onSortOrderChange: (SortOrder) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + Box { + Button(onClick = { expanded = true }) { + Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(sortOrder.label) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + SortOrder.entries.forEach { order -> + DropdownMenuItem( + text = { Text(order.label) }, + onClick = { + expanded = false + onSortOrderChange(order) + } + ) + } + } + } +} + +@Composable +private fun SharedEmptyState( + icon: @Composable () -> Unit, + title: String, + body: String, + modifier: Modifier = Modifier, + actionLabel: String? = null, + onAction: (() -> Unit)? = null +) { + Surface( + modifier = modifier.fillMaxWidth().fillMaxHeight(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surfaceVariant) { + Box(Modifier.padding(18.dp), contentAlignment = Alignment.Center) { + icon() + } + } + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text( + body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 420.dp) + ) + if (actionLabel != null && onAction != null) { + Spacer(Modifier.height(6.dp)) + Button(onClick = onAction) { + Text(actionLabel) + } + } + } + } + } +} + +private val NonReaderLibraryTab.label: String + get() = when (this) { + NonReaderLibraryTab.BOOKS -> "Books" + NonReaderLibraryTab.SHELVES -> "Shelves" + NonReaderLibraryTab.FOLDERS -> "Folders" + } + +private val SortOrder.label: String + get() = when (this) { + SortOrder.RECENT -> "Recent" + SortOrder.TITLE_ASC -> "Title A-Z" + SortOrder.AUTHOR_ASC -> "Author A-Z" + SortOrder.PERCENT_ASC -> "Progress low" + SortOrder.PERCENT_DESC -> "Progress high" + SortOrder.SIZE_ASC -> "Size small" + SortOrder.SIZE_DESC -> "Size large" + } + +private val ReadStatusFilter.label: String + get() = when (this) { + ReadStatusFilter.ALL -> "All" + ReadStatusFilter.UNREAD -> "Unread" + ReadStatusFilter.IN_PROGRESS -> "In progress" + ReadStatusFilter.COMPLETED -> "Complete" + } + +private fun fileTypeColor(type: FileType): Color { + return when (type) { + FileType.PDF -> Color(0xFF9C4146) + FileType.EPUB, FileType.MOBI -> Color(0xFF006C4C) + FileType.DOCX, FileType.ODT, FileType.FODT -> Color(0xFF0F52BA) + FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(0xFF705D49) + else -> Color(0xFF5D6B82) + } +} diff --git a/shared/src/desktopMain/kotlin/com/aryan/reader/shared/Platform.desktop.kt b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/Platform.desktop.kt new file mode 100644 index 0000000..d1f14a5 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/Platform.desktop.kt @@ -0,0 +1,3 @@ +package com.aryan.reader.shared + +actual fun currentTimestamp(): Long = System.currentTimeMillis() diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/HtmlParser.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt similarity index 88% rename from app/src/main/java/com/aryan/reader/paginatedreader/HtmlParser.kt rename to shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt index 50678ab..72e8492 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/HtmlParser.kt +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt @@ -19,10 +19,6 @@ */ package com.aryan.reader.paginatedreader -import android.graphics.BitmapFactory -import android.os.Build -import timber.log.Timber -import androidx.annotation.RequiresApi import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.text.ParagraphStyle @@ -40,12 +36,35 @@ import org.jsoup.nodes.Element import org.jsoup.nodes.Node import org.jsoup.nodes.TextNode import org.jsoup.select.Selector -import java.io.File -import java.net.URLDecoder -import java.nio.file.Paths private val unsupportedPseudoElementRegex = Regex("::?(before|after|first-letter|first-line|marker|selection)", RegexOption.IGNORE_CASE) +interface HtmlResourceResolver { + fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? + fun readText(path: String): String? + fun imageDimensions(path: String): Pair? +} + +interface HtmlFontFamilyLoader { + fun load(fontFaces: List, extractionBasePath: String): Map +} + +object NoOpHtmlResourceResolver : HtmlResourceResolver { + override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? = null + override fun readText(path: String): String? = null + override fun imageDimensions(path: String): Pair? = null +} + +object NoOpHtmlFontFamilyLoader : HtmlFontFamilyLoader { + override fun load(fontFaces: List, extractionBasePath: String): Map = emptyMap() +} + +private object HtmlParserLog { + fun d(@Suppress("UNUSED_PARAMETER") message: String) = Unit + fun w(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit + fun e(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit +} + private fun Element.getCfiPath(): String { val path = mutableListOf() var currentNode: Node? = this @@ -76,7 +95,6 @@ private fun String.capitalizeWords(): String = * The public entry point for converting HTML to a list of [SemanticBlock]s. * This function sets up a parsing context and delegates the work to a [SemanticHtmlParser] instance. */ -@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) fun htmlToSemanticBlocks( html: String, cssRules: OptimizedCssRules, @@ -87,7 +105,9 @@ fun htmlToSemanticBlocks( fontFamilyMap: Map, constraints: Constraints, imageDimensionsCache: Map> = emptyMap(), - mathSvgCache: Map = emptyMap() + mathSvgCache: Map = emptyMap(), + resourceResolver: HtmlResourceResolver = NoOpHtmlResourceResolver, + fontFamilyLoader: HtmlFontFamilyLoader = NoOpHtmlFontFamilyLoader ): List { return SemanticHtmlParser( cssRules, @@ -98,14 +118,15 @@ fun htmlToSemanticBlocks( fontFamilyMap, constraints, imageDimensionsCache, - mathSvgCache + mathSvgCache, + resourceResolver, + fontFamilyLoader ).parse(html) } /** * A stateful parser that holds the context for a single HTML-to-SemanticBlock conversion. */ -@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) private class SemanticHtmlParser( cssRules: OptimizedCssRules, private val textStyle: TextStyle, @@ -115,7 +136,9 @@ private class SemanticHtmlParser( fontFamilyMap: Map, private val constraints: Constraints, private val imageDimensionsCache: Map>, - private val mathSvgCache: Map + private val mathSvgCache: Map, + private val resourceResolver: HtmlResourceResolver, + private val fontFamilyLoader: HtmlFontFamilyLoader ) { private val styleCache = mutableMapOf() private var combinedRules: OptimizedCssRules = cssRules @@ -127,7 +150,7 @@ private class SemanticHtmlParser( val inlineCssContent = document.head().select("style").joinToString(separator = "\n") { it.data() } if (inlineCssContent.isNotBlank()) { - Timber.d("Found inline