From 7a53342f9d308a0476b5be6632eec9a0d48b9c45 Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Tue, 27 Jan 2026 04:18:09 -0500 Subject: [PATCH 01/22] fix: Allow line break after ellipsis and underscore (#425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * Add additional punctuation marks to the list of characters that can be immediately followed by a line break even where there is no explicit space ## Additional Context * Huge appreciation to @osteotek for his amazing work on hyphenation. Reading on the device is so much better now. * I am getting bad line breaks when ellipses (…) are between words and book file does not explicitly include some kind of breaking space. * Per [discussion](https://github.com/crosspoint-reader/crosspoint-reader/pull/305#issuecomment-3765411406), several new characters are added in this PR to the `isExplicitHyphen` list to allow line breaks immediately after them: Character | Unicode | Usage | Why include it? -- | -- | -- | -- Solidus (Slash) | U+002F | / | Essential for breaking URLs and "and/or" constructs. Backslash | U+005C | \ | Critical for technical text, file paths, and coding documentation. Underscore | U+005F | _ | Prevents "runaway" line lengths in usernames or code snippets. Middle Dot | U+00B7 | · | Acts as a semantic separator in dictionaries or stylistic lists. Ellipsis | U+2026 | … | Prevents justification failure when dialogue lacks following spaces. Midline Horizontal Ellipsis | U+22EF | ⋯ | Useful for mathematical sequences and technical notation. ### Example: This shows an example of what line breaking looks like *with* this PR. Note the line break after "matter…" (which would not previously have been allowed). It's particularly important here because the book includes non-breaking spaces in "Mr. Aldrich" and "Mr. Rockefeller." ![IMG_2917](https://github.com/user-attachments/assets/8fa610a9-91dd-407f-8526-0019a8a7195f) --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? **PARTIALLY** --- lib/Epub/Epub/hyphenation/HyphenationCommon.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp b/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp index 99584fde..0a6b7a92 100644 --- a/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp +++ b/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp @@ -125,6 +125,8 @@ bool isExplicitHyphen(const uint32_t cp) { case 0xFE58: // small em dash case 0xFE63: // small hyphen-minus case 0xFF0D: // fullwidth hyphen-minus + case 0x005F: // Underscore + case 0x2026: // Ellipsis return true; default: return false; From 67a679ab41053506ebfa17e2ee94d55b1690e10a Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Tue, 27 Jan 2026 12:20:48 +0300 Subject: [PATCH 02/22] fix: Add .vs folder to .gitignore (#466) ## Summary * Adds Visual Studio project files folder to .gitignore Otherwise: image --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0cc30a26..754c9f68 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ .vscode lib/EpdFont/fontsrc *.generated.h +.vs build **/__pycache__/ \ No newline at end of file From 9224bc3f8c53cda861b2e1a60ec1d00642b1cb88 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Tue, 27 Jan 2026 10:21:15 +0100 Subject: [PATCH 03/22] fix: #348 fit cover artifacts 2 (#465) Supersedes #358 and includes the bugfix from #351 --- lib/Epub/Epub.cpp | 6 +++--- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 14 +++++++++----- lib/JpegToBmpConverter/JpegToBmpConverter.h | 4 ++-- src/activities/boot_sleep/SleepActivity.cpp | 1 + 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 78607573..33f920b4 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -359,7 +359,7 @@ const std::string& Epub::getLanguage() const { } std::string Epub::getCoverBmpPath(bool cropped) const { - const auto coverFileName = "cover" + cropped ? "_crop" : ""; + const auto coverFileName = std::string("cover") + (cropped ? "_crop" : ""); return cachePath + "/" + coverFileName + ".bmp"; } @@ -382,7 +382,7 @@ bool Epub::generateCoverBmp(bool cropped) const { if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { - Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); + Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image (%s mode)\n", millis(), cropped ? "cropped" : "fit"); const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; FsFile coverJpg; @@ -401,7 +401,7 @@ bool Epub::generateCoverBmp(bool cropped) const { coverJpg.close(); return false; } - const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); + const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped); coverJpg.close(); coverBmp.close(); SdMan.remove(coverJpgTempPath.c_str()); diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 01451a05..84ac1d58 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -200,7 +200,7 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un // Internal implementation with configurable target size and bit depth bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, - bool oneBit) { + bool oneBit, bool crop) { Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight); @@ -242,8 +242,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm const float scaleToFitWidth = static_cast(targetWidth) / imageInfo.m_width; const float scaleToFitHeight = static_cast(targetHeight) / imageInfo.m_height; // We scale to the smaller dimension, so we can potentially crop later. - // TODO: ideally, we already crop here. - const float scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; + float scale = 1.0; + if (crop) { // if we will crop, scale to the smaller dimension + scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; + } else { // else, scale to the larger dimension to fit + scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; + } outWidth = static_cast(imageInfo.m_width * scale); outHeight = static_cast(imageInfo.m_height * scale); @@ -550,8 +554,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm } // Core function: Convert JPEG file to 2-bit BMP (uses default target size) -bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { - return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false); +bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut, bool crop) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop); } // Convert with custom target size (for thumbnails, 2-bit) diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index d5e9b950..9b92bb6d 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -8,10 +8,10 @@ class JpegToBmpConverter { static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, unsigned char* pBytes_actually_read, void* pCallback_data); static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, - bool oneBit); + bool oneBit, bool crop = true); public: - static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut); + static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut, bool crop = true); // Convert with custom target size (for thumbnails) static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); // Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index c0d6844f..40341e5f 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -260,6 +260,7 @@ void SleepActivity::renderCoverSleepScreen() const { if (SdMan.openFileForRead("SLP", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { + Serial.printf("[SLP] Rendering sleep cover: %s\n", coverBmpPath); renderBitmapSleepScreen(bitmap); return; } From e858ebbe88f0f95642aeca72b47672022b7937b8 Mon Sep 17 00:00:00 2001 From: Boris Faure Date: Tue, 27 Jan 2026 11:15:42 +0100 Subject: [PATCH 04/22] feat: add new configuration for front buttons, more usable on landscape ccw (#460) When reading on Landscape Counter ClockWise mode, the left/right button appear inverted: the upper button (left) goes down and the lower button (right) goes up. Discussion: #449 ## Summary * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) Add a new configuration for the front buttons: Back, Confirm, Right, Left --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- USER_GUIDE.md | 1 + src/CrossPointSettings.h | 7 ++++++- src/MappedInputManager.cpp | 16 ++++++++++++++++ src/activities/settings/SettingsActivity.cpp | 5 +++-- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index d670abb7..67dee480 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -116,6 +116,7 @@ The Settings screen allows you to configure the device's behavior. There are a f - Back, Confirm, Left, Right (default) - Left, Right, Back, Confirm - Left, Back, Confirm, Right + - Back, Confirm, Right, Left - **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. - **Long-press Chapter Skip**: Set whether long-pressing page turn buttons skip to the next/previous chapter. - "Chapter Skip" (default) - Long-pressing skips to next/previous chapter diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 8ce32a2c..2c33beb3 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -32,7 +32,12 @@ class CrossPointSettings { // Front button layout options // Default: Back, Confirm, Left, Right // Swapped: Left, Right, Back, Confirm - enum FRONT_BUTTON_LAYOUT { BACK_CONFIRM_LEFT_RIGHT = 0, LEFT_RIGHT_BACK_CONFIRM = 1, LEFT_BACK_CONFIRM_RIGHT = 2 }; + enum FRONT_BUTTON_LAYOUT { + BACK_CONFIRM_LEFT_RIGHT = 0, + LEFT_RIGHT_BACK_CONFIRM = 1, + LEFT_BACK_CONFIRM_RIGHT = 2, + BACK_CONFIRM_RIGHT_LEFT = 3 + }; // Side button layout options // Default: Previous, Next diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index 1b038446..994dda5f 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -14,6 +14,9 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: return InputManager::BTN_CONFIRM; case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: + /* fall through */ + case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: + /* fall through */ default: return InputManager::BTN_BACK; } @@ -24,15 +27,22 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: return InputManager::BTN_LEFT; case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: + /* fall through */ + case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: + /* fall through */ default: return InputManager::BTN_CONFIRM; } case Button::Left: switch (frontLayout) { case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM: + /* fall through */ case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: return InputManager::BTN_BACK; + case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: + return InputManager::BTN_RIGHT; case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: + /* fall through */ default: return InputManager::BTN_LEFT; } @@ -40,8 +50,12 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt switch (frontLayout) { case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM: return InputManager::BTN_CONFIRM; + case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: + return InputManager::BTN_LEFT; case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: + /* fall through */ case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: + /* fall through */ default: return InputManager::BTN_RIGHT; } @@ -56,6 +70,7 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt case CrossPointSettings::NEXT_PREV: return InputManager::BTN_DOWN; case CrossPointSettings::PREV_NEXT: + /* fall through */ default: return InputManager::BTN_UP; } @@ -64,6 +79,7 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt case CrossPointSettings::NEXT_PREV: return InputManager::BTN_UP; case CrossPointSettings::PREV_NEXT: + /* fall through */ default: return InputManager::BTN_DOWN; } diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 943fdb4c..45b7a12d 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -37,8 +37,9 @@ const SettingInfo readerSettings[readerSettingsCount] = { constexpr int controlsSettingsCount = 4; const SettingInfo controlsSettings[controlsSettingsCount] = { - SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout, - {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}), + SettingInfo::Enum( + "Front Button Layout", &CrossPointSettings::frontButtonLayout, + {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}), SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, {"Prev, Next", "Next, Prev"}), SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), From b8ebcf5867abd22ba472ab7420fa6933efeb661a Mon Sep 17 00:00:00 2001 From: Sam Davis Date: Tue, 27 Jan 2026 21:24:39 +1100 Subject: [PATCH 05/22] fix: remove decimal places from progress % (#507) ## Summary Addresses https://github.com/crosspoint-reader/crosspoint-reader/issues/504 - Reverts book progress % to showing as an integer instead of with a decimal place - This was changed to 1 decimal point of precision in https://github.com/crosspoint-reader/crosspoint-reader/pull/232 from what I can tell - As this wasn't the primary intention of that PR, I'm assuming it was left in accidentally IMO having a decimal place of precision is too much for something as vague as book completion percent. This de-clutters the status bar and prevents extra updates as you change pages. --- ### AI Usage YES --- src/activities/reader/EpubReaderActivity.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 6ff39c5e..a2e14259 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -448,7 +448,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in // Right aligned text for progress counter char progressStr[32]; - snprintf(progressStr, sizeof(progressStr), "%d/%d %.1f%%", section->currentPage + 1, section->pageCount, + snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", section->currentPage + 1, section->pageCount, bookProgress); const std::string progress = progressStr; progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); From 5d369df6bee6db2eb6afd207824d7c023e80bdf2 Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Tue, 27 Jan 2026 05:25:25 -0500 Subject: [PATCH 06/22] fix: Chapter Selection UI bugs when koreader sync is enabled, and clarify default kosync URL (#501) ## Summary * Fixes #475 * Fixes #477 * Closes #428 ## Additional Context * Updates to `src/activities/reader/EpubReaderChapterSelectionActivity.cpp` are copied verbatim from #433 (thanks to @jonasdiemer) * Update to `src/activities/settings/KOReaderSettingsActivity.cpp` per discussion with @itsthisjustin at #428 Tested on my device with several books and koreader sync turned on and off. --- ### AI Usage Did you use AI tools to help write this code? _NO_ --- .../reader/EpubReaderChapterSelectionActivity.cpp | 13 +++++++------ .../settings/KOReaderSettingsActivity.cpp | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index ad4dd2ff..1b35e143 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -188,22 +188,23 @@ void EpubReaderChapterSelectionActivity::renderScreen() { const auto pageStartIndex = selectorIndex / pageItems * pageItems; renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); - for (int itemIndex = pageStartIndex; itemIndex < totalItems && itemIndex < pageStartIndex + pageItems; itemIndex++) { - const int displayY = 60 + (itemIndex % pageItems) * 30; + for (int i = 0; i < pageItems; i++) { + int itemIndex = pageStartIndex + i; + if (itemIndex >= totalItems) break; + const int displayY = 60 + i * 30; const bool isSelected = (itemIndex == selectorIndex); if (isSyncItem(itemIndex)) { - // Draw sync option (at top or bottom) renderer.drawText(UI_10_FONT_ID, 20, displayY, ">> Sync Progress", !isSelected); } else { - // Draw TOC item (account for top sync offset) const int tocIndex = tocIndexFromItemIndex(itemIndex); auto item = epub->getTocItem(tocIndex); + const int indentSize = 20 + (item.level - 1) * 15; const std::string chapterName = renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - indentSize); - renderer.drawText(UI_10_FONT_ID, indentSize, 60 + (tocIndex % pageItems) * 30, chapterName.c_str(), - tocIndex != selectorIndex); + + renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected); } } diff --git a/src/activities/settings/KOReaderSettingsActivity.cpp b/src/activities/settings/KOReaderSettingsActivity.cpp index 6eb22c8e..71003433 100644 --- a/src/activities/settings/KOReaderSettingsActivity.cpp +++ b/src/activities/settings/KOReaderSettingsActivity.cpp @@ -194,7 +194,7 @@ void KOReaderSettingsActivity::render() { } else if (i == 1) { status = KOREADER_STORE.getPassword().empty() ? "[Not Set]" : "[Set]"; } else if (i == 2) { - status = KOREADER_STORE.getServerUrl().empty() ? "[Not Set]" : "[Set]"; + status = KOREADER_STORE.getServerUrl().empty() ? "[Default]" : "[Custom]"; } else if (i == 3) { status = KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? "[Filename]" : "[Binary]"; } else if (i == 4) { From 0bc0baa966b543960e3402a0c6ea0f7ff9567bb7 Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Tue, 27 Jan 2026 15:25:48 +0500 Subject: [PATCH 07/22] feat: treat .md files as .txt (#498) ## Summary * Quick fix for markdown reading - open them as txt files --- src/activities/home/MyLibraryActivity.cpp | 3 ++- src/activities/reader/ReaderActivity.cpp | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 9e6f3734..1db32397 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -120,7 +120,8 @@ void MyLibraryActivity::loadFiles() { } else { auto filename = std::string(name); if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") || - StringUtils::checkFileExtension(filename, ".xtc") || StringUtils::checkFileExtension(filename, ".txt")) { + StringUtils::checkFileExtension(filename, ".xtc") || StringUtils::checkFileExtension(filename, ".txt") || + StringUtils::checkFileExtension(filename, ".md")) { files.emplace_back(filename); } } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 14d6623c..04240b3c 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -22,9 +22,8 @@ bool ReaderActivity::isXtcFile(const std::string& path) { } bool ReaderActivity::isTxtFile(const std::string& path) { - if (path.length() < 4) return false; - std::string ext4 = path.substr(path.length() - 4); - return ext4 == ".txt" || ext4 == ".TXT"; + return StringUtils::checkFileExtension(path, ".txt") || + StringUtils::checkFileExtension(path, ".md"); // Treat .md as txt files (until we have a markdown reader) } std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { From 13f0ebed9695566ec699482b0f390fddae2efa31 Mon Sep 17 00:00:00 2001 From: Brendan O'Leary Date: Tue, 27 Jan 2026 11:26:17 +0100 Subject: [PATCH 08/22] UX improvment to Forget Network page (#484) ## Summary On the Forget Network page * Update the default option to be DON'T forget the network * Make the options clearer ("Cancel" and "Forget network") * Unify the button hints to match the rest of the UI ## Additional Context Closes #427 --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? PARTIALLY --- .../network/WifiSelectionActivity.cpp | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 07d44418..5c45223b 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -354,8 +354,8 @@ void WifiSelectionActivity::loop() { updateRequired = true; } } else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - if (forgetPromptSelection == 0) { - // User chose "Yes" - forget the network + if (forgetPromptSelection == 1) { + // User chose "Forget network" - forget the network xSemaphoreTake(renderingMutex, portMAX_DELAY); WIFI_STORE.removeCredential(selectedSSID); xSemaphoreGive(renderingMutex); @@ -366,7 +366,7 @@ void WifiSelectionActivity::loop() { network->hasSavedPassword = false; } } - // Go back to network list + // Go back to network list (whether Cancel or Forget network was selected) state = WifiSelectionState::NETWORK_LIST; updateRequired = true; } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { @@ -391,7 +391,7 @@ void WifiSelectionActivity::loop() { // If we used saved credentials, offer to forget the network if (usedSavedPassword) { state = WifiSelectionState::FORGET_PROMPT; - forgetPromptSelection = 0; // Default to "Yes" + forgetPromptSelection = 0; // Default to "Cancel" } else { // Go back to network list on failure state = WifiSelectionState::NETWORK_LIST; @@ -623,7 +623,9 @@ void WifiSelectionActivity::renderConnected() const { const std::string ipInfo = "IP Address: " + connectedIP; renderer.drawCenteredText(UI_10_FONT_ID, top + 40, ipInfo.c_str()); - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue"); + // Use centralized button hints + const auto labels = mappedInput.mapLabels("", "Continue", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderSavePrompt() const { @@ -663,7 +665,9 @@ void WifiSelectionActivity::renderSavePrompt() const { renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No"); } - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm"); + // Use centralized button hints + const auto labels = mappedInput.mapLabels("« Skip", "Select", "Left", "Right"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderConnectionFailed() const { @@ -673,7 +677,10 @@ void WifiSelectionActivity::renderConnectionFailed() const { renderer.drawCenteredText(UI_12_FONT_ID, top - 20, "Connection Failed", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, top + 20, connectionError.c_str()); - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue"); + + // Use centralized button hints + const auto labels = mappedInput.mapLabels("« Back", "Continue", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderForgetPrompt() const { @@ -692,26 +699,28 @@ void WifiSelectionActivity::renderForgetPrompt() const { renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Remove saved password?"); - // Draw Yes/No buttons + // Draw Cancel/Forget network buttons const int buttonY = top + 80; - constexpr int buttonWidth = 60; + constexpr int buttonWidth = 120; constexpr int buttonSpacing = 30; constexpr int totalWidth = buttonWidth * 2 + buttonSpacing; const int startX = (pageWidth - totalWidth) / 2; - // Draw "Yes" button + // Draw "Cancel" button if (forgetPromptSelection == 0) { - renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Yes]"); + renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Cancel]"); } else { - renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, "Yes"); + renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, "Cancel"); } - // Draw "No" button + // Draw "Forget network" button if (forgetPromptSelection == 1) { - renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]"); + renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[Forget network]"); } else { - renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No"); + renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "Forget network"); } - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm"); + // Use centralized button hints + const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } From 3a761b18af6d5f287a43b1c9477714dbb7fc8198 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 27 Jan 2026 06:02:38 -0500 Subject: [PATCH 09/22] Refactors Calibre Wireless Device & Calibre Library (#404) Our esp32 consistently dropped the last few packets of the TCP transfer in the old implementation. Only about 1/5 transfers would complete. I've refactored that entire system into an actual Calibre Device Plugin that basically uses the exact same system as the web server's file transfer protocol. I kept them separate so that we don't muddy up the existing file transfer stuff even if it's basically the same at the end of the day I didn't want to limit our ability to change it later. I've also added basic auth to OPDS and renamed that feature to OPDS Browser to just disassociate it from Calibre. --------- Co-authored-by: Arthur Tazhitdinov Co-authored-by: Dave Allie --- USER_GUIDE.md | 14 +- src/CrossPointSettings.cpp | 20 +- src/CrossPointSettings.h | 2 + .../browser/OpdsBookBrowserActivity.cpp | 5 +- src/activities/home/HomeActivity.cpp | 4 +- .../network/CalibreConnectActivity.cpp | 276 +++++++ .../network/CalibreConnectActivity.h | 55 ++ .../network/CalibreWirelessActivity.cpp | 756 ------------------ .../network/CalibreWirelessActivity.h | 135 ---- .../network/CrossPointWebServerActivity.cpp | 22 +- .../network/CrossPointWebServerActivity.h | 2 +- .../network/NetworkModeSelectionActivity.cpp | 18 +- .../network/NetworkModeSelectionActivity.h | 3 +- .../settings/CalibreSettingsActivity.cpp | 84 +- .../settings/CalibreSettingsActivity.h | 4 +- .../settings/CategorySettingsActivity.cpp | 2 +- src/activities/settings/SettingsActivity.cpp | 2 +- src/network/CrossPointWebServer.cpp | 118 ++- src/network/CrossPointWebServer.h | 20 +- src/network/HttpDownloader.cpp | 17 + 20 files changed, 614 insertions(+), 945 deletions(-) create mode 100644 src/activities/network/CalibreConnectActivity.cpp create mode 100644 src/activities/network/CalibreConnectActivity.h delete mode 100644 src/activities/network/CalibreWirelessActivity.cpp delete mode 100644 src/activities/network/CalibreWirelessActivity.h diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 67dee480..f160af74 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -81,6 +81,18 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con > [!TIP] > Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details. +### 3.4.1 Calibre Wireless Transfers + +CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin. + +1. Install the plugin in Calibre: + - Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin. + - Download the zip file. + - Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file. +2. On the device: File Transfer → Connect to Calibre → Join a network. +3. Make sure your computer is on the same WiFi network. +4. In Calibre, click "Send to device" to transfer books. + ### 3.5 Settings The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: @@ -132,7 +144,7 @@ The Settings screen allows you to configure the device's behavior. There are a f - **Reader Paragraph Alignment**: Set the alignment of paragraphs; options are "Justified" (default), "Left", "Center", or "Right". - **Time to Sleep**: Set the duration of inactivity before the device automatically goes to sleep. - **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting. -- **Calibre Settings**: Set up integration for accessing a Calibre web library or connecting to Calibre as a wireless device. +- **OPDS Browser**: Configure OPDS server settings for browsing and downloading books. Set the server URL (for Calibre Content Server, add `/opds` to the end), and optionally configure username and password for servers requiring authentication. Note: Only HTTP Basic authentication is supported. If using Calibre Content Server with authentication enabled, you must set it to use Basic authentication instead of the default Digest authentication. - **Check for updates**: Check for firmware updates over WiFi. ### 3.6 Sleep Screen diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index f5e8ded5..ea26ad91 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 20; +constexpr uint8_t SETTINGS_COUNT = 22; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -49,6 +49,9 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, hideBatteryPercentage); serialization::writePod(outputFile, longPressChapterSkip); serialization::writePod(outputFile, hyphenationEnabled); + // New fields added at end for backward compatibility + serialization::writeString(outputFile, std::string(opdsUsername)); + serialization::writeString(outputFile, std::string(opdsPassword)); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -120,6 +123,21 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hyphenationEnabled); if (++settingsRead >= fileSettingsCount) break; + // New fields added at end for backward compatibility + { + std::string usernameStr; + serialization::readString(inputFile, usernameStr); + strncpy(opdsUsername, usernameStr.c_str(), sizeof(opdsUsername) - 1); + opdsUsername[sizeof(opdsUsername) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; + { + std::string passwordStr; + serialization::readString(inputFile, passwordStr); + strncpy(opdsPassword, passwordStr.c_str(), sizeof(opdsPassword) - 1); + opdsPassword[sizeof(opdsPassword) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 2c33beb3..f8892bef 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -95,6 +95,8 @@ class CrossPointSettings { uint8_t screenMargin = 5; // OPDS browser settings char opdsServerUrl[128] = ""; + char opdsUsername[64] = ""; + char opdsPassword[64] = ""; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press chapter skip on side buttons diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 555cba91..2bde74de 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -18,7 +18,6 @@ namespace { constexpr int PAGE_ITEMS = 23; constexpr int SKIP_PAGE_MS = 700; -constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL } // namespace void OpdsBookBrowserActivity::taskTrampoline(void* param) { @@ -33,7 +32,7 @@ void OpdsBookBrowserActivity::onEnter() { state = BrowserState::CHECK_WIFI; entries.clear(); navigationHistory.clear(); - currentPath = OPDS_ROOT_PATH; + currentPath = ""; // Root path - user provides full URL in settings selectorIndex = 0; errorMessage.clear(); statusMessage = "Checking WiFi..."; @@ -172,7 +171,7 @@ void OpdsBookBrowserActivity::render() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre Library", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD); if (state == BrowserState::CHECK_WIFI) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index eb11ba95..3389e80d 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -502,8 +502,8 @@ void HomeActivity::render() { // Build menu items dynamically std::vector menuItems = {"My Library", "File Transfer", "Settings"}; if (hasOpdsUrl) { - // Insert Calibre Library after My Library - menuItems.insert(menuItems.begin() + 1, "Calibre Library"); + // Insert OPDS Browser after My Library + menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); } const int menuTileWidth = pageWidth - 2 * margin; diff --git a/src/activities/network/CalibreConnectActivity.cpp b/src/activities/network/CalibreConnectActivity.cpp new file mode 100644 index 00000000..8aa60c40 --- /dev/null +++ b/src/activities/network/CalibreConnectActivity.cpp @@ -0,0 +1,276 @@ +#include "CalibreConnectActivity.h" + +#include +#include +#include +#include + +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "WifiSelectionActivity.h" +#include "fontIds.h" + +namespace { +constexpr const char* HOSTNAME = "crosspoint"; +} // namespace + +void CalibreConnectActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void CalibreConnectActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + updateRequired = true; + state = CalibreConnectState::WIFI_SELECTION; + connectedIP.clear(); + connectedSSID.clear(); + lastHandleClientTime = 0; + lastProgressReceived = 0; + lastProgressTotal = 0; + currentUploadName.clear(); + lastCompleteName.clear(); + lastCompleteAt = 0; + exitRequested = false; + + xTaskCreate(&CalibreConnectActivity::taskTrampoline, "CalibreConnectTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + if (WiFi.status() != WL_CONNECTED) { + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, + [this](const bool connected) { onWifiSelectionComplete(connected); })); + } else { + connectedIP = WiFi.localIP().toString().c_str(); + connectedSSID = WiFi.SSID().c_str(); + startWebServer(); + } +} + +void CalibreConnectActivity::onExit() { + ActivityWithSubactivity::onExit(); + + stopWebServer(); + MDNS.end(); + + delay(50); + WiFi.disconnect(false); + delay(30); + WiFi.mode(WIFI_OFF); + delay(30); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void CalibreConnectActivity::onWifiSelectionComplete(const bool connected) { + if (!connected) { + exitActivity(); + onComplete(); + return; + } + + if (subActivity) { + connectedIP = static_cast(subActivity.get())->getConnectedIP(); + } else { + connectedIP = WiFi.localIP().toString().c_str(); + } + connectedSSID = WiFi.SSID().c_str(); + exitActivity(); + startWebServer(); +} + +void CalibreConnectActivity::startWebServer() { + state = CalibreConnectState::SERVER_STARTING; + updateRequired = true; + + if (MDNS.begin(HOSTNAME)) { + // mDNS is optional for the Calibre plugin but still helpful for users. + Serial.printf("[%lu] [CAL] mDNS started: http://%s.local/\n", millis(), HOSTNAME); + } + + webServer.reset(new CrossPointWebServer()); + webServer->begin(); + + if (webServer->isRunning()) { + state = CalibreConnectState::SERVER_RUNNING; + updateRequired = true; + } else { + state = CalibreConnectState::ERROR; + updateRequired = true; + } +} + +void CalibreConnectActivity::stopWebServer() { + if (webServer) { + webServer->stop(); + webServer.reset(); + } +} + +void CalibreConnectActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + exitRequested = true; + } + + if (webServer && webServer->isRunning()) { + const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; + if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { + Serial.printf("[%lu] [CAL] WARNING: %lu ms gap since last handleClient\n", millis(), timeSinceLastHandleClient); + } + + esp_task_wdt_reset(); + constexpr int MAX_ITERATIONS = 80; + for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { + webServer->handleClient(); + if ((i & 0x07) == 0x07) { + esp_task_wdt_reset(); + } + if ((i & 0x0F) == 0x0F) { + yield(); + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + exitRequested = true; + break; + } + } + } + lastHandleClientTime = millis(); + + const auto status = webServer->getWsUploadStatus(); + bool changed = false; + if (status.inProgress) { + if (status.received != lastProgressReceived || status.total != lastProgressTotal || + status.filename != currentUploadName) { + lastProgressReceived = status.received; + lastProgressTotal = status.total; + currentUploadName = status.filename; + changed = true; + } + } else if (lastProgressReceived != 0 || lastProgressTotal != 0) { + lastProgressReceived = 0; + lastProgressTotal = 0; + currentUploadName.clear(); + changed = true; + } + if (status.lastCompleteAt != 0 && status.lastCompleteAt != lastCompleteAt) { + lastCompleteAt = status.lastCompleteAt; + lastCompleteName = status.lastCompleteName; + changed = true; + } + if (lastCompleteAt > 0 && (millis() - lastCompleteAt) >= 6000) { + lastCompleteAt = 0; + lastCompleteName.clear(); + changed = true; + } + if (changed) { + updateRequired = true; + } + } + + if (exitRequested) { + onComplete(); + return; + } +} + +void CalibreConnectActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void CalibreConnectActivity::render() const { + if (state == CalibreConnectState::SERVER_RUNNING) { + renderer.clearScreen(); + renderServerRunning(); + renderer.displayBuffer(); + return; + } + + renderer.clearScreen(); + const auto pageHeight = renderer.getScreenHeight(); + if (state == CalibreConnectState::SERVER_STARTING) { + renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Calibre...", true, EpdFontFamily::BOLD); + } else if (state == CalibreConnectState::ERROR) { + renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Calibre setup failed", true, EpdFontFamily::BOLD); + } + renderer.displayBuffer(); +} + +void CalibreConnectActivity::renderServerRunning() const { + constexpr int LINE_SPACING = 24; + constexpr int SMALL_SPACING = 20; + constexpr int SECTION_SPACING = 40; + constexpr int TOP_PADDING = 14; + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Connect to Calibre", true, EpdFontFamily::BOLD); + + int y = 55 + TOP_PADDING; + renderer.drawCenteredText(UI_10_FONT_ID, y, "Network", true, EpdFontFamily::BOLD); + y += LINE_SPACING; + std::string ssidInfo = "Network: " + connectedSSID; + if (ssidInfo.length() > 28) { + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); + } + renderer.drawCenteredText(UI_10_FONT_ID, y, ssidInfo.c_str()); + renderer.drawCenteredText(UI_10_FONT_ID, y + LINE_SPACING, ("IP: " + connectedIP).c_str()); + + y += LINE_SPACING * 2 + SECTION_SPACING; + renderer.drawCenteredText(UI_10_FONT_ID, y, "Setup", true, EpdFontFamily::BOLD); + y += LINE_SPACING; + renderer.drawCenteredText(SMALL_FONT_ID, y, "1) Install CrossPoint Reader plugin"); + renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING, "2) Be on the same WiFi network"); + renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING * 2, "3) In Calibre: \"Send to device\""); + renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING * 3, "Keep this screen open while sending"); + + y += SMALL_SPACING * 3 + SECTION_SPACING; + renderer.drawCenteredText(UI_10_FONT_ID, y, "Status", true, EpdFontFamily::BOLD); + y += LINE_SPACING; + if (lastProgressTotal > 0 && lastProgressReceived <= lastProgressTotal) { + std::string label = "Receiving"; + if (!currentUploadName.empty()) { + label += ": " + currentUploadName; + if (label.length() > 34) { + label.replace(31, label.length() - 31, "..."); + } + } + renderer.drawCenteredText(SMALL_FONT_ID, y, label.c_str()); + constexpr int barWidth = 300; + constexpr int barHeight = 16; + constexpr int barX = (480 - barWidth) / 2; + ScreenComponents::drawProgressBar(renderer, barX, y + 22, barWidth, barHeight, lastProgressReceived, + lastProgressTotal); + y += 40; + } + + if (lastCompleteAt > 0 && (millis() - lastCompleteAt) < 6000) { + std::string msg = "Received: " + lastCompleteName; + if (msg.length() > 36) { + msg.replace(33, msg.length() - 33, "..."); + } + renderer.drawCenteredText(SMALL_FONT_ID, y, msg.c_str()); + } + + const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} diff --git a/src/activities/network/CalibreConnectActivity.h b/src/activities/network/CalibreConnectActivity.h new file mode 100644 index 00000000..08cf4bb4 --- /dev/null +++ b/src/activities/network/CalibreConnectActivity.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "activities/ActivityWithSubactivity.h" +#include "network/CrossPointWebServer.h" + +enum class CalibreConnectState { WIFI_SELECTION, SERVER_STARTING, SERVER_RUNNING, ERROR }; + +/** + * CalibreConnectActivity starts the file transfer server in STA mode, + * but renders Calibre-specific instructions instead of the web transfer UI. + */ +class CalibreConnectActivity final : public ActivityWithSubactivity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + CalibreConnectState state = CalibreConnectState::WIFI_SELECTION; + const std::function onComplete; + + std::unique_ptr webServer; + std::string connectedIP; + std::string connectedSSID; + unsigned long lastHandleClientTime = 0; + size_t lastProgressReceived = 0; + size_t lastProgressTotal = 0; + std::string currentUploadName; + std::string lastCompleteName; + unsigned long lastCompleteAt = 0; + bool exitRequested = false; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void renderServerRunning() const; + + void onWifiSelectionComplete(bool connected); + void startWebServer(); + void stopWebServer(); + + public: + explicit CalibreConnectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onComplete) + : ActivityWithSubactivity("CalibreConnect", renderer, mappedInput), onComplete(onComplete) {} + void onEnter() override; + void onExit() override; + void loop() override; + bool skipLoopDelay() override { return webServer && webServer->isRunning(); } + bool preventAutoSleep() override { return webServer && webServer->isRunning(); } +}; diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp deleted file mode 100644 index 0ad9094a..00000000 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ /dev/null @@ -1,756 +0,0 @@ -#include "CalibreWirelessActivity.h" - -#include -#include -#include -#include - -#include - -#include "MappedInputManager.h" -#include "ScreenComponents.h" -#include "fontIds.h" -#include "util/StringUtils.h" - -namespace { -constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; -constexpr uint16_t LOCAL_UDP_PORT = 8134; // Port to receive responses -} // namespace - -void CalibreWirelessActivity::displayTaskTrampoline(void* param) { - auto* self = static_cast(param); - self->displayTaskLoop(); -} - -void CalibreWirelessActivity::networkTaskTrampoline(void* param) { - auto* self = static_cast(param); - self->networkTaskLoop(); -} - -void CalibreWirelessActivity::onEnter() { - Activity::onEnter(); - - renderingMutex = xSemaphoreCreateMutex(); - stateMutex = xSemaphoreCreateMutex(); - - state = WirelessState::DISCOVERING; - statusMessage = "Discovering Calibre..."; - errorMessage.clear(); - calibreHostname.clear(); - calibreHost.clear(); - calibrePort = 0; - calibreAltPort = 0; - currentFilename.clear(); - currentFileSize = 0; - bytesReceived = 0; - inBinaryMode = false; - recvBuffer.clear(); - - updateRequired = true; - - // Start UDP listener for Calibre responses - udp.begin(LOCAL_UDP_PORT); - - // Create display task - xTaskCreate(&CalibreWirelessActivity::displayTaskTrampoline, "CalDisplayTask", 2048, this, 1, &displayTaskHandle); - - // Create network task with larger stack for JSON parsing - xTaskCreate(&CalibreWirelessActivity::networkTaskTrampoline, "CalNetworkTask", 12288, this, 2, &networkTaskHandle); -} - -void CalibreWirelessActivity::onExit() { - Activity::onExit(); - - // Turn off WiFi when exiting - WiFi.mode(WIFI_OFF); - - // Stop UDP listening - udp.stop(); - - // Close TCP client if connected - if (tcpClient.connected()) { - tcpClient.stop(); - } - - // Close any open file - if (currentFile) { - currentFile.close(); - } - - // Acquire stateMutex before deleting network task to avoid race condition - xSemaphoreTake(stateMutex, portMAX_DELAY); - if (networkTaskHandle) { - vTaskDelete(networkTaskHandle); - networkTaskHandle = nullptr; - } - xSemaphoreGive(stateMutex); - - // Acquire renderingMutex before deleting display task - xSemaphoreTake(renderingMutex, portMAX_DELAY); - if (displayTaskHandle) { - vTaskDelete(displayTaskHandle); - displayTaskHandle = nullptr; - } - vSemaphoreDelete(renderingMutex); - renderingMutex = nullptr; - - vSemaphoreDelete(stateMutex); - stateMutex = nullptr; -} - -void CalibreWirelessActivity::loop() { - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - onComplete(); - return; - } -} - -void CalibreWirelessActivity::displayTaskLoop() { - while (true) { - if (updateRequired) { - updateRequired = false; - xSemaphoreTake(renderingMutex, portMAX_DELAY); - render(); - xSemaphoreGive(renderingMutex); - } - vTaskDelay(50 / portTICK_PERIOD_MS); - } -} - -void CalibreWirelessActivity::networkTaskLoop() { - while (true) { - xSemaphoreTake(stateMutex, portMAX_DELAY); - const auto currentState = state; - xSemaphoreGive(stateMutex); - - switch (currentState) { - case WirelessState::DISCOVERING: - listenForDiscovery(); - break; - - case WirelessState::CONNECTING: - case WirelessState::WAITING: - case WirelessState::RECEIVING: - handleTcpClient(); - break; - - case WirelessState::COMPLETE: - case WirelessState::DISCONNECTED: - case WirelessState::ERROR: - // Just wait, user will exit - vTaskDelay(100 / portTICK_PERIOD_MS); - break; - } - - vTaskDelay(10 / portTICK_PERIOD_MS); - } -} - -void CalibreWirelessActivity::listenForDiscovery() { - // Broadcast "hello" on all UDP discovery ports to find Calibre - for (const uint16_t port : UDP_PORTS) { - udp.beginPacket("255.255.255.255", port); - udp.write(reinterpret_cast("hello"), 5); - udp.endPacket(); - } - - // Wait for Calibre's response - vTaskDelay(500 / portTICK_PERIOD_MS); - - // Check for response - const int packetSize = udp.parsePacket(); - if (packetSize > 0) { - char buffer[256]; - const int len = udp.read(buffer, sizeof(buffer) - 1); - if (len > 0) { - buffer[len] = '\0'; - - // Parse Calibre's response format: - // "calibre wireless device client (on hostname);port,content_server_port" - // or just the hostname and port info - std::string response(buffer); - - // Try to extract host and port - // Format: "calibre wireless device client (on HOSTNAME);PORT,..." - size_t onPos = response.find("(on "); - size_t closePos = response.find(')'); - size_t semiPos = response.find(';'); - size_t commaPos = response.find(',', semiPos); - - if (semiPos != std::string::npos) { - // Get ports after semicolon (format: "port1,port2") - std::string portStr; - if (commaPos != std::string::npos && commaPos > semiPos) { - portStr = response.substr(semiPos + 1, commaPos - semiPos - 1); - // Get alternative port after comma - std::string altPortStr = response.substr(commaPos + 1); - // Trim whitespace and non-digits from alt port - size_t altEnd = 0; - while (altEnd < altPortStr.size() && altPortStr[altEnd] >= '0' && altPortStr[altEnd] <= '9') { - altEnd++; - } - if (altEnd > 0) { - calibreAltPort = static_cast(std::stoi(altPortStr.substr(0, altEnd))); - } - } else { - portStr = response.substr(semiPos + 1); - } - - // Trim whitespace from main port - while (!portStr.empty() && (portStr[0] == ' ' || portStr[0] == '\t')) { - portStr = portStr.substr(1); - } - - if (!portStr.empty()) { - calibrePort = static_cast(std::stoi(portStr)); - } - - // Get hostname if present, otherwise use sender IP - if (onPos != std::string::npos && closePos != std::string::npos && closePos > onPos + 4) { - calibreHostname = response.substr(onPos + 4, closePos - onPos - 4); - } - } - - // Use the sender's IP as the host to connect to - calibreHost = udp.remoteIP().toString().c_str(); - if (calibreHostname.empty()) { - calibreHostname = calibreHost; - } - - if (calibrePort > 0) { - // Connect to Calibre's TCP server - try main port first, then alt port - setState(WirelessState::CONNECTING); - setStatus("Connecting to " + calibreHostname + "..."); - - // Small delay before connecting - vTaskDelay(100 / portTICK_PERIOD_MS); - - bool connected = false; - - // Try main port first - if (tcpClient.connect(calibreHost.c_str(), calibrePort, 5000)) { - connected = true; - } - - // Try alternative port if main failed - if (!connected && calibreAltPort > 0) { - vTaskDelay(200 / portTICK_PERIOD_MS); - if (tcpClient.connect(calibreHost.c_str(), calibreAltPort, 5000)) { - connected = true; - } - } - - if (connected) { - setState(WirelessState::WAITING); - setStatus("Connected to " + calibreHostname + "\nWaiting for commands..."); - } else { - // Don't set error yet, keep trying discovery - setState(WirelessState::DISCOVERING); - setStatus("Discovering Calibre...\n(Connection failed, retrying)"); - calibrePort = 0; - calibreAltPort = 0; - } - } - } - } -} - -void CalibreWirelessActivity::handleTcpClient() { - if (!tcpClient.connected()) { - setState(WirelessState::DISCONNECTED); - setStatus("Calibre disconnected"); - return; - } - - if (inBinaryMode) { - receiveBinaryData(); - return; - } - - std::string message; - if (readJsonMessage(message)) { - // Parse opcode from JSON array format: [opcode, {...}] - // Find the opcode (first number after '[') - size_t start = message.find('['); - if (start != std::string::npos) { - start++; - size_t end = message.find(',', start); - if (end != std::string::npos) { - const int opcodeInt = std::stoi(message.substr(start, end - start)); - if (opcodeInt < 0 || opcodeInt >= OpCode::ERROR) { - Serial.printf("[%lu] [CAL] Invalid opcode: %d\n", millis(), opcodeInt); - sendJsonResponse(OpCode::OK, "{}"); - return; - } - const auto opcode = static_cast(opcodeInt); - - // Extract data object (everything after the comma until the last ']') - size_t dataStart = end + 1; - size_t dataEnd = message.rfind(']'); - std::string data = ""; - if (dataEnd != std::string::npos && dataEnd > dataStart) { - data = message.substr(dataStart, dataEnd - dataStart); - } - - handleCommand(opcode, data); - } - } - } -} - -bool CalibreWirelessActivity::readJsonMessage(std::string& message) { - // Read available data into buffer - int available = tcpClient.available(); - if (available > 0) { - // Limit buffer growth to prevent memory issues - if (recvBuffer.size() > 100000) { - recvBuffer.clear(); - return false; - } - // Read in chunks - char buf[1024]; - while (available > 0) { - int toRead = std::min(available, static_cast(sizeof(buf))); - int bytesRead = tcpClient.read(reinterpret_cast(buf), toRead); - if (bytesRead > 0) { - recvBuffer.append(buf, bytesRead); - available -= bytesRead; - } else { - break; - } - } - } - - if (recvBuffer.empty()) { - return false; - } - - // Find '[' which marks the start of JSON - size_t bracketPos = recvBuffer.find('['); - if (bracketPos == std::string::npos) { - // No '[' found - if buffer is getting large, something is wrong - if (recvBuffer.size() > 1000) { - recvBuffer.clear(); - } - return false; - } - - // Try to extract length from digits before '[' - // Calibre ALWAYS sends a length prefix, so if it's not valid digits, it's garbage - size_t msgLen = 0; - bool validPrefix = false; - - if (bracketPos > 0 && bracketPos <= 12) { - // Check if prefix is all digits - bool allDigits = true; - for (size_t i = 0; i < bracketPos; i++) { - char c = recvBuffer[i]; - if (c < '0' || c > '9') { - allDigits = false; - break; - } - } - if (allDigits) { - msgLen = std::stoul(recvBuffer.substr(0, bracketPos)); - validPrefix = true; - } - } - - if (!validPrefix) { - // Not a valid length prefix - discard everything up to '[' and treat '[' as start - if (bracketPos > 0) { - recvBuffer = recvBuffer.substr(bracketPos); - } - // Without length prefix, we can't reliably parse - wait for more data - // that hopefully starts with a proper length prefix - return false; - } - - // Sanity check the message length - if (msgLen > 1000000) { - recvBuffer = recvBuffer.substr(bracketPos + 1); // Skip past this '[' and try again - return false; - } - - // Check if we have the complete message - size_t totalNeeded = bracketPos + msgLen; - if (recvBuffer.size() < totalNeeded) { - // Not enough data yet - wait for more - return false; - } - - // Extract the message - message = recvBuffer.substr(bracketPos, msgLen); - - // Keep the rest in buffer (may contain binary data or next message) - if (recvBuffer.size() > totalNeeded) { - recvBuffer = recvBuffer.substr(totalNeeded); - } else { - recvBuffer.clear(); - } - - return true; -} - -void CalibreWirelessActivity::sendJsonResponse(const OpCode opcode, const std::string& data) { - // Format: length + [opcode, {data}] - std::string json = "[" + std::to_string(opcode) + "," + data + "]"; - const std::string lengthPrefix = std::to_string(json.length()); - json.insert(0, lengthPrefix); - - tcpClient.write(reinterpret_cast(json.c_str()), json.length()); - tcpClient.flush(); -} - -void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::string& data) { - switch (opcode) { - case OpCode::GET_INITIALIZATION_INFO: - handleGetInitializationInfo(data); - break; - case OpCode::GET_DEVICE_INFORMATION: - handleGetDeviceInformation(); - break; - case OpCode::FREE_SPACE: - handleFreeSpace(); - break; - case OpCode::GET_BOOK_COUNT: - handleGetBookCount(); - break; - case OpCode::SEND_BOOK: - handleSendBook(data); - break; - case OpCode::SEND_BOOK_METADATA: - handleSendBookMetadata(data); - break; - case OpCode::DISPLAY_MESSAGE: - handleDisplayMessage(data); - break; - case OpCode::NOOP: - handleNoop(data); - break; - case OpCode::SET_CALIBRE_DEVICE_INFO: - case OpCode::SET_CALIBRE_DEVICE_NAME: - // These set metadata about the connected Calibre instance. - // We don't need this info, just acknowledge receipt. - sendJsonResponse(OpCode::OK, "{}"); - break; - case OpCode::SET_LIBRARY_INFO: - // Library metadata (name, UUID) - not needed for receiving books - sendJsonResponse(OpCode::OK, "{}"); - break; - case OpCode::SEND_BOOKLISTS: - // Calibre asking us to send our book list. We report 0 books in - // handleGetBookCount, so this is effectively a no-op. - sendJsonResponse(OpCode::OK, "{}"); - break; - case OpCode::TOTAL_SPACE: - handleFreeSpace(); - break; - default: - Serial.printf("[%lu] [CAL] Unknown opcode: %d\n", millis(), opcode); - sendJsonResponse(OpCode::OK, "{}"); - break; - } -} - -void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& data) { - setState(WirelessState::WAITING); - setStatus("Connected to " + calibreHostname + - "\nWaiting for transfer...\n\nIf transfer fails, enable\n'Ignore free space' in Calibre's\nSmartDevice " - "plugin settings."); - - // Build response with device capabilities - // Format must match what Calibre expects from a smart device - std::string response = "{"; - response += "\"appName\":\"CrossPoint\","; - response += "\"acceptedExtensions\":[\"epub\"],"; - response += "\"cacheUsesLpaths\":true,"; - response += "\"canAcceptLibraryInfo\":true,"; - response += "\"canDeleteMultipleBooks\":true,"; - response += "\"canReceiveBookBinary\":true,"; - response += "\"canSendOkToSendbook\":true,"; - response += "\"canStreamBooks\":true,"; - response += "\"canStreamMetadata\":true,"; - response += "\"canUseCachedMetadata\":true,"; - // ccVersionNumber: Calibre Companion protocol version. 212 matches CC 5.4.20+. - // Using a known version ensures compatibility with Calibre's feature detection. - response += "\"ccVersionNumber\":212,"; - // coverHeight: Max cover image height. We don't process covers, so this is informational only. - response += "\"coverHeight\":800,"; - response += "\"deviceKind\":\"CrossPoint\","; - response += "\"deviceName\":\"CrossPoint\","; - response += "\"extensionPathLengths\":{\"epub\":37},"; - response += "\"maxBookContentPacketLen\":4096,"; - response += "\"passwordHash\":\"\","; - response += "\"useUuidFileNames\":false,"; - response += "\"versionOK\":true"; - response += "}"; - - sendJsonResponse(OpCode::OK, response); -} - -void CalibreWirelessActivity::handleGetDeviceInformation() { - std::string response = "{"; - response += "\"device_info\":{"; - response += "\"device_store_uuid\":\"" + getDeviceUuid() + "\","; - response += "\"device_name\":\"CrossPoint Reader\","; - response += "\"device_version\":\"" CROSSPOINT_VERSION "\""; - response += "},"; - response += "\"version\":1,"; - response += "\"device_version\":\"" CROSSPOINT_VERSION "\""; - response += "}"; - - sendJsonResponse(OpCode::OK, response); -} - -void CalibreWirelessActivity::handleFreeSpace() { - // TODO: Report actual SD card free space instead of hardcoded value - // Report 10GB free space for now - sendJsonResponse(OpCode::OK, "{\"free_space_on_device\":10737418240}"); -} - -void CalibreWirelessActivity::handleGetBookCount() { - // We report 0 books - Calibre will send books without checking for duplicates - std::string response = "{\"count\":0,\"willStream\":true,\"willScan\":false}"; - sendJsonResponse(OpCode::OK, response); -} - -void CalibreWirelessActivity::handleSendBook(const std::string& data) { - // Manually extract lpath and length from SEND_BOOK data - // Full JSON parsing crashes on large metadata, so we just extract what we need - - // Extract "lpath" field - format: "lpath": "value" - std::string lpath; - size_t lpathPos = data.find("\"lpath\""); - if (lpathPos != std::string::npos) { - size_t colonPos = data.find(':', lpathPos + 7); - if (colonPos != std::string::npos) { - size_t quoteStart = data.find('"', colonPos + 1); - if (quoteStart != std::string::npos) { - size_t quoteEnd = data.find('"', quoteStart + 1); - if (quoteEnd != std::string::npos) { - lpath = data.substr(quoteStart + 1, quoteEnd - quoteStart - 1); - } - } - } - } - - // Extract top-level "length" field - must track depth to skip nested objects - // The metadata contains nested "length" fields (e.g., cover image length) - size_t length = 0; - int depth = 0; - for (size_t i = 0; i < data.size(); i++) { - char c = data[i]; - if (c == '{' || c == '[') { - depth++; - } else if (c == '}' || c == ']') { - depth--; - } else if (depth == 1 && c == '"') { - // At top level, check if this is "length" - if (i + 9 < data.size() && data.substr(i, 8) == "\"length\"") { - // Found top-level "length" - extract the number after ':' - size_t colonPos = data.find(':', i + 8); - if (colonPos != std::string::npos) { - size_t numStart = colonPos + 1; - while (numStart < data.size() && (data[numStart] == ' ' || data[numStart] == '\t')) { - numStart++; - } - size_t numEnd = numStart; - while (numEnd < data.size() && data[numEnd] >= '0' && data[numEnd] <= '9') { - numEnd++; - } - if (numEnd > numStart) { - length = std::stoul(data.substr(numStart, numEnd - numStart)); - break; - } - } - } - } - } - - if (lpath.empty() || length == 0) { - sendJsonResponse(OpCode::ERROR, "{\"message\":\"Invalid book data\"}"); - return; - } - - // Extract filename from lpath - std::string filename = lpath; - const size_t lastSlash = filename.rfind('/'); - if (lastSlash != std::string::npos) { - filename = filename.substr(lastSlash + 1); - } - - // Sanitize and create full path - currentFilename = "/" + StringUtils::sanitizeFilename(filename); - if (!StringUtils::checkFileExtension(currentFilename, ".epub")) { - currentFilename += ".epub"; - } - currentFileSize = length; - bytesReceived = 0; - - setState(WirelessState::RECEIVING); - setStatus("Receiving: " + filename); - - // Open file for writing - if (!SdMan.openFileForWrite("CAL", currentFilename.c_str(), currentFile)) { - setError("Failed to create file"); - sendJsonResponse(OpCode::ERROR, "{\"message\":\"Failed to create file\"}"); - return; - } - - // Send OK to start receiving binary data - sendJsonResponse(OpCode::OK, "{}"); - - // Switch to binary mode - inBinaryMode = true; - binaryBytesRemaining = length; - - // Check if recvBuffer has leftover data (binary file data that arrived with the JSON) - if (!recvBuffer.empty()) { - size_t toWrite = std::min(recvBuffer.size(), binaryBytesRemaining); - size_t written = currentFile.write(reinterpret_cast(recvBuffer.data()), toWrite); - bytesReceived += written; - binaryBytesRemaining -= written; - recvBuffer = recvBuffer.substr(toWrite); - updateRequired = true; - } -} - -void CalibreWirelessActivity::handleSendBookMetadata(const std::string& data) { - // We receive metadata after the book - just acknowledge - sendJsonResponse(OpCode::OK, "{}"); -} - -void CalibreWirelessActivity::handleDisplayMessage(const std::string& data) { - // Calibre may send messages to display - // Check messageKind - 1 means password error - if (data.find("\"messageKind\":1") != std::string::npos) { - setError("Password required"); - } - sendJsonResponse(OpCode::OK, "{}"); -} - -void CalibreWirelessActivity::handleNoop(const std::string& data) { - // Check for ejecting flag - if (data.find("\"ejecting\":true") != std::string::npos) { - setState(WirelessState::DISCONNECTED); - setStatus("Calibre disconnected"); - } - sendJsonResponse(OpCode::NOOP, "{}"); -} - -void CalibreWirelessActivity::receiveBinaryData() { - const int available = tcpClient.available(); - if (available == 0) { - // Check if connection is still alive - if (!tcpClient.connected()) { - currentFile.close(); - inBinaryMode = false; - setError("Transfer interrupted"); - } - return; - } - - uint8_t buffer[1024]; - const size_t toRead = std::min(sizeof(buffer), binaryBytesRemaining); - const size_t bytesRead = tcpClient.read(buffer, toRead); - - if (bytesRead > 0) { - currentFile.write(buffer, bytesRead); - bytesReceived += bytesRead; - binaryBytesRemaining -= bytesRead; - updateRequired = true; - - if (binaryBytesRemaining == 0) { - // Transfer complete - currentFile.flush(); - currentFile.close(); - inBinaryMode = false; - - setState(WirelessState::WAITING); - setStatus("Received: " + currentFilename + "\nWaiting for more..."); - - // Send OK to acknowledge completion - sendJsonResponse(OpCode::OK, "{}"); - } - } -} - -void CalibreWirelessActivity::render() const { - renderer.clearScreen(); - - const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); - - // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 30, "Calibre Wireless", true, EpdFontFamily::BOLD); - - // Draw IP address - const std::string ipAddr = WiFi.localIP().toString().c_str(); - renderer.drawCenteredText(UI_10_FONT_ID, 60, ("IP: " + ipAddr).c_str()); - - // Draw status message - int statusY = pageHeight / 2 - 40; - - // Split status message by newlines and draw each line - std::string status = statusMessage; - size_t pos = 0; - while ((pos = status.find('\n')) != std::string::npos) { - renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.substr(0, pos).c_str()); - statusY += 25; - status = status.substr(pos + 1); - } - if (!status.empty()) { - renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.c_str()); - statusY += 25; - } - - // Draw progress if receiving - if (state == WirelessState::RECEIVING && currentFileSize > 0) { - const int barWidth = pageWidth - 100; - constexpr int barHeight = 20; - constexpr int barX = 50; - const int barY = statusY + 20; - ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, bytesReceived, currentFileSize); - } - - // Draw error if present - if (!errorMessage.empty()) { - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight - 120, errorMessage.c_str()); - } - - // Draw button hints - const auto labels = mappedInput.mapLabels("Back", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - - renderer.displayBuffer(); -} - -std::string CalibreWirelessActivity::getDeviceUuid() const { - // Generate a consistent UUID based on MAC address - uint8_t mac[6]; - WiFi.macAddress(mac); - - char uuid[37]; - snprintf(uuid, sizeof(uuid), "%02x%02x%02x%02x-%02x%02x-4000-8000-%02x%02x%02x%02x%02x%02x", mac[0], mac[1], mac[2], - mac[3], mac[4], mac[5], mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); - - return std::string(uuid); -} - -void CalibreWirelessActivity::setState(WirelessState newState) { - xSemaphoreTake(stateMutex, portMAX_DELAY); - state = newState; - xSemaphoreGive(stateMutex); - updateRequired = true; -} - -void CalibreWirelessActivity::setStatus(const std::string& message) { - statusMessage = message; - updateRequired = true; -} - -void CalibreWirelessActivity::setError(const std::string& message) { - errorMessage = message; - setState(WirelessState::ERROR); -} diff --git a/src/activities/network/CalibreWirelessActivity.h b/src/activities/network/CalibreWirelessActivity.h deleted file mode 100644 index ae2b1767..00000000 --- a/src/activities/network/CalibreWirelessActivity.h +++ /dev/null @@ -1,135 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "activities/Activity.h" - -/** - * CalibreWirelessActivity implements Calibre's "wireless device" protocol. - * This allows Calibre desktop to send books directly to the device over WiFi. - * - * Protocol specification sourced from Calibre's smart device driver: - * https://github.com/kovidgoyal/calibre/blob/master/src/calibre/devices/smart_device_app/driver.py - * - * Protocol overview: - * 1. Device broadcasts "hello" on UDP ports 54982, 48123, 39001, 44044, 59678 - * 2. Calibre responds with its TCP server address - * 3. Device connects to Calibre's TCP server - * 4. Calibre sends JSON commands with length-prefixed messages - * 5. Books are transferred as binary data after SEND_BOOK command - */ -class CalibreWirelessActivity final : public Activity { - // Calibre wireless device states - enum class WirelessState { - DISCOVERING, // Listening for Calibre server broadcasts - CONNECTING, // Establishing TCP connection - WAITING, // Connected, waiting for commands - RECEIVING, // Receiving a book file - COMPLETE, // Transfer complete - DISCONNECTED, // Calibre disconnected - ERROR // Connection/transfer error - }; - - // Calibre protocol opcodes (from calibre/devices/smart_device_app/driver.py) - enum OpCode : uint8_t { - OK = 0, - SET_CALIBRE_DEVICE_INFO = 1, - SET_CALIBRE_DEVICE_NAME = 2, - GET_DEVICE_INFORMATION = 3, - TOTAL_SPACE = 4, - FREE_SPACE = 5, - GET_BOOK_COUNT = 6, - SEND_BOOKLISTS = 7, - SEND_BOOK = 8, - GET_INITIALIZATION_INFO = 9, - BOOK_DONE = 11, - NOOP = 12, // Was incorrectly 18 - DELETE_BOOK = 13, - GET_BOOK_FILE_SEGMENT = 14, - GET_BOOK_METADATA = 15, - SEND_BOOK_METADATA = 16, - DISPLAY_MESSAGE = 17, - CALIBRE_BUSY = 18, - SET_LIBRARY_INFO = 19, - ERROR = 20, - }; - - TaskHandle_t displayTaskHandle = nullptr; - TaskHandle_t networkTaskHandle = nullptr; - SemaphoreHandle_t renderingMutex = nullptr; - SemaphoreHandle_t stateMutex = nullptr; - bool updateRequired = false; - - WirelessState state = WirelessState::DISCOVERING; - const std::function onComplete; - - // UDP discovery - WiFiUDP udp; - - // TCP connection (we connect to Calibre) - WiFiClient tcpClient; - std::string calibreHost; - uint16_t calibrePort = 0; - uint16_t calibreAltPort = 0; // Alternative port (content server) - std::string calibreHostname; - - // Transfer state - std::string currentFilename; - size_t currentFileSize = 0; - size_t bytesReceived = 0; - std::string statusMessage; - std::string errorMessage; - - // Protocol state - bool inBinaryMode = false; - size_t binaryBytesRemaining = 0; - FsFile currentFile; - std::string recvBuffer; // Buffer for incoming data (like KOReader) - - static void displayTaskTrampoline(void* param); - static void networkTaskTrampoline(void* param); - [[noreturn]] void displayTaskLoop(); - [[noreturn]] void networkTaskLoop(); - void render() const; - - // Network operations - void listenForDiscovery(); - void handleTcpClient(); - bool readJsonMessage(std::string& message); - void sendJsonResponse(OpCode opcode, const std::string& data); - void handleCommand(OpCode opcode, const std::string& data); - void receiveBinaryData(); - - // Protocol handlers - void handleGetInitializationInfo(const std::string& data); - void handleGetDeviceInformation(); - void handleFreeSpace(); - void handleGetBookCount(); - void handleSendBook(const std::string& data); - void handleSendBookMetadata(const std::string& data); - void handleDisplayMessage(const std::string& data); - void handleNoop(const std::string& data); - - // Utility - std::string getDeviceUuid() const; - void setState(WirelessState newState); - void setStatus(const std::string& message); - void setError(const std::string& message); - - public: - explicit CalibreWirelessActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onComplete) - : Activity("CalibreWireless", renderer, mappedInput), onComplete(onComplete) {} - void onEnter() override; - void onExit() override; - void loop() override; - bool preventAutoSleep() override { return true; } - bool skipLoopDelay() override { return true; } -}; diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 35ad58ba..c6af1497 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -12,6 +12,7 @@ #include "MappedInputManager.h" #include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" +#include "activities/network/CalibreConnectActivity.h" #include "fontIds.h" namespace { @@ -125,8 +126,13 @@ void CrossPointWebServerActivity::onExit() { } void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) { - Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), - mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot"); + const char* modeName = "Join Network"; + if (mode == NetworkMode::CONNECT_CALIBRE) { + modeName = "Connect to Calibre"; + } else if (mode == NetworkMode::CREATE_HOTSPOT) { + modeName = "Create Hotspot"; + } + Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), modeName); networkMode = mode; isApMode = (mode == NetworkMode::CREATE_HOTSPOT); @@ -134,6 +140,18 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) // Exit mode selection subactivity exitActivity(); + if (mode == NetworkMode::CONNECT_CALIBRE) { + exitActivity(); + enterNewActivity(new CalibreConnectActivity(renderer, mappedInput, [this] { + exitActivity(); + state = WebServerActivityState::MODE_SELECTION; + enterNewActivity(new NetworkModeSelectionActivity( + renderer, mappedInput, [this](const NetworkMode nextMode) { onNetworkModeSelected(nextMode); }, + [this]() { onGoBack(); })); + })); + return; + } + if (mode == NetworkMode::JOIN_NETWORK) { // STA mode - launch WiFi selection Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis()); diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 775a2474..a1189a57 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -23,7 +23,7 @@ enum class WebServerActivityState { /** * CrossPointWebServerActivity is the entry point for file transfer functionality. * It: - * - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP) + * - First presents a choice between "Join a Network" (STA), "Connect to Calibre", and "Create Hotspot" (AP) * - For STA mode: Launches WifiSelectionActivity to connect to an existing network * - For AP mode: Creates an Access Point that clients can connect to * - Starts the CrossPointWebServer when connected diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index ad05f5b8..50767084 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -6,10 +6,13 @@ #include "fontIds.h" namespace { -constexpr int MENU_ITEM_COUNT = 2; -const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"}; -const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Connect to an existing WiFi network", - "Create a WiFi network others can join"}; +constexpr int MENU_ITEM_COUNT = 3; +const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Connect to Calibre", "Create Hotspot"}; +const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = { + "Connect to an existing WiFi network", + "Use Calibre wireless device transfers", + "Create a WiFi network others can join", +}; } // namespace void NetworkModeSelectionActivity::taskTrampoline(void* param) { @@ -58,7 +61,12 @@ void NetworkModeSelectionActivity::loop() { // Handle confirm button - select current option if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT; + NetworkMode mode = NetworkMode::JOIN_NETWORK; + if (selectedIndex == 1) { + mode = NetworkMode::CONNECT_CALIBRE; + } else if (selectedIndex == 2) { + mode = NetworkMode::CREATE_HOTSPOT; + } onModeSelected(mode); return; } diff --git a/src/activities/network/NetworkModeSelectionActivity.h b/src/activities/network/NetworkModeSelectionActivity.h index b9f2e1ee..1b93b825 100644 --- a/src/activities/network/NetworkModeSelectionActivity.h +++ b/src/activities/network/NetworkModeSelectionActivity.h @@ -8,11 +8,12 @@ #include "../Activity.h" // Enum for network mode selection -enum class NetworkMode { JOIN_NETWORK, CREATE_HOTSPOT }; +enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT }; /** * NetworkModeSelectionActivity presents the user with a choice: * - "Join a Network" - Connect to an existing WiFi network (STA mode) + * - "Connect to Calibre" - Use Calibre wireless device transfers * - "Create Hotspot" - Create an Access Point that others can connect to (AP mode) * * The onModeSelected callback is called with the user's choice. diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp index 4f614ffc..d1df9d0e 100644 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -1,20 +1,17 @@ #include "CalibreSettingsActivity.h" #include -#include #include #include "CrossPointSettings.h" #include "MappedInputManager.h" -#include "activities/network/CalibreWirelessActivity.h" -#include "activities/network/WifiSelectionActivity.h" #include "activities/util/KeyboardEntryActivity.h" #include "fontIds.h" namespace { -constexpr int MENU_ITEMS = 2; -const char* menuNames[MENU_ITEMS] = {"Calibre Web URL", "Connect as Wireless Device"}; +constexpr int MENU_ITEMS = 3; +const char* menuNames[MENU_ITEMS] = {"OPDS Server URL", "Username", "Password"}; } // namespace void CalibreSettingsActivity::taskTrampoline(void* param) { @@ -80,10 +77,10 @@ void CalibreSettingsActivity::handleSelection() { xSemaphoreTake(renderingMutex, portMAX_DELAY); if (selectedIndex == 0) { - // Calibre Web URL + // OPDS Server URL exitActivity(); enterNewActivity(new KeyboardEntryActivity( - renderer, mappedInput, "Calibre Web URL", SETTINGS.opdsServerUrl, 10, + renderer, mappedInput, "OPDS Server URL", SETTINGS.opdsServerUrl, 10, 127, // maxLength false, // not password [this](const std::string& url) { @@ -98,26 +95,41 @@ void CalibreSettingsActivity::handleSelection() { updateRequired = true; })); } else if (selectedIndex == 1) { - // Wireless Device - launch the activity (handles WiFi connection internally) + // Username exitActivity(); - if (WiFi.status() != WL_CONNECTED) { - enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) { - exitActivity(); - if (connected) { - enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] { - exitActivity(); - updateRequired = true; - })); - } else { + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Username", SETTINGS.opdsUsername, 10, + 63, // maxLength + false, // not password + [this](const std::string& username) { + strncpy(SETTINGS.opdsUsername, username.c_str(), sizeof(SETTINGS.opdsUsername) - 1); + SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); updateRequired = true; - } - })); - } else { - enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] { - exitActivity(); - updateRequired = true; - })); - } + }, + [this]() { + exitActivity(); + updateRequired = true; + })); + } else if (selectedIndex == 2) { + // Password + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Password", SETTINGS.opdsPassword, 10, + 63, // maxLength + false, // not password mode + [this](const std::string& password) { + strncpy(SETTINGS.opdsPassword, password.c_str(), sizeof(SETTINGS.opdsPassword) - 1); + SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); + updateRequired = true; + }, + [this]() { + exitActivity(); + updateRequired = true; + })); } xSemaphoreGive(renderingMutex); @@ -141,24 +153,32 @@ void CalibreSettingsActivity::render() { const auto pageWidth = renderer.getScreenWidth(); // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD); + + // Draw info text about Calibre + renderer.drawCenteredText(UI_10_FONT_ID, 40, "For Calibre, add /opds to your URL"); // Draw selection highlight - renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30); + renderer.fillRect(0, 70 + selectedIndex * 30 - 2, pageWidth - 1, 30); // Draw menu items for (int i = 0; i < MENU_ITEMS; i++) { - const int settingY = 60 + i * 30; + const int settingY = 70 + i * 30; const bool isSelected = (i == selectedIndex); renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected); - // Draw status for URL setting + // Draw status for each setting + const char* status = "[Not Set]"; if (i == 0) { - const char* status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]"; - const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); - renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); + status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]"; + } else if (i == 1) { + status = (strlen(SETTINGS.opdsUsername) > 0) ? "[Set]" : "[Not Set]"; + } else if (i == 2) { + status = (strlen(SETTINGS.opdsPassword) > 0) ? "[Set]" : "[Not Set]"; } + const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); + renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); } // Draw button hints diff --git a/src/activities/settings/CalibreSettingsActivity.h b/src/activities/settings/CalibreSettingsActivity.h index 77b9218c..49695c62 100644 --- a/src/activities/settings/CalibreSettingsActivity.h +++ b/src/activities/settings/CalibreSettingsActivity.h @@ -8,8 +8,8 @@ #include "activities/ActivityWithSubactivity.h" /** - * Submenu for Calibre settings. - * Shows Calibre Web URL and Calibre Wireless Device options. + * Submenu for OPDS Browser settings. + * Shows OPDS Server URL and HTTP authentication options. */ class CalibreSettingsActivity final : public ActivityWithSubactivity { public: diff --git a/src/activities/settings/CategorySettingsActivity.cpp b/src/activities/settings/CategorySettingsActivity.cpp index a6182b5c..7fd5ef5f 100644 --- a/src/activities/settings/CategorySettingsActivity.cpp +++ b/src/activities/settings/CategorySettingsActivity.cpp @@ -103,7 +103,7 @@ void CategorySettingsActivity::toggleCurrentSetting() { updateRequired = true; })); xSemaphoreGive(renderingMutex); - } else if (strcmp(setting.name, "Calibre Settings") == 0) { + } else if (strcmp(setting.name, "OPDS Browser") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 45b7a12d..819115a5 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -49,7 +49,7 @@ constexpr int systemSettingsCount = 5; const SettingInfo systemSettings[systemSettingsCount] = { SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, {"1 min", "5 min", "10 min", "15 min", "30 min"}), - SettingInfo::Action("KOReader Sync"), SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Clear Cache"), + SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"), SettingInfo::Action("Check for updates")}; } // namespace diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 90dfed7b..a135c9f0 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -18,6 +18,8 @@ namespace { // Note: Items starting with "." are automatically hidden const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); +constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; +constexpr uint16_t LOCAL_UDP_PORT = 8134; // Static pointer for WebSocket callback (WebSocketsServer requires C-style callback) CrossPointWebServer* wsInstance = nullptr; @@ -30,6 +32,9 @@ size_t wsUploadSize = 0; size_t wsUploadReceived = 0; unsigned long wsUploadStartTime = 0; bool wsUploadInProgress = false; +String wsLastCompleteName; +size_t wsLastCompleteSize = 0; +unsigned long wsLastCompleteAt = 0; // Helper function to clear epub cache after upload void clearEpubCacheIfNeeded(const String& filePath) { @@ -96,6 +101,7 @@ void CrossPointWebServer::begin() { server->on("/api/status", HTTP_GET, [this] { handleStatus(); }); server->on("/api/files", HTTP_GET, [this] { handleFileListData(); }); + server->on("/download", HTTP_GET, [this] { handleDownload(); }); // Upload endpoint with special handling for multipart form data server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); }); @@ -119,6 +125,10 @@ void CrossPointWebServer::begin() { wsServer->onEvent(wsEventCallback); Serial.printf("[%lu] [WEB] WebSocket server started\n", millis()); + udpActive = udp.begin(LOCAL_UDP_PORT); + Serial.printf("[%lu] [WEB] Discovery UDP %s on port %d\n", millis(), udpActive ? "enabled" : "failed", + LOCAL_UDP_PORT); + running = true; Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); @@ -156,6 +166,11 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis()); } + if (udpActive) { + udp.stop(); + udpActive = false; + } + // Brief delay to allow any in-flight handleClient() calls to complete delay(20); @@ -174,7 +189,7 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap()); } -void CrossPointWebServer::handleClient() const { +void CrossPointWebServer::handleClient() { static unsigned long lastDebugPrint = 0; // Check running flag FIRST before accessing server @@ -200,6 +215,40 @@ void CrossPointWebServer::handleClient() const { if (wsServer) { wsServer->loop(); } + + // Respond to discovery broadcasts + if (udpActive) { + int packetSize = udp.parsePacket(); + if (packetSize > 0) { + char buffer[16]; + int len = udp.read(buffer, sizeof(buffer) - 1); + if (len > 0) { + buffer[len] = '\0'; + if (strcmp(buffer, "hello") == 0) { + String hostname = WiFi.getHostname(); + if (hostname.isEmpty()) { + hostname = "crosspoint"; + } + String message = "crosspoint (on " + hostname + ");" + String(wsPort); + udp.beginPacket(udp.remoteIP(), udp.remotePort()); + udp.write(reinterpret_cast(message.c_str()), message.length()); + udp.endPacket(); + } + } + } + } +} + +CrossPointWebServer::WsUploadStatus CrossPointWebServer::getWsUploadStatus() const { + WsUploadStatus status; + status.inProgress = wsUploadInProgress; + status.received = wsUploadReceived; + status.total = wsUploadSize; + status.filename = wsUploadFileName.c_str(); + status.lastCompleteName = wsLastCompleteName.c_str(); + status.lastCompleteSize = wsLastCompleteSize; + status.lastCompleteAt = wsLastCompleteAt; + return status; } void CrossPointWebServer::handleRoot() const { @@ -346,6 +395,69 @@ void CrossPointWebServer::handleFileListData() const { Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); } +void CrossPointWebServer::handleDownload() const { + if (!server->hasArg("path")) { + server->send(400, "text/plain", "Missing path"); + return; + } + + String itemPath = server->arg("path"); + if (itemPath.isEmpty() || itemPath == "/") { + server->send(400, "text/plain", "Invalid path"); + return; + } + if (!itemPath.startsWith("/")) { + itemPath = "/" + itemPath; + } + + const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); + if (itemName.startsWith(".")) { + server->send(403, "text/plain", "Cannot access system files"); + return; + } + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { + if (itemName.equals(HIDDEN_ITEMS[i])) { + server->send(403, "text/plain", "Cannot access protected items"); + return; + } + } + + if (!SdMan.exists(itemPath.c_str())) { + server->send(404, "text/plain", "Item not found"); + return; + } + + FsFile file = SdMan.open(itemPath.c_str()); + if (!file) { + server->send(500, "text/plain", "Failed to open file"); + return; + } + if (file.isDirectory()) { + file.close(); + server->send(400, "text/plain", "Path is a directory"); + return; + } + + String contentType = "application/octet-stream"; + if (isEpubFile(itemPath)) { + contentType = "application/epub+zip"; + } + + char nameBuf[128] = {0}; + String filename = "download"; + if (file.getName(nameBuf, sizeof(nameBuf))) { + filename = nameBuf; + } + + server->setContentLength(file.size()); + server->sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); + server->send(200, contentType.c_str(), ""); + + WiFiClient client = server->client(); + client.write(file); + file.close(); +} + // Static variables for upload handling static FsFile uploadFile; static String uploadFileName; @@ -798,6 +910,10 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsUploadFile.close(); wsUploadInProgress = false; + wsLastCompleteName = wsUploadFileName; + wsLastCompleteSize = wsUploadSize; + wsLastCompleteAt = millis(); + unsigned long elapsed = millis() - wsUploadStartTime; float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0; diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index ecc2d3d2..36030292 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -2,7 +2,10 @@ #include #include +#include +#include +#include #include // Structure to hold file information @@ -15,6 +18,16 @@ struct FileInfo { class CrossPointWebServer { public: + struct WsUploadStatus { + bool inProgress = false; + size_t received = 0; + size_t total = 0; + std::string filename; + std::string lastCompleteName; + size_t lastCompleteSize = 0; + unsigned long lastCompleteAt = 0; + }; + CrossPointWebServer(); ~CrossPointWebServer(); @@ -25,11 +38,13 @@ class CrossPointWebServer { void stop(); // Call this periodically to handle client requests - void handleClient() const; + void handleClient(); // Check if server is running bool isRunning() const { return running; } + WsUploadStatus getWsUploadStatus() const; + // Get the port number uint16_t getPort() const { return port; } @@ -40,6 +55,8 @@ class CrossPointWebServer { bool apMode = false; // true when running in AP mode, false for STA mode uint16_t port = 80; uint16_t wsPort = 81; // WebSocket port + WiFiUDP udp; + bool udpActive = false; // WebSocket upload state void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); @@ -56,6 +73,7 @@ class CrossPointWebServer { void handleStatus() const; void handleFileList() const; void handleFileListData() const; + void handleDownload() const; void handleUpload() const; void handleUploadPost() const; void handleCreateFolder() const; diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index fe65ea6b..b7718c2d 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -5,9 +5,12 @@ #include #include #include +#include +#include #include +#include "CrossPointSettings.h" #include "util/UrlUtils.h" bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { @@ -28,6 +31,13 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + // Add Basic HTTP auth if credentials are configured + if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { + std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; + String encoded = base64::encode(credentials.c_str()); + http.addHeader("Authorization", "Basic " + encoded); + } + const int httpCode = http.GET(); if (httpCode != HTTP_CODE_OK) { Serial.printf("[%lu] [HTTP] Fetch failed: %d\n", millis(), httpCode); @@ -72,6 +82,13 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + // Add Basic HTTP auth if credentials are configured + if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { + std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; + String encoded = base64::encode(credentials.c_str()); + http.addHeader("Authorization", "Basic " + encoded); + } + const int httpCode = http.GET(); if (httpCode != HTTP_CODE_OK) { Serial.printf("[%lu] [HTTP] Download failed: %d\n", millis(), httpCode); From bf6cf83577c0229b3712a20f875fc12f271fbae1 Mon Sep 17 00:00:00 2001 From: Vincent Politzer Date: Tue, 27 Jan 2026 03:07:02 -0800 Subject: [PATCH 10/22] fix: line break (#525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * Fixes #519 * Refactors repeated code into new function: `ChapterHtmlSlimParser::flushPartWordBuffer()` ## Additional Context * The `
` tag is self closing and _in-line_, so the existing logic for closing block tags does not get applied to `
` tags. * This PR adds the _in-line_ logic to: * Flush the word preceding the `
` tag from `partWordBuffer` to `currentTextBlock` before calling `startNewTextBlock` * **New function**: `ChapterHtmlSlimParser::flushPartWordBuffer()` * **Purpose**: Consolidates the logic for flushing `partWordBuffer` to `currentTextBlock` * **Impact**: Simplifies `ChapterHtmlSlimParser::characterData(…)`, `ChapterHtmlSlimParser::startElement(…)`, and `ChapterHtmlSlimParser::endElement(…)` by integrating reused code into single function --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 51 +++++++++---------- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 1 + 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 1d7e2ab3..53359179 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -40,6 +40,23 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib return false; } +// flush the contents of partWordBuffer to currentTextBlock +void ChapterHtmlSlimParser::flushPartWordBuffer() { + // determine font style + EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR; + if (boldUntilDepth < depth && italicUntilDepth < depth) { + fontStyle = EpdFontFamily::BOLD_ITALIC; + } else if (boldUntilDepth < depth) { + fontStyle = EpdFontFamily::BOLD; + } else if (italicUntilDepth < depth) { + fontStyle = EpdFontFamily::ITALIC; + } + // flush the buffer + partWordBuffer[partWordBufferIndex] = '\0'; + currentTextBlock->addWord(partWordBuffer, fontStyle); + partWordBufferIndex = 0; +} + // start a new text block if needed void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) { if (currentTextBlock) { @@ -125,6 +142,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); } else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { if (strcmp(name, "br") == 0) { + if (self->partWordBufferIndex > 0) { + // flush word preceding
to currentTextBlock before calling startNewTextBlock + self->flushPartWordBuffer(); + } self->startNewTextBlock(self->currentTextBlock->getStyle()); } else { self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment); @@ -149,22 +170,11 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char return; } - EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR; - if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) { - fontStyle = EpdFontFamily::BOLD_ITALIC; - } else if (self->boldUntilDepth < self->depth) { - fontStyle = EpdFontFamily::BOLD; - } else if (self->italicUntilDepth < self->depth) { - fontStyle = EpdFontFamily::ITALIC; - } - for (int i = 0; i < len; i++) { if (isWhitespace(s[i])) { // Currently looking at whitespace, if there's anything in the partWordBuffer, flush it if (self->partWordBufferIndex > 0) { - self->partWordBuffer[self->partWordBufferIndex] = '\0'; - self->currentTextBlock->addWord(self->partWordBuffer, fontStyle); - self->partWordBufferIndex = 0; + self->flushPartWordBuffer(); } // Skip the whitespace char continue; @@ -186,9 +196,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char // If we're about to run out of space, then cut the word off and start a new one if (self->partWordBufferIndex >= MAX_WORD_SIZE) { - self->partWordBuffer[self->partWordBufferIndex] = '\0'; - self->currentTextBlock->addWord(self->partWordBuffer, fontStyle); - self->partWordBufferIndex = 0; + self->flushPartWordBuffer(); } self->partWordBuffer[self->partWordBufferIndex++] = s[i]; @@ -219,18 +227,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || self->depth == 1; if (shouldBreakText) { - EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR; - if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) { - fontStyle = EpdFontFamily::BOLD_ITALIC; - } else if (self->boldUntilDepth < self->depth) { - fontStyle = EpdFontFamily::BOLD; - } else if (self->italicUntilDepth < self->depth) { - fontStyle = EpdFontFamily::ITALIC; - } - - self->partWordBuffer[self->partWordBufferIndex] = '\0'; - self->currentTextBlock->addWord(self->partWordBuffer, fontStyle); - self->partWordBufferIndex = 0; + self->flushPartWordBuffer(); } } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 5355211a..2d8ebe5c 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -39,6 +39,7 @@ class ChapterHtmlSlimParser { bool hyphenationEnabled; void startNewTextBlock(TextBlock::Style style); + void flushPartWordBuffer(); void makePages(); // XML callbacks static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); From 1b9c8ab5459071abec2ade414f2c7fa42fb60029 Mon Sep 17 00:00:00 2001 From: Xuan-Son Nguyen Date: Tue, 27 Jan 2026 12:07:37 +0100 Subject: [PATCH 11/22] fix: short-press power button to wakeup (#482) ## Summary Fix https://github.com/crosspoint-reader/crosspoint-reader/issues/288 Based on my observation, it seems like the problem was that `inputManager.isPressed(InputManager::BTN_POWER)` takes a bit of time after waking up to report the correct value. I haven't tested this behavior with a standalone ESP32C3, but if you know more about this, feel free to comment. However, if we just want short press, I think it's enough to check for wake up source. If we plan to allow multiple buttons to wake up in the future, may consider using ext1 / `esp_sleep_get_ext1_wakeup_status()` to allow identify which pin triggered wake up. Note that I'm not particularly experienced in esp32 developments, just happen to have prior knowledge hacking esphome. ## Additional Context N/A --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? NO --------- Co-authored-by: Dave Allie --- src/main.cpp | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index c0222e0d..8a081fd8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -151,8 +151,15 @@ void enterNewActivity(Activity* activity) { currentActivity->onEnter(); } -// Verify long press on wake-up from deep sleep -void verifyWakeupLongPress() { +// Verify power button press duration on wake-up from deep sleep +// Pre-condition: isWakeupByPowerButton() == true +void verifyPowerButtonDuration() { + if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) { + // Fast path for short press + // Needed because inputManager.isPressed() may take up to ~500ms to return the correct state + return; + } + // Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration() const auto start = millis(); bool abort = false; @@ -165,6 +172,7 @@ void verifyWakeupLongPress() { inputManager.update(); // Verify the user has actually pressed + // Needed because inputManager.isPressed() may take up to ~500ms to return the correct state while (!inputManager.isPressed(InputManager::BTN_POWER) && millis() - start < 1000) { delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration. inputManager.update(); @@ -281,11 +289,14 @@ bool isUsbConnected() { return digitalRead(UART0_RXD) == HIGH; } -bool isWakeupAfterFlashing() { +bool isWakeupByPowerButton() { const auto wakeupCause = esp_sleep_get_wakeup_cause(); const auto resetReason = esp_reset_reason(); - - return isUsbConnected() && (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_UNKNOWN); + if (isUsbConnected()) { + return wakeupCause == ESP_SLEEP_WAKEUP_GPIO; + } else { + return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON); + } } void setup() { @@ -322,9 +333,10 @@ void setup() { SETTINGS.loadFromFile(); KOREADER_STORE.loadFromFile(); - if (!isWakeupAfterFlashing()) { - // For normal wakeups (not immediately after flashing), verify long press - verifyWakeupLongPress(); + if (isWakeupByPowerButton()) { + // For normal wakeups, verify power button press duration + Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); + verifyPowerButtonDuration(); } // First serial output only here to avoid timing inconsistencies for power button press duration verification From 6ca75c4653f5a7db0335c25af1a17ac45bcb6c42 Mon Sep 17 00:00:00 2001 From: GenesiaW <74142392+GenesiaW@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:11:11 +0800 Subject: [PATCH 12/22] fix: goes to relative position when reader settings are changed (#486) ## Summary * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) * Aims to fix Issue #220 * **What changes are included?** - Increased size of `progress.bin` such that total page count of current section can be stored - Comparison of total page count is done to determine if reader settings were changed - New position/page number is calculated using percentage calculated from read progress ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- src/activities/reader/EpubReaderActivity.cpp | 26 +++++++++++++++++--- src/activities/reader/EpubReaderActivity.h | 2 ++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index a2e14259..a6d27d34 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -56,12 +56,17 @@ void EpubReaderActivity::onEnter() { FsFile f; if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { - uint8_t data[4]; - if (f.read(data, 4) == 4) { + uint8_t data[6]; + int dataSize = f.read(data, 6); + if (dataSize == 4 || dataSize == 6) { currentSpineIndex = data[0] + (data[1] << 8); nextPageNumber = data[2] + (data[3] << 8); + cachedSpineIndex = currentSpineIndex; Serial.printf("[%lu] [ERS] Loaded cache: %d, %d\n", millis(), currentSpineIndex, nextPageNumber); } + if (dataSize == 6) { + cachedChapterTotalPageCount = data[4] + (data[5] << 8); + } f.close(); } // We may want a better condition to detect if we are opening for the first time. @@ -341,6 +346,17 @@ void EpubReaderActivity::renderScreen() { } else { section->currentPage = nextPageNumber; } + + // handles changes in reader settings and reset to approximate position based on cached progress + if (cachedChapterTotalPageCount > 0) { + // only goes to relative position if spine index matches cached value + if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) { + float progress = static_cast(section->currentPage) / static_cast(cachedChapterTotalPageCount); + int newPage = static_cast(progress * section->pageCount); + section->currentPage = newPage; + } + cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again + } } renderer.clearScreen(); @@ -376,12 +392,14 @@ void EpubReaderActivity::renderScreen() { FsFile f; if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { - uint8_t data[4]; + uint8_t data[6]; data[0] = currentSpineIndex & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF; data[2] = section->currentPage & 0xFF; data[3] = (section->currentPage >> 8) & 0xFF; - f.write(data, 4); + data[4] = section->pageCount & 0xFF; + data[5] = (section->pageCount >> 8) & 0xFF; + f.write(data, 6); f.close(); } } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 63d48872..ab4aff2d 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -15,6 +15,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity { int currentSpineIndex = 0; int nextPageNumber = 0; int pagesUntilFullRefresh = 0; + int cachedSpineIndex = 0; + int cachedChapterTotalPageCount = 0; bool updateRequired = false; const std::function onGoBack; const std::function onGoHome; From aca6dceaa801019dcf59ad968d847f19e58337c6 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Tue, 27 Jan 2026 12:12:40 +0100 Subject: [PATCH 13/22] fix: Make sure img alt text is treated as separate text block (#497) ## Summary Should address issues discussed in #168 and potentially fix #478. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**PARTIALLY**_ --- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 53359179..f6d96be4 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -100,7 +100,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (atts != nullptr) { for (int i = 0; atts[i]; i += 2) { if (strcmp(atts[i], "alt") == 0) { - alt = "[Image: " + std::string(atts[i + 1]) + "]"; + // add " " (counts as whitespace) at the end of alt + // so the corresponding text block ends. + // TODO: A zero-width breaking space would be more appropriate (once/if we support it) + alt = "[Image: " + std::string(atts[i + 1]) + "] "; } } Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str()); @@ -109,7 +112,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->italicUntilDepth = min(self->italicUntilDepth, self->depth); self->depth += 1; self->characterData(userData, alt.c_str(), alt.length()); - + return; } else { // Skip for now self->skipUntilDepth = self->depth; From dfd7b615dccfb1a9263c9aea0e20fedbbc443df3 Mon Sep 17 00:00:00 2001 From: Carson Hicks Date: Tue, 27 Jan 2026 03:14:07 -0800 Subject: [PATCH 14/22] fix: Fix KOReader document md5 calculation for binary matching progress sync (#529) ## Summary * **What is the goal of this PR?** Resolve [KoSync progress does not sync between Crosspoint-reader and KOReader (Kindle)](https://github.com/crosspoint-reader/crosspoint-reader/issues/502) * **What changes are included?** KOReaderDocumentId::getOffset() - Update the value for the md5 offset calculation to match KOReader. ## Additional Context I've tested this with a couple of my ebooks and binary matching with KOReader sync seems to be working fine now for both pushing and pulling progress. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- lib/KOReaderSync/KOReaderDocumentId.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/KOReaderSync/KOReaderDocumentId.cpp b/lib/KOReaderSync/KOReaderDocumentId.cpp index 2c52464c..b33beb75 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.cpp +++ b/lib/KOReaderSync/KOReaderDocumentId.cpp @@ -33,10 +33,10 @@ std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePat size_t KOReaderDocumentId::getOffset(int i) { // Offset = 1024 << (2*i) - // For i = -1: 1024 >> 2 = 256 + // For i = -1: KOReader uses a value of 0 // For i >= 0: 1024 << (2*i) if (i < 0) { - return CHUNK_SIZE >> (-2 * i); + return 0; } return CHUNK_SIZE << (2 * i); } From c73fca26f5e45d672f72175dc55d980ea6bfaf42 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Tue, 27 Jan 2026 14:14:32 +0300 Subject: [PATCH 15/22] docs: Update README with supported languages for EPUB (#530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Update README with the list of supported languages for EPUB files. - Update USER_GUIDE with an extended list of supported and unsupported languages. ## Additional Context For weeks, I thought this firmware only supported English, because I remember you saying that full language support would only be possible after implementing proper font rendering. I also remember mentioning a separate Korean fork, Vietnamese issues and so on. All of this made it clear that this system doesn't support my languages. I was surprised when I saw a Reddit post with a photo of a book in my native language. Only then I did learn that such languages ​​are supported. Therefore, mentioning the supported languages ​​would help future buyers and new users. --- ### AI Usage Did you use AI tools to help write this code? _**NO**_ --- README.md | 4 +++- USER_GUIDE.md | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d59df835..633ae3b8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ This project is **not affiliated with Xteink**; it's built as a community projec - [ ] Full UTF support - [x] Screen rotation -See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint. +Multi-language support: Read EPUBs in various languages, including English, Spanish, French, German, Italian, Portuguese, Russian, Ukrainian, Polish, Swedish, Norwegian, [and more](./USER_GUIDE.md#supported-languages). + +See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint. ## Installing diff --git a/USER_GUIDE.md b/USER_GUIDE.md index f160af74..bdc0f036 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -190,6 +190,15 @@ This feature can be disabled in **[Settings](#35-settings)** to help avoid chang * **Return to Home:** Press and **hold** the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen. * **Chapter Menu:** Press **Confirm** to open the **[Table of Contents/Chapter Selection](#5-chapter-selection-screen)**. +### Supported Languages + +CrossPoint renders text using the following Unicode character blocks, enabling support for a wide range of languages: + +* **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others. +* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others. + +What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic and Farsi. + --- ## 5. Chapter Selection Screen From a4b9a43ca17b403660de7ae7e22a417675069580 Mon Sep 17 00:00:00 2001 From: Boris Faure Date: Tue, 27 Jan 2026 12:19:19 +0100 Subject: [PATCH 16/22] docs: add font generation commands to builtin font headers (#547) ## Summary * **What is the goal of this PR?** Simple quality of life, ease maintenance * **What changes are included?** Update fontconvert.py to include the command used to generate each font file in the header comment, making it easier to regenerate fonts when needed. I plan on adding options to this scripts (kerning, and maybe ligatures), thus knowing which command was used, even with already existing options like `--additional-intervals`, is important. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- lib/EpdFont/builtinFonts/bookerly_12_bold.h | 1 + lib/EpdFont/builtinFonts/bookerly_12_bolditalic.h | 1 + lib/EpdFont/builtinFonts/bookerly_12_italic.h | 1 + lib/EpdFont/builtinFonts/bookerly_12_regular.h | 1 + lib/EpdFont/builtinFonts/bookerly_14_bold.h | 1 + lib/EpdFont/builtinFonts/bookerly_14_bolditalic.h | 1 + lib/EpdFont/builtinFonts/bookerly_14_italic.h | 1 + lib/EpdFont/builtinFonts/bookerly_14_regular.h | 1 + lib/EpdFont/builtinFonts/bookerly_16_bold.h | 1 + lib/EpdFont/builtinFonts/bookerly_16_bolditalic.h | 1 + lib/EpdFont/builtinFonts/bookerly_16_italic.h | 1 + lib/EpdFont/builtinFonts/bookerly_16_regular.h | 1 + lib/EpdFont/builtinFonts/bookerly_18_bold.h | 1 + lib/EpdFont/builtinFonts/bookerly_18_bolditalic.h | 1 + lib/EpdFont/builtinFonts/bookerly_18_italic.h | 1 + lib/EpdFont/builtinFonts/bookerly_18_regular.h | 1 + lib/EpdFont/builtinFonts/notosans_12_bold.h | 1 + lib/EpdFont/builtinFonts/notosans_12_bolditalic.h | 1 + lib/EpdFont/builtinFonts/notosans_12_italic.h | 1 + lib/EpdFont/builtinFonts/notosans_12_regular.h | 1 + lib/EpdFont/builtinFonts/notosans_14_bold.h | 1 + lib/EpdFont/builtinFonts/notosans_14_bolditalic.h | 1 + lib/EpdFont/builtinFonts/notosans_14_italic.h | 1 + lib/EpdFont/builtinFonts/notosans_14_regular.h | 1 + lib/EpdFont/builtinFonts/notosans_16_bold.h | 1 + lib/EpdFont/builtinFonts/notosans_16_bolditalic.h | 1 + lib/EpdFont/builtinFonts/notosans_16_italic.h | 1 + lib/EpdFont/builtinFonts/notosans_16_regular.h | 1 + lib/EpdFont/builtinFonts/notosans_18_bold.h | 1 + lib/EpdFont/builtinFonts/notosans_18_bolditalic.h | 1 + lib/EpdFont/builtinFonts/notosans_18_italic.h | 1 + lib/EpdFont/builtinFonts/notosans_18_regular.h | 1 + lib/EpdFont/builtinFonts/notosans_8_regular.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_10_bold.h | 1 + .../builtinFonts/opendyslexic_10_bolditalic.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_10_italic.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_10_regular.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_12_bold.h | 1 + .../builtinFonts/opendyslexic_12_bolditalic.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_12_italic.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_12_regular.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_14_bold.h | 1 + .../builtinFonts/opendyslexic_14_bolditalic.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_14_italic.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_14_regular.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_8_bold.h | 1 + .../builtinFonts/opendyslexic_8_bolditalic.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_8_italic.h | 1 + lib/EpdFont/builtinFonts/opendyslexic_8_regular.h | 1 + lib/EpdFont/builtinFonts/ubuntu_10_bold.h | 1 + lib/EpdFont/builtinFonts/ubuntu_10_regular.h | 1 + lib/EpdFont/builtinFonts/ubuntu_12_bold.h | 1 + lib/EpdFont/builtinFonts/ubuntu_12_regular.h | 1 + lib/EpdFont/scripts/fontconvert.py | 14 +++++++++++--- 54 files changed, 64 insertions(+), 3 deletions(-) diff --git a/lib/EpdFont/builtinFonts/bookerly_12_bold.h b/lib/EpdFont/builtinFonts/bookerly_12_bold.h index c20b5742..2dd52ca0 100644 --- a/lib/EpdFont/builtinFonts/bookerly_12_bold.h +++ b/lib/EpdFont/builtinFonts/bookerly_12_bold.h @@ -3,6 +3,7 @@ * name: bookerly_12_bold * size: 12 * mode: 2-bit + * Command used: fontconvert.py bookerly_12_bold 12 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_12_bolditalic.h b/lib/EpdFont/builtinFonts/bookerly_12_bolditalic.h index 6e914f48..32b7510b 100644 --- a/lib/EpdFont/builtinFonts/bookerly_12_bolditalic.h +++ b/lib/EpdFont/builtinFonts/bookerly_12_bolditalic.h @@ -3,6 +3,7 @@ * name: bookerly_12_bolditalic * size: 12 * mode: 2-bit + * Command used: fontconvert.py bookerly_12_bolditalic 12 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_12_italic.h b/lib/EpdFont/builtinFonts/bookerly_12_italic.h index 1fbd43b0..0344d9dc 100644 --- a/lib/EpdFont/builtinFonts/bookerly_12_italic.h +++ b/lib/EpdFont/builtinFonts/bookerly_12_italic.h @@ -3,6 +3,7 @@ * name: bookerly_12_italic * size: 12 * mode: 2-bit + * Command used: fontconvert.py bookerly_12_italic 12 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_12_regular.h b/lib/EpdFont/builtinFonts/bookerly_12_regular.h index 1e788d41..a64cbb61 100644 --- a/lib/EpdFont/builtinFonts/bookerly_12_regular.h +++ b/lib/EpdFont/builtinFonts/bookerly_12_regular.h @@ -3,6 +3,7 @@ * name: bookerly_12_regular * size: 12 * mode: 2-bit + * Command used: fontconvert.py bookerly_12_regular 12 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_14_bold.h b/lib/EpdFont/builtinFonts/bookerly_14_bold.h index 793c6d38..98d280dd 100644 --- a/lib/EpdFont/builtinFonts/bookerly_14_bold.h +++ b/lib/EpdFont/builtinFonts/bookerly_14_bold.h @@ -3,6 +3,7 @@ * name: bookerly_14_bold * size: 14 * mode: 2-bit + * Command used: fontconvert.py bookerly_14_bold 14 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_14_bolditalic.h b/lib/EpdFont/builtinFonts/bookerly_14_bolditalic.h index 60da39be..21b55bfe 100644 --- a/lib/EpdFont/builtinFonts/bookerly_14_bolditalic.h +++ b/lib/EpdFont/builtinFonts/bookerly_14_bolditalic.h @@ -3,6 +3,7 @@ * name: bookerly_14_bolditalic * size: 14 * mode: 2-bit + * Command used: fontconvert.py bookerly_14_bolditalic 14 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_14_italic.h b/lib/EpdFont/builtinFonts/bookerly_14_italic.h index a8d196cb..592d2ed7 100644 --- a/lib/EpdFont/builtinFonts/bookerly_14_italic.h +++ b/lib/EpdFont/builtinFonts/bookerly_14_italic.h @@ -3,6 +3,7 @@ * name: bookerly_14_italic * size: 14 * mode: 2-bit + * Command used: fontconvert.py bookerly_14_italic 14 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_14_regular.h b/lib/EpdFont/builtinFonts/bookerly_14_regular.h index 8c8355fe..b1c77366 100644 --- a/lib/EpdFont/builtinFonts/bookerly_14_regular.h +++ b/lib/EpdFont/builtinFonts/bookerly_14_regular.h @@ -3,6 +3,7 @@ * name: bookerly_14_regular * size: 14 * mode: 2-bit + * Command used: fontconvert.py bookerly_14_regular 14 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_16_bold.h b/lib/EpdFont/builtinFonts/bookerly_16_bold.h index 139d37b1..63a791b2 100644 --- a/lib/EpdFont/builtinFonts/bookerly_16_bold.h +++ b/lib/EpdFont/builtinFonts/bookerly_16_bold.h @@ -3,6 +3,7 @@ * name: bookerly_16_bold * size: 16 * mode: 2-bit + * Command used: fontconvert.py bookerly_16_bold 16 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_16_bolditalic.h b/lib/EpdFont/builtinFonts/bookerly_16_bolditalic.h index c68f1208..46a0bb5a 100644 --- a/lib/EpdFont/builtinFonts/bookerly_16_bolditalic.h +++ b/lib/EpdFont/builtinFonts/bookerly_16_bolditalic.h @@ -3,6 +3,7 @@ * name: bookerly_16_bolditalic * size: 16 * mode: 2-bit + * Command used: fontconvert.py bookerly_16_bolditalic 16 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_16_italic.h b/lib/EpdFont/builtinFonts/bookerly_16_italic.h index bdbb6a65..2d699f61 100644 --- a/lib/EpdFont/builtinFonts/bookerly_16_italic.h +++ b/lib/EpdFont/builtinFonts/bookerly_16_italic.h @@ -3,6 +3,7 @@ * name: bookerly_16_italic * size: 16 * mode: 2-bit + * Command used: fontconvert.py bookerly_16_italic 16 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_16_regular.h b/lib/EpdFont/builtinFonts/bookerly_16_regular.h index c980928e..2948146a 100644 --- a/lib/EpdFont/builtinFonts/bookerly_16_regular.h +++ b/lib/EpdFont/builtinFonts/bookerly_16_regular.h @@ -3,6 +3,7 @@ * name: bookerly_16_regular * size: 16 * mode: 2-bit + * Command used: fontconvert.py bookerly_16_regular 16 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_18_bold.h b/lib/EpdFont/builtinFonts/bookerly_18_bold.h index ca8078bf..e281af85 100644 --- a/lib/EpdFont/builtinFonts/bookerly_18_bold.h +++ b/lib/EpdFont/builtinFonts/bookerly_18_bold.h @@ -3,6 +3,7 @@ * name: bookerly_18_bold * size: 18 * mode: 2-bit + * Command used: fontconvert.py bookerly_18_bold 18 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_18_bolditalic.h b/lib/EpdFont/builtinFonts/bookerly_18_bolditalic.h index 42f46796..4562dc52 100644 --- a/lib/EpdFont/builtinFonts/bookerly_18_bolditalic.h +++ b/lib/EpdFont/builtinFonts/bookerly_18_bolditalic.h @@ -3,6 +3,7 @@ * name: bookerly_18_bolditalic * size: 18 * mode: 2-bit + * Command used: fontconvert.py bookerly_18_bolditalic 18 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_18_italic.h b/lib/EpdFont/builtinFonts/bookerly_18_italic.h index 8534b03e..643b5cc1 100644 --- a/lib/EpdFont/builtinFonts/bookerly_18_italic.h +++ b/lib/EpdFont/builtinFonts/bookerly_18_italic.h @@ -3,6 +3,7 @@ * name: bookerly_18_italic * size: 18 * mode: 2-bit + * Command used: fontconvert.py bookerly_18_italic 18 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_18_regular.h b/lib/EpdFont/builtinFonts/bookerly_18_regular.h index 6d638e65..a6297ea9 100644 --- a/lib/EpdFont/builtinFonts/bookerly_18_regular.h +++ b/lib/EpdFont/builtinFonts/bookerly_18_regular.h @@ -3,6 +3,7 @@ * name: bookerly_18_regular * size: 18 * mode: 2-bit + * Command used: fontconvert.py bookerly_18_regular 18 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_12_bold.h b/lib/EpdFont/builtinFonts/notosans_12_bold.h index 57107166..65ade32a 100644 --- a/lib/EpdFont/builtinFonts/notosans_12_bold.h +++ b/lib/EpdFont/builtinFonts/notosans_12_bold.h @@ -3,6 +3,7 @@ * name: notosans_12_bold * size: 12 * mode: 2-bit + * Command used: fontconvert.py notosans_12_bold 12 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_12_bolditalic.h b/lib/EpdFont/builtinFonts/notosans_12_bolditalic.h index 1b485f7d..6ef7ef4a 100644 --- a/lib/EpdFont/builtinFonts/notosans_12_bolditalic.h +++ b/lib/EpdFont/builtinFonts/notosans_12_bolditalic.h @@ -3,6 +3,7 @@ * name: notosans_12_bolditalic * size: 12 * mode: 2-bit + * Command used: fontconvert.py notosans_12_bolditalic 12 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_12_italic.h b/lib/EpdFont/builtinFonts/notosans_12_italic.h index 994dc40a..a599577f 100644 --- a/lib/EpdFont/builtinFonts/notosans_12_italic.h +++ b/lib/EpdFont/builtinFonts/notosans_12_italic.h @@ -3,6 +3,7 @@ * name: notosans_12_italic * size: 12 * mode: 2-bit + * Command used: fontconvert.py notosans_12_italic 12 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_12_regular.h b/lib/EpdFont/builtinFonts/notosans_12_regular.h index ff47f6fd..a89cb380 100644 --- a/lib/EpdFont/builtinFonts/notosans_12_regular.h +++ b/lib/EpdFont/builtinFonts/notosans_12_regular.h @@ -3,6 +3,7 @@ * name: notosans_12_regular * size: 12 * mode: 2-bit + * Command used: fontconvert.py notosans_12_regular 12 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_14_bold.h b/lib/EpdFont/builtinFonts/notosans_14_bold.h index 1f948b93..70403581 100644 --- a/lib/EpdFont/builtinFonts/notosans_14_bold.h +++ b/lib/EpdFont/builtinFonts/notosans_14_bold.h @@ -3,6 +3,7 @@ * name: notosans_14_bold * size: 14 * mode: 2-bit + * Command used: fontconvert.py notosans_14_bold 14 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_14_bolditalic.h b/lib/EpdFont/builtinFonts/notosans_14_bolditalic.h index f75fa527..f4168354 100644 --- a/lib/EpdFont/builtinFonts/notosans_14_bolditalic.h +++ b/lib/EpdFont/builtinFonts/notosans_14_bolditalic.h @@ -3,6 +3,7 @@ * name: notosans_14_bolditalic * size: 14 * mode: 2-bit + * Command used: fontconvert.py notosans_14_bolditalic 14 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_14_italic.h b/lib/EpdFont/builtinFonts/notosans_14_italic.h index d7d00a53..18cc49e0 100644 --- a/lib/EpdFont/builtinFonts/notosans_14_italic.h +++ b/lib/EpdFont/builtinFonts/notosans_14_italic.h @@ -3,6 +3,7 @@ * name: notosans_14_italic * size: 14 * mode: 2-bit + * Command used: fontconvert.py notosans_14_italic 14 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_14_regular.h b/lib/EpdFont/builtinFonts/notosans_14_regular.h index f93afddc..a8f7fbba 100644 --- a/lib/EpdFont/builtinFonts/notosans_14_regular.h +++ b/lib/EpdFont/builtinFonts/notosans_14_regular.h @@ -3,6 +3,7 @@ * name: notosans_14_regular * size: 14 * mode: 2-bit + * Command used: fontconvert.py notosans_14_regular 14 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_16_bold.h b/lib/EpdFont/builtinFonts/notosans_16_bold.h index b6a0414a..4e346852 100644 --- a/lib/EpdFont/builtinFonts/notosans_16_bold.h +++ b/lib/EpdFont/builtinFonts/notosans_16_bold.h @@ -3,6 +3,7 @@ * name: notosans_16_bold * size: 16 * mode: 2-bit + * Command used: fontconvert.py notosans_16_bold 16 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_16_bolditalic.h b/lib/EpdFont/builtinFonts/notosans_16_bolditalic.h index 8452a245..8c5bc3e5 100644 --- a/lib/EpdFont/builtinFonts/notosans_16_bolditalic.h +++ b/lib/EpdFont/builtinFonts/notosans_16_bolditalic.h @@ -3,6 +3,7 @@ * name: notosans_16_bolditalic * size: 16 * mode: 2-bit + * Command used: fontconvert.py notosans_16_bolditalic 16 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_16_italic.h b/lib/EpdFont/builtinFonts/notosans_16_italic.h index d1a0cac5..e129f3ed 100644 --- a/lib/EpdFont/builtinFonts/notosans_16_italic.h +++ b/lib/EpdFont/builtinFonts/notosans_16_italic.h @@ -3,6 +3,7 @@ * name: notosans_16_italic * size: 16 * mode: 2-bit + * Command used: fontconvert.py notosans_16_italic 16 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_16_regular.h b/lib/EpdFont/builtinFonts/notosans_16_regular.h index 24398196..f07dc566 100644 --- a/lib/EpdFont/builtinFonts/notosans_16_regular.h +++ b/lib/EpdFont/builtinFonts/notosans_16_regular.h @@ -3,6 +3,7 @@ * name: notosans_16_regular * size: 16 * mode: 2-bit + * Command used: fontconvert.py notosans_16_regular 16 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_18_bold.h b/lib/EpdFont/builtinFonts/notosans_18_bold.h index cb57a3bf..e2eb5799 100644 --- a/lib/EpdFont/builtinFonts/notosans_18_bold.h +++ b/lib/EpdFont/builtinFonts/notosans_18_bold.h @@ -3,6 +3,7 @@ * name: notosans_18_bold * size: 18 * mode: 2-bit + * Command used: fontconvert.py notosans_18_bold 18 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_18_bolditalic.h b/lib/EpdFont/builtinFonts/notosans_18_bolditalic.h index bd09ce14..465d847f 100644 --- a/lib/EpdFont/builtinFonts/notosans_18_bolditalic.h +++ b/lib/EpdFont/builtinFonts/notosans_18_bolditalic.h @@ -3,6 +3,7 @@ * name: notosans_18_bolditalic * size: 18 * mode: 2-bit + * Command used: fontconvert.py notosans_18_bolditalic 18 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_18_italic.h b/lib/EpdFont/builtinFonts/notosans_18_italic.h index 926bd32e..0e36e189 100644 --- a/lib/EpdFont/builtinFonts/notosans_18_italic.h +++ b/lib/EpdFont/builtinFonts/notosans_18_italic.h @@ -3,6 +3,7 @@ * name: notosans_18_italic * size: 18 * mode: 2-bit + * Command used: fontconvert.py notosans_18_italic 18 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_18_regular.h b/lib/EpdFont/builtinFonts/notosans_18_regular.h index d8bbe9c7..029ff804 100644 --- a/lib/EpdFont/builtinFonts/notosans_18_regular.h +++ b/lib/EpdFont/builtinFonts/notosans_18_regular.h @@ -3,6 +3,7 @@ * name: notosans_18_regular * size: 18 * mode: 2-bit + * Command used: fontconvert.py notosans_18_regular 18 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_8_regular.h b/lib/EpdFont/builtinFonts/notosans_8_regular.h index 0c01edcc..7e339184 100644 --- a/lib/EpdFont/builtinFonts/notosans_8_regular.h +++ b/lib/EpdFont/builtinFonts/notosans_8_regular.h @@ -3,6 +3,7 @@ * name: notosans_8_regular * size: 8 * mode: 1-bit + * Command used: fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_10_bold.h b/lib/EpdFont/builtinFonts/opendyslexic_10_bold.h index eb900628..b3a16e6e 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_10_bold.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_10_bold.h @@ -3,6 +3,7 @@ * name: opendyslexic_10_bold * size: 10 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_10_bold 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_10_bolditalic.h b/lib/EpdFont/builtinFonts/opendyslexic_10_bolditalic.h index f2a45714..e939db2d 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_10_bolditalic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_10_bolditalic.h @@ -3,6 +3,7 @@ * name: opendyslexic_10_bolditalic * size: 10 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_10_bolditalic 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_10_italic.h b/lib/EpdFont/builtinFonts/opendyslexic_10_italic.h index 2e9f4127..e0f43bb1 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_10_italic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_10_italic.h @@ -3,6 +3,7 @@ * name: opendyslexic_10_italic * size: 10 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_10_italic 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_10_regular.h b/lib/EpdFont/builtinFonts/opendyslexic_10_regular.h index 928d7526..0fded271 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_10_regular.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_10_regular.h @@ -3,6 +3,7 @@ * name: opendyslexic_10_regular * size: 10 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_10_regular 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_12_bold.h b/lib/EpdFont/builtinFonts/opendyslexic_12_bold.h index 5f7c8ecc..115a737c 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_12_bold.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_12_bold.h @@ -3,6 +3,7 @@ * name: opendyslexic_12_bold * size: 12 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_12_bold 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_12_bolditalic.h b/lib/EpdFont/builtinFonts/opendyslexic_12_bolditalic.h index fdb3f63b..54732e1a 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_12_bolditalic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_12_bolditalic.h @@ -3,6 +3,7 @@ * name: opendyslexic_12_bolditalic * size: 12 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_12_bolditalic 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_12_italic.h b/lib/EpdFont/builtinFonts/opendyslexic_12_italic.h index 4ce9eed8..d927f96c 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_12_italic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_12_italic.h @@ -3,6 +3,7 @@ * name: opendyslexic_12_italic * size: 12 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_12_italic 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_12_regular.h b/lib/EpdFont/builtinFonts/opendyslexic_12_regular.h index 596ee1ec..61643c60 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_12_regular.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_12_regular.h @@ -3,6 +3,7 @@ * name: opendyslexic_12_regular * size: 12 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_12_regular 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_14_bold.h b/lib/EpdFont/builtinFonts/opendyslexic_14_bold.h index b5de40b6..e150dbd3 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_14_bold.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_14_bold.h @@ -3,6 +3,7 @@ * name: opendyslexic_14_bold * size: 14 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_14_bold 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_14_bolditalic.h b/lib/EpdFont/builtinFonts/opendyslexic_14_bolditalic.h index dae158ca..9aa5e19d 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_14_bolditalic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_14_bolditalic.h @@ -3,6 +3,7 @@ * name: opendyslexic_14_bolditalic * size: 14 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_14_bolditalic 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_14_italic.h b/lib/EpdFont/builtinFonts/opendyslexic_14_italic.h index d69b842a..06fd04d4 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_14_italic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_14_italic.h @@ -3,6 +3,7 @@ * name: opendyslexic_14_italic * size: 14 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_14_italic 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_14_regular.h b/lib/EpdFont/builtinFonts/opendyslexic_14_regular.h index f45e71ae..cda4f876 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_14_regular.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_14_regular.h @@ -3,6 +3,7 @@ * name: opendyslexic_14_regular * size: 14 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_14_regular 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_8_bold.h b/lib/EpdFont/builtinFonts/opendyslexic_8_bold.h index b0fc804c..72e131d8 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_8_bold.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_8_bold.h @@ -3,6 +3,7 @@ * name: opendyslexic_8_bold * size: 8 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_8_bold 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_8_bolditalic.h b/lib/EpdFont/builtinFonts/opendyslexic_8_bolditalic.h index 77336edf..4858ad08 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_8_bolditalic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_8_bolditalic.h @@ -3,6 +3,7 @@ * name: opendyslexic_8_bolditalic * size: 8 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_8_bolditalic 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_8_italic.h b/lib/EpdFont/builtinFonts/opendyslexic_8_italic.h index 37dcfa99..62e37b32 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_8_italic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_8_italic.h @@ -3,6 +3,7 @@ * name: opendyslexic_8_italic * size: 8 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_8_italic 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_8_regular.h b/lib/EpdFont/builtinFonts/opendyslexic_8_regular.h index f68c7438..fae287a5 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_8_regular.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_8_regular.h @@ -3,6 +3,7 @@ * name: opendyslexic_8_regular * size: 8 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_8_regular 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/ubuntu_10_bold.h b/lib/EpdFont/builtinFonts/ubuntu_10_bold.h index cab81b10..80032fd8 100644 --- a/lib/EpdFont/builtinFonts/ubuntu_10_bold.h +++ b/lib/EpdFont/builtinFonts/ubuntu_10_bold.h @@ -3,6 +3,7 @@ * name: ubuntu_10_bold * size: 10 * mode: 1-bit + * Command used: fontconvert.py ubuntu_10_bold 10 ../builtinFonts/source/Ubuntu/Ubuntu-Bold.ttf */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/ubuntu_10_regular.h b/lib/EpdFont/builtinFonts/ubuntu_10_regular.h index a7292c19..e76ab2c0 100644 --- a/lib/EpdFont/builtinFonts/ubuntu_10_regular.h +++ b/lib/EpdFont/builtinFonts/ubuntu_10_regular.h @@ -3,6 +3,7 @@ * name: ubuntu_10_regular * size: 10 * mode: 1-bit + * Command used: fontconvert.py ubuntu_10_regular 10 ../builtinFonts/source/Ubuntu/Ubuntu-Regular.ttf */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/ubuntu_12_bold.h b/lib/EpdFont/builtinFonts/ubuntu_12_bold.h index 9419ed4b..5b24d067 100644 --- a/lib/EpdFont/builtinFonts/ubuntu_12_bold.h +++ b/lib/EpdFont/builtinFonts/ubuntu_12_bold.h @@ -3,6 +3,7 @@ * name: ubuntu_12_bold * size: 12 * mode: 1-bit + * Command used: fontconvert.py ubuntu_12_bold 12 ../builtinFonts/source/Ubuntu/Ubuntu-Bold.ttf */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/ubuntu_12_regular.h b/lib/EpdFont/builtinFonts/ubuntu_12_regular.h index f02de88c..23ddbe78 100644 --- a/lib/EpdFont/builtinFonts/ubuntu_12_regular.h +++ b/lib/EpdFont/builtinFonts/ubuntu_12_regular.h @@ -3,6 +3,7 @@ * name: ubuntu_12_regular * size: 12 * mode: 1-bit + * Command used: fontconvert.py ubuntu_12_regular 12 ../builtinFonts/source/Ubuntu/Ubuntu-Regular.ttf */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/scripts/fontconvert.py b/lib/EpdFont/scripts/fontconvert.py index d11f73b7..ba7a44af 100755 --- a/lib/EpdFont/scripts/fontconvert.py +++ b/lib/EpdFont/scripts/fontconvert.py @@ -270,9 +270,17 @@ for index, glyph in enumerate(all_glyphs): glyph_data.extend([b for b in packed]) glyph_props.append(props) -print(f"/**\n * generated by fontconvert.py\n * name: {font_name}\n * size: {size}\n * mode: {'2-bit' if is2Bit else '1-bit'}\n */") -print("#pragma once") -print("#include \"EpdFontData.h\"\n") +print(f"""/** + * generated by fontconvert.py + * name: {font_name} + * size: {size} + * mode: {'2-bit' if is2Bit else '1-bit'} + * Command used: {' '.join(sys.argv)} + */ +#pragma once +#include "EpdFontData.h" +""") + print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{") for c in chunks(glyph_data, 16): print (" " + " ".join(f"0x{b:02X}," for b in c)) From e2ca0e94ca827a96cc0e88fad44cc7b3ab5b5864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B3=D0=BE=D1=80=20=D0=9C=D0=B0=D1=80=D1=82=D1=8B?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2?= Date: Tue, 27 Jan 2026 18:53:31 +0700 Subject: [PATCH 17/22] fix: add txt books to recent tab (#526) Fixes #512 --- ### AI Usage _**NO**_ --- src/activities/reader/TxtReaderActivity.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index db725320..b7de16d8 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -8,6 +8,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "RecentBooksStore.h" #include "ScreenComponents.h" #include "fontIds.h" @@ -55,9 +56,10 @@ void TxtReaderActivity::onEnter() { txt->setupCacheDir(); - // Save current txt as last opened file + // Save current txt as last opened file and add to recent books APP_STATE.openEpubPath = txt->getPath(); APP_STATE.saveToFile(); + RECENT_BOOKS.addBook(txt->getPath()); // Trigger first update updateRequired = true; From 5e24895f6d0f6c4687f13f3c1067b511c5da6fee Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 27 Jan 2026 22:56:51 +1100 Subject: [PATCH 18/22] feat: Extract author from XTC/XTCH files (#563) ## Summary * Extract author from XTC/XTCH files ## Additional Context * Based on updated details in https://gist.github.com/CrazyCoder/b125f26d6987c0620058249f59f1327d --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? No --- lib/Xtc/Xtc.cpp | 10 ++++++- lib/Xtc/Xtc.h | 1 + lib/Xtc/Xtc/XtcParser.cpp | 43 +++++++++++++++++++++------- lib/Xtc/Xtc/XtcParser.h | 5 +++- lib/Xtc/Xtc/XtcTypes.h | 14 +++++---- src/activities/home/HomeActivity.cpp | 3 ++ 6 files changed, 58 insertions(+), 18 deletions(-) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index c79421d7..7850d934 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -7,7 +7,6 @@ #include "Xtc.h" -#include #include #include @@ -87,6 +86,15 @@ std::string Xtc::getTitle() const { return filepath.substr(lastSlash, lastDot - lastSlash); } +std::string Xtc::getAuthor() const { + if (!loaded || !parser) { + return ""; + } + + // Try to get author from XTC metadata + return parser->getAuthor(); +} + bool Xtc::hasChapters() const { if (!loaded || !parser) { return false; diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index 7413ef47..c8d9a040 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -56,6 +56,7 @@ class Xtc { // Metadata std::string getTitle() const; + std::string getAuthor() const; bool hasChapters() const; const std::vector& getChapters() const; diff --git a/lib/Xtc/Xtc/XtcParser.cpp b/lib/Xtc/Xtc/XtcParser.cpp index c33e7193..8db3dead 100644 --- a/lib/Xtc/Xtc/XtcParser.cpp +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -47,8 +47,21 @@ XtcError XtcParser::open(const char* filepath) { return m_lastError; } - // Read title if available - readTitle(); + // Read title & author if available + if (m_header.hasMetadata) { + m_lastError = readTitle(); + if (m_lastError != XtcError::OK) { + Serial.printf("[%lu] [XTC] Failed to read title: %s\n", millis(), errorToString(m_lastError)); + m_file.close(); + return m_lastError; + } + m_lastError = readAuthor(); + if (m_lastError != XtcError::OK) { + Serial.printf("[%lu] [XTC] Failed to read author: %s\n", millis(), errorToString(m_lastError)); + m_file.close(); + return m_lastError; + } + } // Read page table m_lastError = readPageTable(); @@ -124,24 +137,34 @@ XtcError XtcParser::readHeader() { } XtcError XtcParser::readTitle() { - // Title is usually at offset 0x38 (56) for 88-byte headers - // Read title as null-terminated UTF-8 string - if (m_header.titleOffset == 0) { - m_header.titleOffset = 0x38; // Default offset - } - - if (!m_file.seek(m_header.titleOffset)) { + constexpr auto titleOffset = 0x38; + if (!m_file.seek(titleOffset)) { return XtcError::READ_ERROR; } char titleBuf[128] = {0}; - m_file.read(reinterpret_cast(titleBuf), sizeof(titleBuf) - 1); + m_file.read(titleBuf, sizeof(titleBuf) - 1); m_title = titleBuf; Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str()); return XtcError::OK; } +XtcError XtcParser::readAuthor() { + // Read author as null-terminated UTF-8 string with max length 64, directly following title + constexpr auto authorOffset = 0xB8; + if (!m_file.seek(authorOffset)) { + return XtcError::READ_ERROR; + } + + char authorBuf[64] = {0}; + m_file.read(authorBuf, sizeof(authorBuf) - 1); + m_author = authorBuf; + + Serial.printf("[%lu] [XTC] Author: %s\n", millis(), m_author.c_str()); + return XtcError::OK; +} + XtcError XtcParser::readPageTable() { if (m_header.pageTableOffset == 0) { Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis()); diff --git a/lib/Xtc/Xtc/XtcParser.h b/lib/Xtc/Xtc/XtcParser.h index 2d2b780e..b0033542 100644 --- a/lib/Xtc/Xtc/XtcParser.h +++ b/lib/Xtc/Xtc/XtcParser.h @@ -67,8 +67,9 @@ class XtcParser { std::function callback, size_t chunkSize = 1024); - // Get title from metadata + // Get title/author from metadata std::string getTitle() const { return m_title; } + std::string getAuthor() const { return m_author; } bool hasChapters() const { return m_hasChapters; } const std::vector& getChapters() const { return m_chapters; } @@ -86,6 +87,7 @@ class XtcParser { std::vector m_pageTable; std::vector m_chapters; std::string m_title; + std::string m_author; uint16_t m_defaultWidth; uint16_t m_defaultHeight; uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit) @@ -96,6 +98,7 @@ class XtcParser { XtcError readHeader(); XtcError readPageTable(); XtcError readTitle(); + XtcError readAuthor(); XtcError readChapters(); }; diff --git a/lib/Xtc/Xtc/XtcTypes.h b/lib/Xtc/Xtc/XtcTypes.h index 08f9c00b..773c7ad5 100644 --- a/lib/Xtc/Xtc/XtcTypes.h +++ b/lib/Xtc/Xtc/XtcTypes.h @@ -38,14 +38,16 @@ struct XtcHeader { uint8_t versionMajor; // 0x04: Format version major (typically 1) (together with minor = 1.0) uint8_t versionMinor; // 0x05: Format version minor (typically 0) uint16_t pageCount; // 0x06: Total page count - uint32_t flags; // 0x08: Flags/reserved - uint32_t headerSize; // 0x0C: Size of header section (typically 88) - uint32_t reserved1; // 0x10: Reserved - uint32_t tocOffset; // 0x14: TOC offset (0 if unused) - 4 bytes, not 8! + uint8_t readDirection; // 0x08: Reading direction (0-2) + uint8_t hasMetadata; // 0x09: Has metadata (0-1) + uint8_t hasThumbnails; // 0x0A: Has thumbnails (0-1) + uint8_t hasChapters; // 0x0B: Has chapters (0-1) + uint32_t currentPage; // 0x0C: Current page (1-based) (0-65535) + uint64_t metadataOffset; // 0x10: Metadata offset (0 if unused) uint64_t pageTableOffset; // 0x18: Page table offset uint64_t dataOffset; // 0x20: First page data offset - uint64_t reserved2; // 0x28: Reserved - uint32_t titleOffset; // 0x30: Title string offset + uint64_t thumbOffset; // 0x28: Thumbnail offset + uint32_t chapterOffset; // 0x30: Chapter data offset uint32_t padding; // 0x34: Padding to 56 bytes }; #pragma pack(pop) diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 3389e80d..58b29505 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -71,6 +71,9 @@ void HomeActivity::onEnter() { if (!xtc.getTitle().empty()) { lastBookTitle = std::string(xtc.getTitle()); } + if (!xtc.getAuthor().empty()) { + lastBookAuthor = std::string(xtc.getAuthor()); + } // Try to generate thumbnail image for Continue Reading card if (xtc.generateThumbBmp()) { coverBmpPath = xtc.getThumbBmpPath(); From 51c5c3c0aa665f0852bd8bda42a5d4aa44ae4add Mon Sep 17 00:00:00 2001 From: Maeve Andrews <37351465+maeveynot@users.noreply.github.com> Date: Tue, 27 Jan 2026 05:59:41 -0600 Subject: [PATCH 19/22] fix: rotate origin in drawImage (#557) ## Summary This was originally a comment in #499, but I'm making it its own PR, because it doesn't depend on anything there and then I can base that PR on this one. Currently, `drawBitmap` is used for covers and sleep wallpaper, and `drawImage` is used for the boot logo. `drawBitmap` goes row by row and pixel by pixel, so it respects the renderer orientation. `drawImage` just calls the `EInkDisplay`'s `drawImage`, which works in the eink panel's native display orientation. `drawImage` rotates the x,y coordinates where it's going to draw the image, but doesn't account for the fact that the northwest corner in portrait orientation becomes, the southwest corner of the image rectangle in the native orientation. The boot and sleep activities currently work around this by calculating the north*east* corner of where the image should go, which becomes the northwest corner after `rotateCoordinates`. I think this wasn't really apparent because the CrossPoint logo is rotationally symmetrical. The `EInkDisplay` `drawImage` always draws the image in native orientation, but that looks the same for the "X" image. If we rotate the origin coordinate in `GfxRenderer`'s `drawImage`, we can use a much clearer northwest corner coordinate in the boot and sleep activities. (And then, in #499, we can actually rotate the boot screen to the user's preferred orientation). This does *not* yet rotate the actual bits in the image; it's still displayed in native orientation. This doesn't affect the rotationally-symmetric logo, but if it's ever changed, we will probably want to allocate a new `u8int[]` and transpose rows and columns if necessary. ## Additional Context I've created an additional branch on top of this to demonstrate by replacing the logo with a non-rotationally-symmetrical image: Cat-in-a-pan-128-bw https://github.com/crosspoint-reader/crosspoint-reader/compare/master...maeveynot:rotated-cat (many thanks to https://notisrac.github.io/FileToCArray/) As you can see, it is always drawn in native orientation, which makes it sideways (turned clockwise) in portrait. --- ### AI Usage No Co-authored-by: Maeve Andrews --- lib/GfxRenderer/GfxRenderer.cpp | 17 ++++++++++++++++- src/activities/boot_sleep/BootActivity.cpp | 2 +- src/activities/boot_sleep/SleepActivity.cpp | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 08420bf9..1dbe8ee6 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -145,10 +145,25 @@ void GfxRenderer::fillRect(const int x, const int y, const int width, const int } void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { - // TODO: Rotate bits int rotatedX = 0; int rotatedY = 0; rotateCoordinates(x, y, &rotatedX, &rotatedY); + // Rotate origin corner + switch (orientation) { + case Portrait: + rotatedY = rotatedY - height; + break; + case PortraitInverted: + rotatedX = rotatedX - width; + break; + case LandscapeClockwise: + rotatedY = rotatedY - height; + rotatedX = rotatedX - width; + break; + case LandscapeCounterClockwise: + break; + } + // TODO: Rotate bits einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height); } diff --git a/src/activities/boot_sleep/BootActivity.cpp b/src/activities/boot_sleep/BootActivity.cpp index 65eb6a07..b741c3e3 100644 --- a/src/activities/boot_sleep/BootActivity.cpp +++ b/src/activities/boot_sleep/BootActivity.cpp @@ -12,7 +12,7 @@ void BootActivity::onEnter() { const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); - renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128); + renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "BOOTING"); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION); diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 40341e5f..c4b98968 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -124,7 +124,7 @@ void SleepActivity::renderDefaultSleepScreen() const { const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); - renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128); + renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING"); From dd1741bf0b53ce7abf9fc8c50106bf2c7819fd0c Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Tue, 27 Jan 2026 13:08:58 +0100 Subject: [PATCH 20/22] fix: Validate settings on read. (#492) ## Summary Fixes #487 --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _** YES **_ Co-authored-by: Dave Allie --- src/CrossPointSettings.cpp | 36 ++++++++++++++---------- src/CrossPointSettings.h | 57 ++++++++++++++++++++++++++------------ 2 files changed, 61 insertions(+), 32 deletions(-) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index ea26ad91..f3a7a524 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -11,6 +11,14 @@ // Initialize the static instance CrossPointSettings CrossPointSettings::instance; +void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) { + uint8_t tempValue; + serialization::readPod(file, tempValue); + if (tempValue < maxValue) { + member = tempValue; + } +} + namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields @@ -78,35 +86,35 @@ bool CrossPointSettings::loadFromFile() { // load settings that exist (support older files with fewer fields) uint8_t settingsRead = 0; do { - serialization::readPod(inputFile, sleepScreen); + readAndValidate(inputFile, sleepScreen, SLEEP_SCREEN_MODE_COUNT); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, extraParagraphSpacing); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, shortPwrBtn); + readAndValidate(inputFile, shortPwrBtn, SHORT_PWRBTN_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, statusBar); + readAndValidate(inputFile, statusBar, STATUS_BAR_MODE_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, orientation); + readAndValidate(inputFile, orientation, ORIENTATION_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, frontButtonLayout); + readAndValidate(inputFile, frontButtonLayout, FRONT_BUTTON_LAYOUT_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, sideButtonLayout); + readAndValidate(inputFile, sideButtonLayout, SIDE_BUTTON_LAYOUT_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, fontFamily); + readAndValidate(inputFile, fontFamily, FONT_FAMILY_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, fontSize); + readAndValidate(inputFile, fontSize, FONT_SIZE_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, lineSpacing); + readAndValidate(inputFile, lineSpacing, LINE_COMPRESSION_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, paragraphAlignment); + readAndValidate(inputFile, paragraphAlignment, PARAGRAPH_ALIGNMENT_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, sleepTimeout); + readAndValidate(inputFile, sleepTimeout, SLEEP_TIMEOUT_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, refreshFrequency); + readAndValidate(inputFile, refreshFrequency, REFRESH_FREQUENCY_COUNT); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, screenMargin); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, sleepScreenCoverMode); + readAndValidate(inputFile, sleepScreenCoverMode, SLEEP_SCREEN_COVER_MODE_COUNT); if (++settingsRead >= fileSettingsCount) break; { std::string urlStr; @@ -117,7 +125,7 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, textAntiAliasing); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, hideBatteryPercentage); + readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, longPressChapterSkip); if (++settingsRead >= fileSettingsCount) break; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index f8892bef..e2883425 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -15,18 +15,18 @@ class CrossPointSettings { CrossPointSettings(const CrossPointSettings&) = delete; CrossPointSettings& operator=(const CrossPointSettings&) = delete; - // Should match with SettingsActivity text - enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4 }; - enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1 }; + enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4, SLEEP_SCREEN_MODE_COUNT }; + enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1, SLEEP_SCREEN_COVER_MODE_COUNT }; // Status bar display type enum - enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 }; + enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2, STATUS_BAR_MODE_COUNT }; enum ORIENTATION { - PORTRAIT = 0, // 480x800 logical coordinates (current default) - LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom) - INVERTED = 2, // 480x800 logical coordinates, inverted - LANDSCAPE_CCW = 3 // 800x480 logical coordinates, native panel orientation + PORTRAIT = 0, // 480x800 logical coordinates (current default) + LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom) + INVERTED = 2, // 480x800 logical coordinates, inverted + LANDSCAPE_CCW = 3, // 800x480 logical coordinates, native panel orientation + ORIENTATION_COUNT }; // Front button layout options @@ -36,32 +36,53 @@ class CrossPointSettings { BACK_CONFIRM_LEFT_RIGHT = 0, LEFT_RIGHT_BACK_CONFIRM = 1, LEFT_BACK_CONFIRM_RIGHT = 2, - BACK_CONFIRM_RIGHT_LEFT = 3 + BACK_CONFIRM_RIGHT_LEFT = 3, + FRONT_BUTTON_LAYOUT_COUNT }; // Side button layout options // Default: Previous, Next // Swapped: Next, Previous - enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1 }; + enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1, SIDE_BUTTON_LAYOUT_COUNT }; // Font family options - enum FONT_FAMILY { BOOKERLY = 0, NOTOSANS = 1, OPENDYSLEXIC = 2 }; + enum FONT_FAMILY { BOOKERLY = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, FONT_FAMILY_COUNT }; // Font size options - enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 }; - enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 }; - enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3 }; + enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3, FONT_SIZE_COUNT }; + enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2, LINE_COMPRESSION_COUNT }; + enum PARAGRAPH_ALIGNMENT { + JUSTIFIED = 0, + LEFT_ALIGN = 1, + CENTER_ALIGN = 2, + RIGHT_ALIGN = 3, + PARAGRAPH_ALIGNMENT_COUNT + }; // Auto-sleep timeout options (in minutes) - enum SLEEP_TIMEOUT { SLEEP_1_MIN = 0, SLEEP_5_MIN = 1, SLEEP_10_MIN = 2, SLEEP_15_MIN = 3, SLEEP_30_MIN = 4 }; + enum SLEEP_TIMEOUT { + SLEEP_1_MIN = 0, + SLEEP_5_MIN = 1, + SLEEP_10_MIN = 2, + SLEEP_15_MIN = 3, + SLEEP_30_MIN = 4, + SLEEP_TIMEOUT_COUNT + }; // E-ink refresh frequency (pages between full refreshes) - enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 }; + enum REFRESH_FREQUENCY { + REFRESH_1 = 0, + REFRESH_5 = 1, + REFRESH_10 = 2, + REFRESH_15 = 3, + REFRESH_30 = 4, + REFRESH_FREQUENCY_COUNT + }; // Short power button press actions - enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2 }; + enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, SHORT_PWRBTN_COUNT }; // Hide battery percentage - enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2 }; + enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT }; // Sleep screen settings uint8_t sleepScreen = DARK; From e9c2fe1c8780c68af9bc0a7aca8801a3ddf58313 Mon Sep 17 00:00:00 2001 From: Alex Faria <3195321+alexfaria@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:25:44 +0000 Subject: [PATCH 21/22] feat: Add status bar option "Full w/ Progress Bar" (#438) ## Summary * **What is the goal of this PR?** This PR introduces a new "Status Bar" mode that displays a visual progress bar at the bottom of the screen, providing readers with a graphical indication of their position within the book. * **What changes are included?** * **Settings**: Updated SettingsActivity to expand the "Status Bar" configuration with a new option: Full w/ Progress Bar. * **EPUB Reader**: Modified EpubReaderActivity to calculate the global book progress and render a progress bar at the bottom of the viewable area when the new setting is active. * **TXT Reader**: Modified TxtReaderActivity to implement similar progress bar rendering logic based on the current page and total page count. ## Additional Context * The progress bar is rendered with a height of 4 pixels at the very bottom of the screen (adjusted for margins). * The feature reuses the existing renderStatusBar logic but conditionally draws the bar instead of (or in addition to) other elements depending on the specific implementation details in each reader. * Renamed existing 'Full' mode to 'Full w/ Percentage' * Added new 'Full w/ Progress Bar' option --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- src/CrossPointSettings.h | 9 +++- src/ScreenComponents.cpp | 11 ++++ src/ScreenComponents.h | 3 ++ src/activities/reader/EpubReaderActivity.cpp | 54 +++++++++++++++----- src/activities/reader/TxtReaderActivity.cpp | 51 ++++++++++++++---- src/activities/settings/SettingsActivity.cpp | 3 +- 6 files changed, 105 insertions(+), 26 deletions(-) diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index e2883425..6385f4f1 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -19,7 +19,14 @@ class CrossPointSettings { enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1, SLEEP_SCREEN_COVER_MODE_COUNT }; // Status bar display type enum - enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2, STATUS_BAR_MODE_COUNT }; + enum STATUS_BAR_MODE { + NONE = 0, + NO_PROGRESS = 1, + FULL = 2, + FULL_WITH_PROGRESS_BAR = 3, + ONLY_PROGRESS_BAR = 4, + STATUS_BAR_MODE_COUNT + }; enum ORIENTATION { PORTRAIT = 0, // 480x800 logical coordinates (current default) diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp index 2e8d9e7c..ef47dfc5 100644 --- a/src/ScreenComponents.cpp +++ b/src/ScreenComponents.cpp @@ -42,6 +42,17 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4); } +void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) { + int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft; + renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom, + &vieweableMarginLeft); + + const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight; + const int progressBarY = renderer.getScreenHeight() - vieweableMarginBottom - BOOK_PROGRESS_BAR_HEIGHT; + const int barWidth = progressBarMaxWidth * bookProgress / 100; + renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BOOK_PROGRESS_BAR_HEIGHT, true); +} + int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector& tabs) { constexpr int tabPadding = 20; // Horizontal padding between tabs constexpr int leftMargin = 20; // Left margin for first tab diff --git a/src/ScreenComponents.h b/src/ScreenComponents.h index 48c40f42..15403f60 100644 --- a/src/ScreenComponents.h +++ b/src/ScreenComponents.h @@ -13,7 +13,10 @@ struct TabInfo { class ScreenComponents { public: + static const int BOOK_PROGRESS_BAR_HEIGHT = 4; + static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); + static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress); // Draw a horizontal tab bar with underline indicator for selected tab // Returns the height of the tab bar (for positioning content below) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index a6d27d34..bd9c1b1d 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -18,6 +18,8 @@ namespace { constexpr unsigned long skipChapterMs = 700; constexpr unsigned long goHomeMs = 1000; constexpr int statusBarMargin = 19; +constexpr int progressBarMarginTop = 1; + } // namespace void EpubReaderActivity::taskTrampoline(void* param) { @@ -275,7 +277,16 @@ void EpubReaderActivity::renderScreen() { orientedMarginTop += SETTINGS.screenMargin; orientedMarginLeft += SETTINGS.screenMargin; orientedMarginRight += SETTINGS.screenMargin; - orientedMarginBottom += statusBarMargin; + orientedMarginBottom += SETTINGS.screenMargin; + + // Add status bar margin + if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { + // Add additional margin for status bar if progress bar is shown + const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; + orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin + + (showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); + } if (!section) { const auto filepath = epub->getSpineItem(currentSpineIndex).href; @@ -446,11 +457,17 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) const { // determine visible status bar elements - const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; + const bool showProgressText = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || - SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || - SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; @@ -459,19 +476,30 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in const auto textY = screenHeight - orientedMarginBottom - 4; int progressTextWidth = 0; - if (showProgress) { - // Calculate progress in book - const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; - const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100; + // Calculate progress in book + const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; + const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100; + if (showProgressText || showProgressPercentage) { // Right aligned text for progress counter char progressStr[32]; - snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", section->currentPage + 1, section->pageCount, - bookProgress); - const std::string progress = progressStr; - progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); + + // Hide percentage when progress bar is shown to reduce clutter + if (showProgressPercentage) { + snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", section->currentPage + 1, section->pageCount, + bookProgress); + } else { + snprintf(progressStr, sizeof(progressStr), "%d/%d", section->currentPage + 1, section->pageCount); + } + + progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, - progress.c_str()); + progressStr); + } + + if (showProgressBar) { + // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area + ScreenComponents::drawBookProgressBar(renderer, static_cast(bookProgress)); } if (showBattery) { diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index b7de16d8..cc2036b9 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -15,6 +15,7 @@ namespace { constexpr unsigned long goHomeMs = 1000; constexpr int statusBarMargin = 25; +constexpr int progressBarMarginTop = 1; constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading // Cache file magic and version @@ -158,7 +159,16 @@ void TxtReaderActivity::initializeReader() { orientedMarginTop += cachedScreenMargin; orientedMarginLeft += cachedScreenMargin; orientedMarginRight += cachedScreenMargin; - orientedMarginBottom += statusBarMargin; + orientedMarginBottom += cachedScreenMargin; + + // Add status bar margin + if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { + // Add additional margin for status bar if progress bar is shown + const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; + orientedMarginBottom += statusBarMargin - cachedScreenMargin + + (showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); + } viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const int viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; @@ -499,27 +509,46 @@ void TxtReaderActivity::renderPage() { void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) const { - const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; + const bool showProgressText = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || - SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; const bool showTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || - SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; + const bool showBatteryPercentage = + SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; const auto screenHeight = renderer.getScreenHeight(); const auto textY = screenHeight - orientedMarginBottom - 4; int progressTextWidth = 0; - if (showProgress) { - const int progress = totalPages > 0 ? (currentPage + 1) * 100 / totalPages : 0; - const std::string progressStr = - std::to_string(currentPage + 1) + "/" + std::to_string(totalPages) + " " + std::to_string(progress) + "%"; - progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr.c_str()); + const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0; + + if (showProgressText || showProgressPercentage) { + char progressStr[32]; + if (showProgressPercentage) { + snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", currentPage + 1, totalPages, progress); + } else { + snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage + 1, totalPages); + } + + progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, - progressStr.c_str()); + progressStr); + } + + if (showProgressBar) { + // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area + ScreenComponents::drawBookProgressBar(renderer, static_cast(progress)); } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY); + ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage); } if (showTitle) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 819115a5..a211e033 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -16,7 +16,8 @@ const SettingInfo displaySettings[displaySettingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), - SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), + SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, + {"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}), SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})}; From 49190cca6d405d7f4e8fd2fdb20363fdd92d8441 Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Tue, 27 Jan 2026 17:53:13 +0500 Subject: [PATCH 22/22] feat(ux): page turning on button pressed if long-press chapter skip is disabled (#451) ## Summary * If long-press chapter skip is disabled, turn pages on button pressed, not released * Makes page turning snappier * Refactors MappedInputManager for readability --- ### AI Usage Did you use AI tools to help write this code? _**< PARTIALLY>**_ --------- Co-authored-by: Dave Allie --- src/MappedInputManager.cpp | 120 ++++++++----------- src/MappedInputManager.h | 3 +- src/activities/reader/EpubReaderActivity.cpp | 25 ++-- src/activities/reader/TxtReaderActivity.cpp | 25 ++-- src/activities/reader/XtcReaderActivity.cpp | 25 ++-- 5 files changed, 98 insertions(+), 100 deletions(-) diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index 994dda5f..14c45deb 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -2,97 +2,73 @@ #include "CrossPointSettings.h" -decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button button) const { +namespace { +using ButtonIndex = uint8_t; + +struct FrontLayoutMap { + ButtonIndex back; + ButtonIndex confirm; + ButtonIndex left; + ButtonIndex right; +}; + +struct SideLayoutMap { + ButtonIndex pageBack; + ButtonIndex pageForward; +}; + +// Order matches CrossPointSettings::FRONT_BUTTON_LAYOUT. +constexpr FrontLayoutMap kFrontLayouts[] = { + {InputManager::BTN_BACK, InputManager::BTN_CONFIRM, InputManager::BTN_LEFT, InputManager::BTN_RIGHT}, + {InputManager::BTN_LEFT, InputManager::BTN_RIGHT, InputManager::BTN_BACK, InputManager::BTN_CONFIRM}, + {InputManager::BTN_CONFIRM, InputManager::BTN_LEFT, InputManager::BTN_BACK, InputManager::BTN_RIGHT}, + {InputManager::BTN_BACK, InputManager::BTN_CONFIRM, InputManager::BTN_RIGHT, InputManager::BTN_LEFT}, +}; + +// Order matches CrossPointSettings::SIDE_BUTTON_LAYOUT. +constexpr SideLayoutMap kSideLayouts[] = { + {InputManager::BTN_UP, InputManager::BTN_DOWN}, + {InputManager::BTN_DOWN, InputManager::BTN_UP}, +}; +} // namespace + +bool MappedInputManager::mapButton(const Button button, bool (InputManager::*fn)(uint8_t) const) const { const auto frontLayout = static_cast(SETTINGS.frontButtonLayout); const auto sideLayout = static_cast(SETTINGS.sideButtonLayout); + const auto& front = kFrontLayouts[frontLayout]; + const auto& side = kSideLayouts[sideLayout]; switch (button) { case Button::Back: - switch (frontLayout) { - case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM: - return InputManager::BTN_LEFT; - case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: - return InputManager::BTN_CONFIRM; - case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: - /* fall through */ - case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: - /* fall through */ - default: - return InputManager::BTN_BACK; - } + return (inputManager.*fn)(front.back); case Button::Confirm: - switch (frontLayout) { - case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM: - return InputManager::BTN_RIGHT; - case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: - return InputManager::BTN_LEFT; - case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: - /* fall through */ - case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: - /* fall through */ - default: - return InputManager::BTN_CONFIRM; - } + return (inputManager.*fn)(front.confirm); case Button::Left: - switch (frontLayout) { - case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM: - /* fall through */ - case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: - return InputManager::BTN_BACK; - case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: - return InputManager::BTN_RIGHT; - case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: - /* fall through */ - default: - return InputManager::BTN_LEFT; - } + return (inputManager.*fn)(front.left); case Button::Right: - switch (frontLayout) { - case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM: - return InputManager::BTN_CONFIRM; - case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: - return InputManager::BTN_LEFT; - case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: - /* fall through */ - case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: - /* fall through */ - default: - return InputManager::BTN_RIGHT; - } + return (inputManager.*fn)(front.right); case Button::Up: - return InputManager::BTN_UP; + return (inputManager.*fn)(InputManager::BTN_UP); case Button::Down: - return InputManager::BTN_DOWN; + return (inputManager.*fn)(InputManager::BTN_DOWN); case Button::Power: - return InputManager::BTN_POWER; + return (inputManager.*fn)(InputManager::BTN_POWER); case Button::PageBack: - switch (sideLayout) { - case CrossPointSettings::NEXT_PREV: - return InputManager::BTN_DOWN; - case CrossPointSettings::PREV_NEXT: - /* fall through */ - default: - return InputManager::BTN_UP; - } + return (inputManager.*fn)(side.pageBack); case Button::PageForward: - switch (sideLayout) { - case CrossPointSettings::NEXT_PREV: - return InputManager::BTN_UP; - case CrossPointSettings::PREV_NEXT: - /* fall through */ - default: - return InputManager::BTN_DOWN; - } + return (inputManager.*fn)(side.pageForward); } - return InputManager::BTN_BACK; + return false; } -bool MappedInputManager::wasPressed(const Button button) const { return inputManager.wasPressed(mapButton(button)); } +bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &InputManager::wasPressed); } -bool MappedInputManager::wasReleased(const Button button) const { return inputManager.wasReleased(mapButton(button)); } +bool MappedInputManager::wasReleased(const Button button) const { + return mapButton(button, &InputManager::wasReleased); +} -bool MappedInputManager::isPressed(const Button button) const { return inputManager.isPressed(mapButton(button)); } +bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &InputManager::isPressed); } bool MappedInputManager::wasAnyPressed() const { return inputManager.wasAnyPressed(); } diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index 62065fe9..bee7cd4b 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -25,5 +25,6 @@ class MappedInputManager { private: InputManager& inputManager; - decltype(InputManager::BTN_BACK) mapButton(Button button) const; + + bool mapButton(Button button, bool (InputManager::*fn)(uint8_t) const) const; }; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index bd9c1b1d..89be3bc7 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -170,14 +170,21 @@ void EpubReaderActivity::loop() { return; } - const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || - mappedInput.wasReleased(MappedInputManager::Button::Left); - const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || - (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && - mappedInput.wasReleased(MappedInputManager::Button::Power)) || - mappedInput.wasReleased(MappedInputManager::Button::Right); + // When long-press chapter skip is disabled, turn pages on press instead of release. + const bool usePressForPageTurn = !SETTINGS.longPressChapterSkip; + const bool prevTriggered = usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) || + mappedInput.wasPressed(MappedInputManager::Button::Left)) + : (mappedInput.wasReleased(MappedInputManager::Button::PageBack) || + mappedInput.wasReleased(MappedInputManager::Button::Left)); + const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power); + const bool nextTriggered = usePressForPageTurn + ? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) || powerPageTurn || + mappedInput.wasPressed(MappedInputManager::Button::Right)) + : (mappedInput.wasReleased(MappedInputManager::Button::PageForward) || powerPageTurn || + mappedInput.wasReleased(MappedInputManager::Button::Right)); - if (!prevReleased && !nextReleased) { + if (!prevTriggered && !nextTriggered) { return; } @@ -195,7 +202,7 @@ void EpubReaderActivity::loop() { // We don't want to delete the section mid-render, so grab the semaphore xSemaphoreTake(renderingMutex, portMAX_DELAY); nextPageNumber = 0; - currentSpineIndex = nextReleased ? currentSpineIndex + 1 : currentSpineIndex - 1; + currentSpineIndex = nextTriggered ? currentSpineIndex + 1 : currentSpineIndex - 1; section.reset(); xSemaphoreGive(renderingMutex); updateRequired = true; @@ -208,7 +215,7 @@ void EpubReaderActivity::loop() { return; } - if (prevReleased) { + if (prevTriggered) { if (section->currentPage > 0) { section->currentPage--; } else { diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index cc2036b9..7df083a6 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -110,21 +110,28 @@ void TxtReaderActivity::loop() { return; } - const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || - mappedInput.wasReleased(MappedInputManager::Button::Left); - const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || - (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && - mappedInput.wasReleased(MappedInputManager::Button::Power)) || - mappedInput.wasReleased(MappedInputManager::Button::Right); + // When long-press chapter skip is disabled, turn pages on press instead of release. + const bool usePressForPageTurn = !SETTINGS.longPressChapterSkip; + const bool prevTriggered = usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) || + mappedInput.wasPressed(MappedInputManager::Button::Left)) + : (mappedInput.wasReleased(MappedInputManager::Button::PageBack) || + mappedInput.wasReleased(MappedInputManager::Button::Left)); + const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power); + const bool nextTriggered = usePressForPageTurn + ? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) || powerPageTurn || + mappedInput.wasPressed(MappedInputManager::Button::Right)) + : (mappedInput.wasReleased(MappedInputManager::Button::PageForward) || powerPageTurn || + mappedInput.wasReleased(MappedInputManager::Button::Right)); - if (!prevReleased && !nextReleased) { + if (!prevTriggered && !nextTriggered) { return; } - if (prevReleased && currentPage > 0) { + if (prevTriggered && currentPage > 0) { currentPage--; updateRequired = true; - } else if (nextReleased && currentPage < totalPages - 1) { + } else if (nextTriggered && currentPage < totalPages - 1) { currentPage++; updateRequired = true; } diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 0a58d7b3..9761e27d 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -111,14 +111,21 @@ void XtcReaderActivity::loop() { return; } - const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || - mappedInput.wasReleased(MappedInputManager::Button::Left); - const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || - (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && - mappedInput.wasReleased(MappedInputManager::Button::Power)) || - mappedInput.wasReleased(MappedInputManager::Button::Right); + // When long-press chapter skip is disabled, turn pages on press instead of release. + const bool usePressForPageTurn = !SETTINGS.longPressChapterSkip; + const bool prevTriggered = usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) || + mappedInput.wasPressed(MappedInputManager::Button::Left)) + : (mappedInput.wasReleased(MappedInputManager::Button::PageBack) || + mappedInput.wasReleased(MappedInputManager::Button::Left)); + const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power); + const bool nextTriggered = usePressForPageTurn + ? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) || powerPageTurn || + mappedInput.wasPressed(MappedInputManager::Button::Right)) + : (mappedInput.wasReleased(MappedInputManager::Button::PageForward) || powerPageTurn || + mappedInput.wasReleased(MappedInputManager::Button::Right)); - if (!prevReleased && !nextReleased) { + if (!prevTriggered && !nextTriggered) { return; } @@ -132,14 +139,14 @@ void XtcReaderActivity::loop() { const bool skipPages = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipPageMs; const int skipAmount = skipPages ? 10 : 1; - if (prevReleased) { + if (prevTriggered) { if (currentPage >= static_cast(skipAmount)) { currentPage -= skipAmount; } else { currentPage = 0; } updateRequired = true; - } else if (nextReleased) { + } else if (nextTriggered) { currentPage += skipAmount; if (currentPage >= xtc->getPageCount()) { currentPage = xtc->getPageCount(); // Allow showing "End of book"