From a922e553eda7384847e9a0c1abd9fc0787290e9b Mon Sep 17 00:00:00 2001 From: Pavel Liashkov Date: Fri, 2 Jan 2026 14:44:17 +0800 Subject: [PATCH 01/28] Prevent device sleep during WiFi file transfer and OTA updates (#203) ## Summary * **What is the goal of this PR?** Fixes #199 - Device falls asleep during WiFi file transfer after 10 minutes of inactivity, disconnecting the web server. * **What changes are included?** - Add `preventAutoSleep()` virtual method to `Activity` base class - Modify main loop to reset inactivity timer when `preventAutoSleep()` returns true - Override `preventAutoSleep()` in `CrossPointWebServerActivity` (returns true when web server running) - Override `preventAutoSleep()` in `OtaUpdateActivity` (returns true during update check/download) ## Additional Context * The existing `skipLoopDelay()` method controls loop timing (yield vs delay) for HTTP responsiveness. The new `preventAutoSleep()` method is semantically separate - it explicitly signals that an activity should keep the device awake. * `CrossPointWebServerActivity` uses both methods: `skipLoopDelay()` for responsive HTTP handling, `preventAutoSleep()` for staying awake. * `OtaUpdateActivity` only needs `preventAutoSleep()` since the OTA library handles HTTP internally. --- src/activities/Activity.h | 1 + src/activities/network/CrossPointWebServerActivity.h | 1 + src/activities/settings/OtaUpdateActivity.h | 1 + src/main.cpp | 5 +++-- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/activities/Activity.h b/src/activities/Activity.h index aad55915..4a60607b 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -22,4 +22,5 @@ class Activity { virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); } virtual void loop() {} virtual bool skipLoopDelay() { return false; } + virtual bool preventAutoSleep() { return false; } }; diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index deb7cea7..775a2474 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -70,4 +70,5 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity { 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/settings/OtaUpdateActivity.h b/src/activities/settings/OtaUpdateActivity.h index 9e35567c..e049b077 100644 --- a/src/activities/settings/OtaUpdateActivity.h +++ b/src/activities/settings/OtaUpdateActivity.h @@ -41,4 +41,5 @@ class OtaUpdateActivity : public ActivityWithSubactivity { void onEnter() override; void onExit() override; void loop() override; + bool preventAutoSleep() override { return state == CHECKING_FOR_UPDATE || state == UPDATE_IN_PROGRESS; } }; diff --git a/src/main.cpp b/src/main.cpp index 6859f41c..b9e33426 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -316,9 +316,10 @@ void loop() { lastMemPrint = millis(); } - // Check for any user activity (button press or release) + // Check for any user activity (button press or release) or active background work static unsigned long lastActivityTime = millis(); - if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased()) { + if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() || + (currentActivity && currentActivity->preventAutoSleep())) { lastActivityTime = millis(); // Reset inactivity timer } From 9e59a5106bc1437e54d81401ae6c6271939198c9 Mon Sep 17 00:00:00 2001 From: Brendan O'Leary Date: Fri, 2 Jan 2026 01:49:16 -0500 Subject: [PATCH 02/28] Fix race condition with keyboard and Wifi entry (#204) ## Summary * **What is the goal of this PR?** Fixes a bug - https://github.com/daveallie/crosspoint-reader/issues/187 - where the screen would freeze after entering a WiFi password, causing the device to appear hung. * **What changes are included?** - Fixed a race condition in `WifiSelectionActivity::displayTaskLoop()` that caused rendering of an empty screen when transitioning from the keyboard subactivity - Added `vTaskDelay()` when a subactivity is active to prevent CPU starvation from a tight `continue` loop - Added a check to skip rendering when in `PASSWORD_ENTRY` state, allowing the state machine to properly transition to `CONNECTING` before the display updates ## Additional Context * **Root cause:** When the keyboard subactivity exited after password entry, the display task would wake up and attempt to render. However, the `state` was still `PASSWORD_ENTRY` (before `attemptConnection()` changed it to `CONNECTING`), and since there was no render case for `PASSWORD_ENTRY`, the display would show a cleared/empty buffer, appearing frozen. * **Performance implications:** The added `vTaskDelay(10)` calls when subactivity is active or in `PASSWORD_ENTRY` state actually improve performance by preventing CPU starvation - previously the display task would spin in a tight loop with `continue` while a subactivity was present. * **Testing focus:** Test the full WiFi connection flow: 1. Enter network selection 2. Select a network requiring a password 3. Enter password and press OK 4. Verify "Connecting..." screen appears 5. Verify connection completes and prompts to save password --- src/activities/network/WifiSelectionActivity.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index cbcec2d1..a8653f43 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -447,7 +447,16 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi void WifiSelectionActivity::displayTaskLoop() { while (true) { + // If a subactivity is active, yield CPU time but don't render if (subActivity) { + vTaskDelay(10 / portTICK_PERIOD_MS); + continue; + } + + // Don't render if we're in PASSWORD_ENTRY state - we're just transitioning + // from the keyboard subactivity back to the main activity + if (state == WifiSelectionState::PASSWORD_ENTRY) { + vTaskDelay(10 / portTICK_PERIOD_MS); continue; } From 39080c0e510f824cab8c055f3a20410830d9ce67 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Fri, 2 Jan 2026 07:54:46 +0100 Subject: [PATCH 03/28] Skip soft hyphens. (#195) For now, let's skip the soft hyphens (later, we can treat them in the layouter). See https://github.com/daveallie/crosspoint-reader/discussions/17#discussioncomment-15378475 --- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 5cd53293..a2ff485c 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -137,6 +137,21 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char continue; } + // Skip soft-hyphen with UTF-8 representation (U+00AD) = 0xC2 0xAD + const XML_Char SHY_BYTE_1 = static_cast(0xC2); + const XML_Char SHY_BYTE_2 = static_cast(0xAD); + // 1. Check for the start of the 2-byte Soft Hyphen sequence + if (s[i] == SHY_BYTE_1) { + // 2. Check if the next byte exists AND if it completes the sequence + // We must check i + 1 < len to prevent reading past the end of the buffer. + if ((i + 1 < len) && (s[i + 1] == SHY_BYTE_2)) { + // Sequence 0xC2 0xAD found! + // Skip the current byte (0xC2) and the next byte (0xAD) + i++; // Increment 'i' one more time to skip the 0xAD byte + continue; // Skip the rest of the loop and move to the next iteration + } + } + // 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'; From 00e83af4e8bce0f14f9abd79f8c1a1a0333356c7 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Fri, 2 Jan 2026 07:55:21 +0100 Subject: [PATCH 04/28] Show "Entering Sleep" on black, so it's quicker to notice (in book). (#181) Black popup is easier to notice at higher contrast. --- src/activities/boot_sleep/SleepActivity.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 401d74c0..cc9fa9d9 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -5,8 +5,6 @@ #include #include -#include - #include "CrossPointSettings.h" #include "CrossPointState.h" #include "fontIds.h" @@ -42,16 +40,16 @@ void SleepActivity::onEnter() { } void SleepActivity::renderPopup(const char* message) const { - const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message); + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD); constexpr int margin = 20; const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2; constexpr int y = 117; const int w = textWidth + margin * 2; const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2; // renderer.clearScreen(); + renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true); renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false); - renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message); - renderer.drawRect(x + 5, y + 5, w - 10, h - 10); + renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD); renderer.displayBuffer(); } From 5e9626eb2ad7a545fb0ec2f99e65a8276d15a9f9 Mon Sep 17 00:00:00 2001 From: Maeve Andrews <37351465+maeveynot@users.noreply.github.com> Date: Fri, 2 Jan 2026 01:21:48 -0600 Subject: [PATCH 05/28] Add paragraph alignment setting (justify/left/center/right) (#191) ## Summary * **What is the goal of this PR?** Add a new user setting for paragraph alignment, instead of hard-coding full justification. * **What changes are included?** One new line in the settings screen, with 4 options (justify/left/center/right). Default is justified since that's what it was already. I personally only wanted to disable justification and use "left", but I included the other options for completeness since they were already supported. ## Additional Context Tested on my X4 and looks as expected for each alignment. Co-authored-by: Maeve Andrews --- lib/Epub/Epub/Section.cpp | 33 +++++++++++-------- lib/Epub/Epub/Section.h | 13 ++++---- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 4 +-- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 5 ++- src/CrossPointSettings.cpp | 5 ++- src/CrossPointSettings.h | 2 ++ src/activities/reader/EpubReaderActivity.cpp | 7 ++-- src/activities/settings/SettingsActivity.cpp | 6 +++- 8 files changed, 48 insertions(+), 27 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 1f99f018..18b81aae 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -7,9 +7,9 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 8; -constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint16_t) + - sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint32_t); +constexpr uint8_t SECTION_FILE_VERSION = 9; +constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint32_t); } // namespace uint32_t Section::onPageComplete(std::unique_ptr page) { @@ -30,19 +30,21 @@ uint32_t Section::onPageComplete(std::unique_ptr page) { } void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing, - const uint16_t viewportWidth, const uint16_t viewportHeight) { + const uint8_t paragraphAlignment, const uint16_t viewportWidth, + const uint16_t viewportHeight) { if (!file) { Serial.printf("[%lu] [SCT] File not open for writing header\n", millis()); return; } static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) + - sizeof(extraParagraphSpacing) + sizeof(viewportWidth) + sizeof(viewportHeight) + - sizeof(pageCount) + sizeof(uint32_t), + sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) + + sizeof(viewportHeight) + sizeof(pageCount) + sizeof(uint32_t), "Header size mismatch"); serialization::writePod(file, SECTION_FILE_VERSION); serialization::writePod(file, fontId); serialization::writePod(file, lineCompression); serialization::writePod(file, extraParagraphSpacing); + serialization::writePod(file, paragraphAlignment); serialization::writePod(file, viewportWidth); serialization::writePod(file, viewportHeight); serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written) @@ -50,7 +52,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi } bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, - const uint16_t viewportWidth, const uint16_t viewportHeight) { + const uint8_t paragraphAlignment, const uint16_t viewportWidth, + const uint16_t viewportHeight) { if (!SdMan.openFileForRead("SCT", filePath, file)) { return false; } @@ -70,15 +73,17 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con uint16_t fileViewportWidth, fileViewportHeight; float fileLineCompression; bool fileExtraParagraphSpacing; + uint8_t fileParagraphAlignment; serialization::readPod(file, fileFontId); serialization::readPod(file, fileLineCompression); serialization::readPod(file, fileExtraParagraphSpacing); + serialization::readPod(file, fileParagraphAlignment); serialization::readPod(file, fileViewportWidth); serialization::readPod(file, fileViewportHeight); if (fontId != fileFontId || lineCompression != fileLineCompression || - extraParagraphSpacing != fileExtraParagraphSpacing || viewportWidth != fileViewportWidth || - viewportHeight != fileViewportHeight) { + extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment || + viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight) { file.close(); Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis()); clearCache(); @@ -109,8 +114,8 @@ bool Section::clearCache() const { } bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, - const uint16_t viewportWidth, const uint16_t viewportHeight, - const std::function& progressSetupFn, + const uint8_t paragraphAlignment, const uint16_t viewportWidth, + const uint16_t viewportHeight, const std::function& progressSetupFn, const std::function& progressFn) { constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB const auto localPath = epub->getSpineItem(spineIndex).href; @@ -166,11 +171,13 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c if (!SdMan.openFileForWrite("SCT", filePath, file)) { return false; } - writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight); + writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, + viewportHeight); std::vector lut = {}; ChapterHtmlSlimParser visitor( - tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight, + tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, + viewportHeight, [this, &lut](std::unique_ptr page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, progressFn); success = visitor.parseAndBuildPages(); diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 55244d0e..bac95efd 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -14,8 +14,8 @@ class Section { std::string filePath; FsFile file; - void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint16_t viewportWidth, - uint16_t viewportHeight); + void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, + uint16_t viewportWidth, uint16_t viewportHeight); uint32_t onPageComplete(std::unique_ptr page); public: @@ -28,11 +28,12 @@ class Section { renderer(renderer), filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {} ~Section() = default; - bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint16_t viewportWidth, - uint16_t viewportHeight); + bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, + uint16_t viewportWidth, uint16_t viewportHeight); bool clearCache() const; - bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint16_t viewportWidth, - uint16_t viewportHeight, const std::function& progressSetupFn = nullptr, + bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, + uint16_t viewportWidth, uint16_t viewportHeight, + const std::function& progressSetupFn = nullptr, const std::function& progressFn = nullptr); std::unique_ptr loadPageFromSectionFile(); }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index a2ff485c..e5eb4d10 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -97,7 +97,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (strcmp(name, "br") == 0) { self->startNewTextBlock(self->currentTextBlock->getStyle()); } else { - self->startNewTextBlock(TextBlock::JUSTIFIED); + self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment); } } else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); @@ -221,7 +221,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n } bool ChapterHtmlSlimParser::parseAndBuildPages() { - startNewTextBlock(TextBlock::JUSTIFIED); + startNewTextBlock((TextBlock::Style)this->paragraphAlignment); const XML_Parser parser = XML_ParserCreate(nullptr); int done; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 795c2c33..c559e157 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -33,6 +33,7 @@ class ChapterHtmlSlimParser { int fontId; float lineCompression; bool extraParagraphSpacing; + uint8_t paragraphAlignment; uint16_t viewportWidth; uint16_t viewportHeight; @@ -46,7 +47,8 @@ class ChapterHtmlSlimParser { public: explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId, const float lineCompression, const bool extraParagraphSpacing, - const uint16_t viewportWidth, const uint16_t viewportHeight, + const uint8_t paragraphAlignment, const uint16_t viewportWidth, + const uint16_t viewportHeight, const std::function)>& completePageFn, const std::function& progressFn = nullptr) : filepath(filepath), @@ -54,6 +56,7 @@ class ChapterHtmlSlimParser { fontId(fontId), lineCompression(lineCompression), extraParagraphSpacing(extraParagraphSpacing), + paragraphAlignment(paragraphAlignment), viewportWidth(viewportWidth), viewportHeight(viewportHeight), completePageFn(completePageFn), diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 74a95959..74bc0d26 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -12,7 +12,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 = 10; +constexpr uint8_t SETTINGS_COUNT = 11; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -37,6 +37,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, fontFamily); serialization::writePod(outputFile, fontSize); serialization::writePod(outputFile, lineSpacing); + serialization::writePod(outputFile, paragraphAlignment); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -83,6 +84,8 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, lineSpacing); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, paragraphAlignment); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 108aecdf..b9cce85e 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -43,6 +43,7 @@ class CrossPointSettings { // 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 }; // Sleep screen settings uint8_t sleepScreen = DARK; @@ -62,6 +63,7 @@ class CrossPointSettings { uint8_t fontFamily = BOOKERLY; uint8_t fontSize = MEDIUM; uint8_t lineSpacing = NORMAL; + uint8_t paragraphAlignment = JUSTIFIED; ~CrossPointSettings() = default; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index fae5d241..4348625d 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -267,7 +267,8 @@ void EpubReaderActivity::renderScreen() { const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), - SETTINGS.extraParagraphSpacing, viewportWidth, viewportHeight)) { + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, + viewportHeight)) { Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); // Progress bar dimensions @@ -311,8 +312,8 @@ void EpubReaderActivity::renderScreen() { }; if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), - SETTINGS.extraParagraphSpacing, viewportWidth, viewportHeight, progressSetup, - progressCallback)) { + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, + viewportHeight, progressSetup, progressCallback)) { Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); section.reset(); return; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index fa0cc084..d9d19411 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -9,7 +9,7 @@ // Define the static settings list namespace { -constexpr int settingsCount = 11; +constexpr int settingsCount = 12; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, @@ -34,6 +34,10 @@ const SettingInfo settingsList[settingsCount] = { {"Bookerly", "Noto Sans", "Open Dyslexic"}}, {"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}}, {"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}}, + {"Reader Paragraph Alignment", + SettingType::ENUM, + &CrossPointSettings::paragraphAlignment, + {"Justify", "Left", "Center", "Right"}}, {"Check for updates", SettingType::ACTION, nullptr, {}}, }; } // namespace From 062d69dc2a58c91b7c147830df5730c5bd064769 Mon Sep 17 00:00:00 2001 From: Jake Lyell Date: Fri, 2 Jan 2026 18:32:26 +1100 Subject: [PATCH 06/28] Add support for uploading multiple epubs (#202) Upload multiple files at once in sequence. Add retry button for files that fail ## Summary * **What is the goal of this PR?** Add support for selecting multiple epub files in one go, before uploading them all to the device * **What changes are included?** Allow multiple selections to be submitted to the input field. Sends each file to the device one by one in a loop Adds retry logic and UI for easy re-trying of failed uploads Addresses #201 button now says "Choose Files", and shows the number of files you selected image Shows which file is uploading: image Failed upload dialog: image image --- src/network/html/FilesPage.html | 272 ++++++++++++++++++++++++++++---- 1 file changed, 242 insertions(+), 30 deletions(-) diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index f58200f3..08c0a0be 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -341,6 +341,90 @@ width: 60px; text-align: center; } + /* Failed uploads banner */ + .failed-uploads-banner { + background-color: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + padding: 15px; + margin-bottom: 15px; + display: none; + } + .failed-uploads-banner.show { + display: block; + } + .failed-uploads-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + } + .failed-uploads-title { + font-weight: 600; + color: #856404; + margin: 0; + } + .dismiss-btn { + background: none; + border: none; + font-size: 1.2em; + cursor: pointer; + color: #856404; + padding: 0; + line-height: 1; + } + .dismiss-btn:hover { + color: #533f03; + } + .failed-file-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #ffe69c; + } + .failed-file-item:last-child { + border-bottom: none; + } + .failed-file-info { + flex: 1; + } + .failed-file-name { + font-weight: 500; + color: #856404; + } + .failed-file-error { + font-size: 0.85em; + color: #856404; + opacity: 0.8; + } + .retry-btn { + background-color: #ffc107; + color: #533f03; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; + font-weight: 600; + } + .retry-btn:hover { + background-color: #e0a800; + } + .retry-all-btn { + background-color: #ffc107; + color: #533f03; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 0.95em; + font-weight: 600; + margin-top: 10px; + } + .retry-all-btn:hover { + background-color: #e0a800; + } /* Delete modal */ .delete-warning { color: #e74c3c; @@ -505,6 +589,16 @@ + +
+
+

⚠️ Some files failed to upload

+ +
+
+ +
+

Contents

@@ -531,7 +625,7 @@

📤 Upload file

Select a file to upload to

- +
@@ -717,65 +811,183 @@ function validateFile() { const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); - const file = fileInput.files[0]; - uploadBtn.disabled = !file; + const files = fileInput.files; + uploadBtn.disabled = !(files.length > 0); } - function uploadFile() { - const fileInput = document.getElementById('fileInput'); - const file = fileInput.files[0]; +let failedUploadsGlobal = []; - if (!file) { - alert('Please select a file first!'); +function uploadFile() { + const fileInput = document.getElementById('fileInput'); + const files = Array.from(fileInput.files); + + if (files.length === 0) { + alert('Please select at least one file!'); + return; + } + + const progressContainer = document.getElementById('progress-container'); + const progressFill = document.getElementById('progress-fill'); + const progressText = document.getElementById('progress-text'); + const uploadBtn = document.getElementById('uploadBtn'); + + progressContainer.style.display = 'block'; + uploadBtn.disabled = true; + + let currentIndex = 0; + const failedFiles = []; + + function uploadNextFile() { + if (currentIndex >= files.length) { + // All files processed - show summary + if (failedFiles.length === 0) { + progressFill.style.backgroundColor = '#4caf50'; + progressText.textContent = 'All uploads complete!'; + setTimeout(() => { + closeUploadModal(); + hydrate(); // Refresh file list instead of reloading + }, 1000); + } else { + progressFill.style.backgroundColor = '#e74c3c'; + const failedList = failedFiles.map(f => f.name).join(', '); + progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`; + + // Store failed files globally and show banner + failedUploadsGlobal = failedFiles; + + setTimeout(() => { + closeUploadModal(); + showFailedUploadsBanner(); + hydrate(); // Refresh file list to show successfully uploaded files + }, 2000); + } return; } + const file = files[currentIndex]; const formData = new FormData(); formData.append('file', file); - const progressContainer = document.getElementById('progress-container'); - const progressFill = document.getElementById('progress-fill'); - const progressText = document.getElementById('progress-text'); - const uploadBtn = document.getElementById('uploadBtn'); - - progressContainer.style.display = 'block'; - uploadBtn.disabled = true; - const xhr = new XMLHttpRequest(); // Include path as query parameter since multipart form data doesn't make // form fields available until after file upload completes xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); - xhr.upload.onprogress = function(e) { + progressFill.style.width = '0%'; + progressFill.style.backgroundColor = '#4caf50'; + progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`; + + xhr.upload.onprogress = function (e) { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); progressFill.style.width = percent + '%'; - progressText.textContent = 'Uploading: ' + percent + '%'; + progressText.textContent = + `Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`; } }; - xhr.onload = function() { + xhr.onload = function () { if (xhr.status === 200) { - progressText.textContent = 'Upload complete!'; - setTimeout(function() { - window.location.reload(); - }, 1000); + currentIndex++; + uploadNextFile(); // upload next file } else { - progressText.textContent = 'Upload failed: ' + xhr.responseText; - progressFill.style.backgroundColor = '#e74c3c'; - uploadBtn.disabled = false; + // Track failure and continue with next file + failedFiles.push({ name: file.name, error: xhr.responseText, file: file }); + currentIndex++; + uploadNextFile(); } }; - xhr.onerror = function() { - progressText.textContent = 'Upload failed - network error'; - progressFill.style.backgroundColor = '#e74c3c'; - uploadBtn.disabled = false; + xhr.onerror = function () { + // Track network error and continue with next file + failedFiles.push({ name: file.name, error: 'network error', file: file }); + currentIndex++; + uploadNextFile(); }; xhr.send(formData); } + uploadNextFile(); +} + +function showFailedUploadsBanner() { + const banner = document.getElementById('failedUploadsBanner'); + const filesList = document.getElementById('failedFilesList'); + + filesList.innerHTML = ''; + + failedUploadsGlobal.forEach((failedFile, index) => { + const item = document.createElement('div'); + item.className = 'failed-file-item'; + item.innerHTML = ` +
+
📄 ${escapeHtml(failedFile.name)}
+
Error: ${escapeHtml(failedFile.error)}
+
+ + `; + filesList.appendChild(item); + }); + + // Ensure retry all button is visible + const retryAllBtn = banner.querySelector('.retry-all-btn'); + if (retryAllBtn) retryAllBtn.style.display = ''; + + banner.classList.add('show'); +} + +function dismissFailedUploads() { + const banner = document.getElementById('failedUploadsBanner'); + banner.classList.remove('show'); + failedUploadsGlobal = []; +} + +function retrySingleUpload(index) { + const failedFile = failedUploadsGlobal[index]; + if (!failedFile) return; + + // Create a DataTransfer to set the file input + const dt = new DataTransfer(); + dt.items.add(failedFile.file); + + const fileInput = document.getElementById('fileInput'); + fileInput.files = dt.files; + + // Remove this file from failed list + failedUploadsGlobal.splice(index, 1); + + // If no more failed files, hide banner + if (failedUploadsGlobal.length === 0) { + dismissFailedUploads(); + } + + // Open modal and trigger upload + openUploadModal(); + validateFile(); +} + +function retryAllFailedUploads() { + if (failedUploadsGlobal.length === 0) return; + + // Create a DataTransfer with all failed files + const dt = new DataTransfer(); + failedUploadsGlobal.forEach(failedFile => { + dt.items.add(failedFile.file); + }); + + const fileInput = document.getElementById('fileInput'); + fileInput.files = dt.files; + + // Clear failed files list + failedUploadsGlobal = []; + dismissFailedUploads(); + + // Open modal and trigger upload + openUploadModal(); + validateFile(); +} + function createFolder() { const folderName = document.getElementById('folderName').value.trim(); From 5790d6f5dcc3de9a15f72947bc1db848f154ba55 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Sat, 3 Jan 2026 08:54:23 +0100 Subject: [PATCH 07/28] Subtract time it took reaching the evaluation from the press duration. (#208) Adresses #206 --- src/main.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index b9e33426..5273d035 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -150,8 +150,10 @@ void verifyWakeupLongPress() { // 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; - // It takes us some time to wake up from deep sleep, so we need to subtract that from the duration - constexpr uint16_t calibration = 29; + // Subtract the current time, because inputManager only starts counting the HeldTime from the first update() + // This way, we remove the time we already took to reach here from the duration, + // assuming the button was held until now from millis()==0 (i.e. device start time). + const uint16_t calibration = start; const uint16_t calibratedPressDuration = (calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1; From 0332e1103a117bbe341d0d1f1f7bae7edf417722 Mon Sep 17 00:00:00 2001 From: Pavel Liashkov Date: Sat, 3 Jan 2026 16:10:35 +0800 Subject: [PATCH 08/28] Add EPUB 3 nav.xhtml TOC support (#197) ## Summary * **What is the goal of this PR?** Add EPUB 3 support by implementing native navigation document (nav.xhtml) parsing with NCX fallback, addressing issue Fixes: #143. * **What changes are included?** - New `TocNavParser` for parsing EPUB 3 HTML5 navigation documents (`