diff --git a/.github/workflows/pr-formatting-check.yml b/.github/workflows/pr-formatting-check.yml new file mode 100644 index 00000000..044b7b64 --- /dev/null +++ b/.github/workflows/pr-formatting-check.yml @@ -0,0 +1,26 @@ +name: "PR Formatting" + +on: + pull_request_target: + types: + - opened + - reopened + - edited + +permissions: + statuses: write + +jobs: + title-check: + name: Title Check + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Check PR Title + uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 70a765ba..b411140e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -20,9 +20,10 @@ Button layout can be customized in **[Settings](#35-settings)**. ### Power On / Off -To turn the device on or off, **press and hold the Power button for half a second**. In **[Settings](#35-settings)** you can configure the power button to trigger on a short press instead of a long one. +To turn the device on or off, **press and hold the Power button for approximately half a second**. +In **[Settings](#35-settings)** you can configure the power button to turn the device off with a short press instead of a long one. -To reboot the device (for example if it's frozen, or after a firmware update), press and release the Reset button, and then hold the Power button for a few seconds. +To reboot the device (for example if it's frozen, or after a firmware update), press and release the Reset button, and then quickly press and hold the Power button for a few seconds. ### First Launch @@ -63,18 +64,29 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: - **Sleep Screen**: Which sleep screen to display when the device sleeps: - - "Dark" (default) - The default dark sleep screen + - "Dark" (default) - The default dark Crosspoint logo sleep screen - "Light" - The same default sleep screen, on a white background - - "Custom" - Custom images from the SD card, see [Sleep Screen](#36-sleep-screen) below for more information + - "Custom" - Custom images from the SD card; see [Sleep Screen](#36-sleep-screen) below for more information - "Cover" - The book cover image (Note: this is experimental and may not work as expected) - - "Blank" - A blank screen + - "None" - A blank screen +- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected: + - "Fit" (default) - Scale the image down to fit centered on the screen, padding with white borders as necessary + - "Crop" - Scale the image down and crop as necessary to try to to fill the screen (Note: this is experimental and may not work as expected) - **Status Bar**: Configure the status bar displayed while reading: - "None" - No status bar - "No Progress" - Show status bar without reading progress - "Full" - Show status bar with reading progress +- **Hide Battery %**: Configure where to suppress the battery pecentage display in the status bar; the battery icon will still be shown: + - "Never" - Always show battery percentage (default) + - "In Reader" - Show battery percentage everywhere except in reading mode + - "Always" - Always hide battery percentage - **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book. If disabled, paragraphs will not have vertical space between them, but will have first-line indentation. -- **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. -- **Reading Orientation**: Set the screen orientation for reading: +- **Text Anti-Aliasing**: Whether to show smooth grey edges (anti-aliasing) on text in reading mode. Note this slows down page turns slightly. +- **Short Power Button Click**: Controls the effect of a short click of the power button: + - "Ignore" - Require a long press to turn off the device + - "Sleep" - A short press powers the device off + - "Page Turn" - A short press in reading mode turns to the next page; a long press turns the device off +- **Reading Orientation**: Set the screen orientation for reading EPUB files: - "Portrait" (default) - Standard portrait orientation - "Landscape CW" - Landscape, rotated clockwise - "Inverted" - Portrait, upside down @@ -83,16 +95,18 @@ 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 -- **Side Button Layout**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. +- **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. - **Reader Font Family**: Choose the font used for reading: - "Bookerly" (default) - Amazon's reading font - "Noto Sans" - Google's sans-serif font - "Open Dyslexic" - Font designed for readers with dyslexia - **Reader Font Size**: Adjust the text size for reading; options are "Small", "Medium", "Large", or "X Large". - **Reader Line Spacing**: Adjust the spacing between lines; options are "Tight", "Normal", or "Wide". +- **Reader Screen Margin**: Controls the screen margins in reader mode between 5 and 40 pixels in 5 pixel increments. - **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. - **Check for updates**: Check for firmware updates over WiFi. ### 3.6 Sleep Screen @@ -124,6 +138,8 @@ Once you have opened a book, the button layout changes to facilitate reading. The role of the volume (side) buttons can be swapped in **[Settings](#35-settings)**. +If the **Short Power Button Click** setting is set to "Page Turn", you can also turn to the next page by briefly pressing the Power button. + ### Chapter Navigation * **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release. * **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release. diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 64727bca..1b337721 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -409,6 +409,70 @@ bool Epub::generateCoverBmp(bool cropped) const { return false; } +std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } + +bool Epub::generateThumbBmp() const { + // Already generated, return true + if (SdMan.exists(getThumbBmpPath().c_str())) { + return true; + } + + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis()); + return false; + } + + const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + if (coverImageHref.empty()) { + Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis()); + return false; + } + + if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || + coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { + Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis()); + const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; + + FsFile coverJpg; + if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + readItemContentsToStream(coverImageHref, coverJpg, 1024); + coverJpg.close(); + + if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + + FsFile thumbBmp; + if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) { + coverJpg.close(); + return false; + } + // Use smaller target size for Continue Reading card (half of screen: 240x400) + // Generate 1-bit BMP for fast home screen rendering (no gray passes needed) + constexpr int THUMB_TARGET_WIDTH = 240; + constexpr int THUMB_TARGET_HEIGHT = 400; + const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, + THUMB_TARGET_HEIGHT); + coverJpg.close(); + thumbBmp.close(); + SdMan.remove(coverJpgTempPath.c_str()); + + if (!success) { + Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis()); + SdMan.remove(getThumbBmpPath().c_str()); + } + Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(), + success ? "yes" : "no"); + return success; + } else { + Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis()); + } + + return false; +} + uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const { if (itemHref.empty()) { Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis()); diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 047c955a..91062aa4 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -46,6 +46,8 @@ class Epub { const std::string& getAuthor() const; std::string getCoverBmpPath(bool cropped = false) const; bool generateCoverBmp(bool cropped = false) const; + std::string getThumbBmpPath() const; + bool generateThumbBmp() const; uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index f9c0326f..3c37e31b 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -43,7 +43,7 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere wordWidths.reserve(totalWordCount); // add em-space at the beginning of first word in paragraph to indent - if (!extraParagraphSpacing) { + if ((style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN) && !extraParagraphSpacing) { std::string& first_word = words.front(); first_word.insert(0, "\xe2\x80\x83"); } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index b96d28f8..e3e08310 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -97,6 +97,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->startNewTextBlock(self->currentTextBlock->getStyle()); } else { self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment); + if (strcmp(name, "li") == 0) { + self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); + } } } else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); @@ -151,6 +154,20 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char } } + // Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF + const XML_Char FEFF_BYTE_1 = static_cast(0xEF); + const XML_Char FEFF_BYTE_2 = static_cast(0xBB); + const XML_Char FEFF_BYTE_3 = static_cast(0xBF); + + if (s[i] == FEFF_BYTE_1) { + // Check if the next two bytes complete the 3-byte sequence + if ((i + 2 < len) && (s[i + 1] == FEFF_BYTE_2) && (s[i + 2] == FEFF_BYTE_3)) { + // Sequence 0xEF 0xBB 0xBF found! + i += 2; // Skip the next two bytes + continue; // 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'; diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 1a3b4406..ad25ffcd 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -228,7 +228,10 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { } case 1: { for (int x = 0; x < width; x++) { - lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; + // Get palette index (0 or 1) from bit at position x + const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0; + // Use palette lookup for proper black/white mapping + lum = paletteLum[palIndex]; packPixel(lum); } break; diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 9ac7cfbb..544869c1 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -42,6 +42,8 @@ class Bitmap { bool isTopDown() const { return topDown; } bool hasGreyscale() const { return bpp > 1; } int getRowBytes() const { return rowBytes; } + bool is1Bit() const { return bpp == 1; } + uint16_t getBpp() const { return bpp; } private: static uint16_t readLE16(FsFile& f); diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index b0d9dc06..465593e8 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -88,3 +88,19 @@ uint8_t quantize(int gray, int x, int y) { return quantizeSimple(gray); } } + +// 1-bit noise dithering for fast home screen rendering +// Uses hash-based noise for consistent dithering that works well at small sizes +uint8_t quantize1bit(int gray, int x, int y) { + gray = adjustPixel(gray); + + // Generate noise threshold using integer hash (no regular pattern to alias) + uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); // 0-255 + + // Simple threshold with noise: gray >= (128 + noise offset) -> white + // The noise adds variation around the 128 midpoint + const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 + return (gray >= adjustedThreshold) ? 1 : 0; +} diff --git a/lib/GfxRenderer/BitmapHelpers.h b/lib/GfxRenderer/BitmapHelpers.h index 300527e0..791e70b9 100644 --- a/lib/GfxRenderer/BitmapHelpers.h +++ b/lib/GfxRenderer/BitmapHelpers.h @@ -5,8 +5,89 @@ // Helper functions uint8_t quantize(int gray, int x, int y); uint8_t quantizeSimple(int gray); +uint8_t quantize1bit(int gray, int x, int y); int adjustPixel(int gray); +// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails +// Error distribution pattern (same as 2-bit but quantizes to 2 levels): +// X 1/8 1/8 +// 1/8 1/8 1/8 +// 1/8 +class Atkinson1BitDitherer { + public: + explicit Atkinson1BitDitherer(int width) : width(width) { + errorRow0 = new int16_t[width + 4](); // Current row + errorRow1 = new int16_t[width + 4](); // Next row + errorRow2 = new int16_t[width + 4](); // Row after next + } + + ~Atkinson1BitDitherer() { + delete[] errorRow0; + delete[] errorRow1; + delete[] errorRow2; + } + + // EXPLICITLY DELETE THE COPY CONSTRUCTOR + Atkinson1BitDitherer(const Atkinson1BitDitherer& other) = delete; + + // EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR + Atkinson1BitDitherer& operator=(const Atkinson1BitDitherer& other) = delete; + + uint8_t processPixel(int gray, int x) { + // Apply brightness/contrast/gamma adjustments + gray = adjustPixel(gray); + + // Add accumulated error + int adjusted = gray + errorRow0[x + 2]; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 2 levels (1-bit): 0 = black, 1 = white + uint8_t quantized; + int quantizedValue; + if (adjusted < 128) { + quantized = 0; + quantizedValue = 0; + } else { + quantized = 1; + quantizedValue = 255; + } + + // Calculate error (only distribute 6/8 = 75%) + int error = (adjusted - quantizedValue) >> 3; // error/8 + + // Distribute 1/8 to each of 6 neighbors + errorRow0[x + 3] += error; // Right + errorRow0[x + 4] += error; // Right+1 + errorRow1[x + 1] += error; // Bottom-left + errorRow1[x + 2] += error; // Bottom + errorRow1[x + 3] += error; // Bottom-right + errorRow2[x + 2] += error; // Two rows down + + return quantized; + } + + void nextRow() { + int16_t* temp = errorRow0; + errorRow0 = errorRow1; + errorRow1 = errorRow2; + errorRow2 = temp; + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + void reset() { + memset(errorRow0, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow1, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + private: + int width; + int16_t* errorRow0; + int16_t* errorRow1; + int16_t* errorRow2; +}; + // Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results // Error distribution pattern: // X 1/8 1/8 diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index cc1288a7..28022e90 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -154,6 +154,12 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, const float cropX, const float cropY) const { + // For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit) + if (bitmap.is1Bit() && cropX == 0.0f && cropY == 0.0f) { + drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight); + return; + } + float scale = 1.0f; bool isScaled = false; int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f); @@ -195,6 +201,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con if (screenY >= getScreenHeight()) { break; } + if (screenY < 0) { + continue; + } if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); @@ -217,6 +226,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con if (screenX >= getScreenWidth()) { break; } + if (screenX < 0) { + continue; + } const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; @@ -234,6 +246,143 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con free(rowBytes); } +void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth, + const int maxHeight) const { + float scale = 1.0f; + bool isScaled = false; + if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { + scale = static_cast(maxWidth) / static_cast(bitmap.getWidth()); + isScaled = true; + } + if (maxHeight > 0 && bitmap.getHeight() > maxHeight) { + scale = std::min(scale, static_cast(maxHeight) / static_cast(bitmap.getHeight())); + isScaled = true; + } + + // For 1-bit BMP, output is still 2-bit packed (for consistency with readNextRow) + const int outputRowSize = (bitmap.getWidth() + 3) / 4; + auto* outputRow = static_cast(malloc(outputRowSize)); + auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); + + if (!outputRow || !rowBytes) { + Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", millis()); + free(outputRow); + free(rowBytes); + return; + } + + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + // Read rows sequentially using readNextRow + if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { + Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", millis(), bmpY); + free(outputRow); + free(rowBytes); + return; + } + + // Calculate screen Y based on whether BMP is top-down or bottom-up + const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + int screenY = y + (isScaled ? static_cast(std::floor(bmpYOffset * scale)) : bmpYOffset); + if (screenY >= getScreenHeight()) { + continue; // Continue reading to keep row counter in sync + } + if (screenY < 0) { + continue; + } + + for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { + int screenX = x + (isScaled ? static_cast(std::floor(bmpX * scale)) : bmpX); + if (screenX >= getScreenWidth()) { + break; + } + if (screenX < 0) { + continue; + } + + // Get 2-bit value (result of readNextRow quantization) + const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; + + // For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3) + // val < 3 means black pixel (draw it) + if (val < 3) { + drawPixel(screenX, screenY, true); + } + // White pixels (val == 3) are not drawn (leave background) + } + } + + free(outputRow); + free(rowBytes); +} + +void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const { + if (numPoints < 3) return; + + // Find bounding box + int minY = yPoints[0], maxY = yPoints[0]; + for (int i = 1; i < numPoints; i++) { + if (yPoints[i] < minY) minY = yPoints[i]; + if (yPoints[i] > maxY) maxY = yPoints[i]; + } + + // Clip to screen + if (minY < 0) minY = 0; + if (maxY >= getScreenHeight()) maxY = getScreenHeight() - 1; + + // Allocate node buffer for scanline algorithm + auto* nodeX = static_cast(malloc(numPoints * sizeof(int))); + if (!nodeX) { + Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", millis()); + return; + } + + // Scanline fill algorithm + for (int scanY = minY; scanY <= maxY; scanY++) { + int nodes = 0; + + // Find all intersection points with edges + int j = numPoints - 1; + for (int i = 0; i < numPoints; i++) { + if ((yPoints[i] < scanY && yPoints[j] >= scanY) || (yPoints[j] < scanY && yPoints[i] >= scanY)) { + // Calculate X intersection using fixed-point to avoid float + int dy = yPoints[j] - yPoints[i]; + if (dy != 0) { + nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) * (xPoints[j] - xPoints[i]) / dy; + } + } + j = i; + } + + // Sort nodes by X (simple bubble sort, numPoints is small) + for (int i = 0; i < nodes - 1; i++) { + for (int k = i + 1; k < nodes; k++) { + if (nodeX[i] > nodeX[k]) { + int temp = nodeX[i]; + nodeX[i] = nodeX[k]; + nodeX[k] = temp; + } + } + } + + // Fill between pairs of nodes + for (int i = 0; i < nodes - 1; i += 2) { + int startX = nodeX[i]; + int endX = nodeX[i + 1]; + + // Clip to screen + if (startX < 0) startX = 0; + if (endX >= getScreenWidth()) endX = getScreenWidth() - 1; + + // Draw horizontal line + for (int x = startX; x <= endX; x++) { + drawPixel(x, scanY, state); + } + } + } + + free(nodeX); +} + void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); } void GfxRenderer::invertScreen() const { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e3e9558d..9d341bcc 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -68,6 +68,8 @@ class GfxRenderer { void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, float cropY = 0) const; + void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; + void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const; // Text int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; @@ -97,8 +99,8 @@ class GfxRenderer { void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; - bool storeBwBuffer(); // Returns true if buffer was stored successfully - void restoreBwBuffer(); + bool storeBwBuffer(); // Returns true if buffer was stored successfully + void restoreBwBuffer(); // Restore and free the stored buffer void cleanupGrayscaleWithFrameBuffer() const; // Low level functions diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 30c1314f..01451a05 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -87,8 +87,47 @@ void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { } } +// Helper function: Write BMP header with 1-bit color depth (black and white) +static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) { + // Calculate row padding (each row must be multiple of 4 bytes) + const int bytesPerRow = (width + 31) / 32 * 4; // 1 bit per pixel, round up to 4-byte boundary + const int imageSize = bytesPerRow * height; + const uint32_t fileSize = 62 + imageSize; // 14 (file header) + 40 (DIB header) + 8 (palette) + image + + // BMP File Header (14 bytes) + bmpOut.write('B'); + bmpOut.write('M'); + write32(bmpOut, fileSize); // File size + write32(bmpOut, 0); // Reserved + write32(bmpOut, 62); // Offset to pixel data (14 + 40 + 8) + + // DIB Header (BITMAPINFOHEADER - 40 bytes) + write32(bmpOut, 40); + write32Signed(bmpOut, width); + write32Signed(bmpOut, -height); // Negative height = top-down bitmap + write16(bmpOut, 1); // Color planes + write16(bmpOut, 1); // Bits per pixel (1 bit) + write32(bmpOut, 0); // BI_RGB (no compression) + write32(bmpOut, imageSize); + write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) + write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) + write32(bmpOut, 2); // colorsUsed + write32(bmpOut, 2); // colorsImportant + + // Color Palette (2 colors x 4 bytes = 8 bytes) + // Format: Blue, Green, Red, Reserved (BGRA) + // Note: In 1-bit BMP, palette index 0 = black, 1 = white + uint8_t palette[8] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White + }; + for (const uint8_t i : palette) { + bmpOut.write(i); + } +} + // Helper function: Write BMP header with 2-bit color depth -void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) { +static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) { // Calculate row padding (each row must be multiple of 4 bytes) const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up const int imageSize = bytesPerRow * height; @@ -159,9 +198,11 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un return 0; // Success } -// Core function: Convert JPEG file to 2-bit BMP -bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { - Serial.printf("[%lu] [JPG] Converting JPEG to BMP\n", millis()); +// Internal implementation with configurable target size and bit depth +bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, + bool oneBit) { + Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit", + targetWidth, targetHeight); // Setup context for picojpeg callback JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; @@ -196,10 +237,10 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { uint32_t scaleY_fp = 65536; bool needsScaling = false; - if (USE_PRESCALE && (imageInfo.m_width > TARGET_MAX_WIDTH || imageInfo.m_height > TARGET_MAX_HEIGHT)) { + if (targetWidth > 0 && targetHeight > 0 && (imageInfo.m_width > targetWidth || imageInfo.m_height > targetHeight)) { // Calculate scale to fit within target dimensions while maintaining aspect ratio - const float scaleToFitWidth = static_cast(TARGET_MAX_WIDTH) / imageInfo.m_width; - const float scaleToFitHeight = static_cast(TARGET_MAX_HEIGHT) / imageInfo.m_height; + 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; @@ -218,16 +259,19 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { needsScaling = true; Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width, - imageInfo.m_height, outWidth, outHeight, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT); + imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight); } // Write BMP header with output dimensions int bytesPerRow; - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { writeBmpHeader8bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth + 3) / 4 * 4; + } else if (oneBit) { + writeBmpHeader1bit(bmpOut, outWidth, outHeight); + bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel } else { - writeBmpHeader(bmpOut, outWidth, outHeight); + writeBmpHeader2bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth * 2 + 31) / 32 * 4; } @@ -258,11 +302,16 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { return false; } - // Create ditherer if enabled (only for 2-bit output) + // Create ditherer if enabled // Use OUTPUT dimensions for dithering (after prescaling) AtkinsonDitherer* atkinsonDitherer = nullptr; FloydSteinbergDitherer* fsDitherer = nullptr; - if (!USE_8BIT_OUTPUT) { + Atkinson1BitDitherer* atkinson1BitDitherer = nullptr; + + if (oneBit) { + // For 1-bit output, use Atkinson dithering for better quality + atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth); + } else if (!USE_8BIT_OUTPUT) { if (USE_ATKINSON) { atkinsonDitherer = new AtkinsonDitherer(outWidth); } else if (USE_FLOYD_STEINBERG) { @@ -348,12 +397,25 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { // No scaling - direct output (1:1 mapping) memset(rowBuffer, 0, bytesPerRow); - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { for (int x = 0; x < outWidth; x++) { const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; rowBuffer[x] = adjustPixel(gray); } + } else if (oneBit) { + // 1-bit output with Atkinson dithering for better quality + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + const uint8_t bit = + atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y); + // Pack 1-bit value: MSB first, 8 pixels per byte + const int byteIndex = x / 8; + const int bitOffset = 7 - (x % 8); + rowBuffer[byteIndex] |= (bit << bitOffset); + } + if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); } else { + // 2-bit output for (int x = 0; x < outWidth; x++) { const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]); uint8_t twoBit; @@ -411,12 +473,25 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) { memset(rowBuffer, 0, bytesPerRow); - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { for (int x = 0; x < outWidth; x++) { const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; rowBuffer[x] = adjustPixel(gray); } + } else if (oneBit) { + // 1-bit output with Atkinson dithering for better quality + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; + const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) + : quantize1bit(gray, x, currentOutY); + // Pack 1-bit value: MSB first, 8 pixels per byte + const int byteIndex = x / 8; + const int bitOffset = 7 - (x % 8); + rowBuffer[byteIndex] |= (bit << bitOffset); + } + if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); } else { + // 2-bit output for (int x = 0; x < outWidth; x++) { const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0); uint8_t twoBit; @@ -464,9 +539,29 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { if (fsDitherer) { delete fsDitherer; } + if (atkinson1BitDitherer) { + delete atkinson1BitDitherer; + } free(mcuRowBuffer); free(rowBuffer); Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis()); return true; } + +// 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); +} + +// Convert with custom target size (for thumbnails, 2-bit) +bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, + int targetMaxHeight) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false); +} + +// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering +bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, + int targetMaxHeight) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true); +} diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index f61bd8ef..d5e9b950 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -5,11 +5,15 @@ class Print; class ZipFile; class JpegToBmpConverter { - static void writeBmpHeader(Print& bmpOut, int width, int height); - // [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y); 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); public: static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut); + // 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 + static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); }; diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp new file mode 100644 index 00000000..52c75ed7 --- /dev/null +++ b/lib/Txt/Txt.cpp @@ -0,0 +1,191 @@ +#include "Txt.h" + +#include +#include + +Txt::Txt(std::string path, std::string cacheBasePath) + : filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) { + // Generate cache path from file path hash + const size_t hash = std::hash{}(filepath); + cachePath = this->cacheBasePath + "/txt_" + std::to_string(hash); +} + +bool Txt::load() { + if (loaded) { + return true; + } + + if (!SdMan.exists(filepath.c_str())) { + Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str()); + return false; + } + + FsFile file; + if (!SdMan.openFileForRead("TXT", filepath, file)) { + Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str()); + return false; + } + + fileSize = file.size(); + file.close(); + + loaded = true; + Serial.printf("[%lu] [TXT] Loaded TXT file: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize); + return true; +} + +std::string Txt::getTitle() const { + // Extract filename without path and extension + size_t lastSlash = filepath.find_last_of('/'); + std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath; + + // Remove .txt extension + if (filename.length() >= 4 && filename.substr(filename.length() - 4) == ".txt") { + filename = filename.substr(0, filename.length() - 4); + } + + return filename; +} + +void Txt::setupCacheDir() const { + if (!SdMan.exists(cacheBasePath.c_str())) { + SdMan.mkdir(cacheBasePath.c_str()); + } + if (!SdMan.exists(cachePath.c_str())) { + SdMan.mkdir(cachePath.c_str()); + } +} + +std::string Txt::findCoverImage() const { + // Get the folder containing the txt file + size_t lastSlash = filepath.find_last_of('/'); + std::string folder = (lastSlash != std::string::npos) ? filepath.substr(0, lastSlash) : ""; + if (folder.empty()) { + folder = "/"; + } + + // Get the base filename without extension (e.g., "mybook" from "/books/mybook.txt") + std::string baseName = getTitle(); + + // Image extensions to try + const char* extensions[] = {".bmp", ".jpg", ".jpeg", ".png", ".BMP", ".JPG", ".JPEG", ".PNG"}; + + // First priority: look for image with same name as txt file (e.g., mybook.jpg) + for (const auto& ext : extensions) { + std::string coverPath = folder + "/" + baseName + ext; + if (SdMan.exists(coverPath.c_str())) { + Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str()); + return coverPath; + } + } + + // Fallback: look for cover image files + const char* coverNames[] = {"cover", "Cover", "COVER"}; + for (const auto& name : coverNames) { + for (const auto& ext : extensions) { + std::string coverPath = folder + "/" + std::string(name) + ext; + if (SdMan.exists(coverPath.c_str())) { + Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str()); + return coverPath; + } + } + } + + return ""; +} + +std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } + +bool Txt::generateCoverBmp() const { + // Already generated, return true + if (SdMan.exists(getCoverBmpPath().c_str())) { + return true; + } + + std::string coverImagePath = findCoverImage(); + if (coverImagePath.empty()) { + Serial.printf("[%lu] [TXT] No cover image found for TXT file\n", millis()); + return false; + } + + // Setup cache directory + setupCacheDir(); + + // Get file extension + const size_t len = coverImagePath.length(); + const bool isJpg = + (len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) || + (len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG")); + const bool isBmp = len >= 4 && (coverImagePath.substr(len - 4) == ".bmp" || coverImagePath.substr(len - 4) == ".BMP"); + + if (isBmp) { + // Copy BMP file to cache + Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis()); + FsFile src, dst; + if (!SdMan.openFileForRead("TXT", coverImagePath, src)) { + return false; + } + if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), dst)) { + src.close(); + return false; + } + uint8_t buffer[1024]; + while (src.available()) { + size_t bytesRead = src.read(buffer, sizeof(buffer)); + dst.write(buffer, bytesRead); + } + src.close(); + dst.close(); + Serial.printf("[%lu] [TXT] Copied BMP cover to cache\n", millis()); + return true; + } + + if (isJpg) { + // Convert JPG/JPEG to BMP (same approach as Epub) + Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis()); + FsFile coverJpg, coverBmp; + if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) { + return false; + } + if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) { + coverJpg.close(); + return false; + } + const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); + coverJpg.close(); + coverBmp.close(); + + if (!success) { + Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis()); + SdMan.remove(getCoverBmpPath().c_str()); + } else { + Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis()); + } + return success; + } + + // PNG files are not supported (would need a PNG decoder) + Serial.printf("[%lu] [TXT] Cover image format not supported (only BMP/JPG/JPEG)\n", millis()); + return false; +} + +bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const { + if (!loaded) { + return false; + } + + FsFile file; + if (!SdMan.openFileForRead("TXT", filepath, file)) { + return false; + } + + if (!file.seek(offset)) { + file.close(); + return false; + } + + size_t bytesRead = file.read(buffer, length); + file.close(); + + return bytesRead > 0; +} diff --git a/lib/Txt/Txt.h b/lib/Txt/Txt.h new file mode 100644 index 00000000..b75c7738 --- /dev/null +++ b/lib/Txt/Txt.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include +#include + +class Txt { + std::string filepath; + std::string cacheBasePath; + std::string cachePath; + bool loaded = false; + size_t fileSize = 0; + + public: + explicit Txt(std::string path, std::string cacheBasePath); + + bool load(); + [[nodiscard]] const std::string& getPath() const { return filepath; } + [[nodiscard]] const std::string& getCachePath() const { return cachePath; } + [[nodiscard]] std::string getTitle() const; + [[nodiscard]] size_t getFileSize() const { return fileSize; } + + void setupCacheDir() const; + + // Cover image support - looks for cover.bmp/jpg/jpeg/png in same folder as txt file + [[nodiscard]] std::string getCoverBmpPath() const; + [[nodiscard]] bool generateCoverBmp() const; + [[nodiscard]] std::string findCoverImage() const; + + // Read content from file + [[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const; +}; diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 8f79c9dd..7205ffb9 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -293,6 +293,267 @@ bool Xtc::generateCoverBmp() const { return true; } +std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } + +bool Xtc::generateThumbBmp() const { + // Already generated + if (SdMan.exists(getThumbBmpPath().c_str())) { + return true; + } + + if (!loaded || !parser) { + Serial.printf("[%lu] [XTC] Cannot generate thumb BMP, file not loaded\n", millis()); + return false; + } + + if (parser->getPageCount() == 0) { + Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis()); + return false; + } + + // Setup cache directory + setupCacheDir(); + + // Get first page info for cover + xtc::PageInfo pageInfo; + if (!parser->getPageInfo(0, pageInfo)) { + Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis()); + return false; + } + + // Get bit depth + const uint8_t bitDepth = parser->getBitDepth(); + + // Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card) + constexpr int THUMB_TARGET_WIDTH = 240; + constexpr int THUMB_TARGET_HEIGHT = 400; + + // Calculate scale factor + float scaleX = static_cast(THUMB_TARGET_WIDTH) / pageInfo.width; + float scaleY = static_cast(THUMB_TARGET_HEIGHT) / pageInfo.height; + float scale = (scaleX < scaleY) ? scaleX : scaleY; + + // Only scale down, never up + if (scale >= 1.0f) { + // Page is already small enough, just use cover.bmp + // Copy cover.bmp to thumb.bmp + if (generateCoverBmp()) { + FsFile src, dst; + if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) { + if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) { + uint8_t buffer[512]; + while (src.available()) { + size_t bytesRead = src.read(buffer, sizeof(buffer)); + dst.write(buffer, bytesRead); + } + dst.close(); + } + src.close(); + } + Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis()); + return SdMan.exists(getThumbBmpPath().c_str()); + } + return false; + } + + uint16_t thumbWidth = static_cast(pageInfo.width * scale); + uint16_t thumbHeight = static_cast(pageInfo.height * scale); + + Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width, + pageInfo.height, thumbWidth, thumbHeight, scale); + + // Allocate buffer for page data + size_t bitmapSize; + if (bitDepth == 2) { + bitmapSize = ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) * 2; + } else { + bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; + } + uint8_t* pageBuffer = static_cast(malloc(bitmapSize)); + if (!pageBuffer) { + Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize); + return false; + } + + // Load first page (cover) + size_t bytesRead = const_cast(parser.get())->loadPage(0, pageBuffer, bitmapSize); + if (bytesRead == 0) { + Serial.printf("[%lu] [XTC] Failed to load cover page for thumb\n", millis()); + free(pageBuffer); + return false; + } + + // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) + FsFile thumbBmp; + if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) { + Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); + free(pageBuffer); + return false; + } + + // Write 1-bit BMP header for fast home screen rendering + const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes + const uint32_t imageSize = rowSize * thumbHeight; + const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette + + // File header + thumbBmp.write('B'); + thumbBmp.write('M'); + thumbBmp.write(reinterpret_cast(&fileSize), 4); + uint32_t reserved = 0; + thumbBmp.write(reinterpret_cast(&reserved), 4); + uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes) + thumbBmp.write(reinterpret_cast(&dataOffset), 4); + + // DIB header + uint32_t dibHeaderSize = 40; + thumbBmp.write(reinterpret_cast(&dibHeaderSize), 4); + int32_t widthVal = thumbWidth; + thumbBmp.write(reinterpret_cast(&widthVal), 4); + int32_t heightVal = -static_cast(thumbHeight); // Negative for top-down + thumbBmp.write(reinterpret_cast(&heightVal), 4); + uint16_t planes = 1; + thumbBmp.write(reinterpret_cast(&planes), 2); + uint16_t bitsPerPixel = 1; // 1-bit for black and white + thumbBmp.write(reinterpret_cast(&bitsPerPixel), 2); + uint32_t compression = 0; + thumbBmp.write(reinterpret_cast(&compression), 4); + thumbBmp.write(reinterpret_cast(&imageSize), 4); + int32_t ppmX = 2835; + thumbBmp.write(reinterpret_cast(&ppmX), 4); + int32_t ppmY = 2835; + thumbBmp.write(reinterpret_cast(&ppmY), 4); + uint32_t colorsUsed = 2; + thumbBmp.write(reinterpret_cast(&colorsUsed), 4); + uint32_t colorsImportant = 2; + thumbBmp.write(reinterpret_cast(&colorsImportant), 4); + + // Color palette (2 colors for 1-bit: black and white) + uint8_t palette[8] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White + }; + thumbBmp.write(palette, 8); + + // Allocate row buffer for 1-bit output + uint8_t* rowBuffer = static_cast(malloc(rowSize)); + if (!rowBuffer) { + free(pageBuffer); + thumbBmp.close(); + return false; + } + + // Fixed-point scale factor (16.16) + uint32_t scaleInv_fp = static_cast(65536.0f / scale); + + // Pre-calculate plane info for 2-bit mode + const size_t planeSize = (bitDepth == 2) ? ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) : 0; + const uint8_t* plane1 = (bitDepth == 2) ? pageBuffer : nullptr; + const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr; + const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0; + const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0; + + for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { + memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1) + + // Calculate source Y range with bounds checking + uint32_t srcYStart = (static_cast(dstY) * scaleInv_fp) >> 16; + uint32_t srcYEnd = (static_cast(dstY + 1) * scaleInv_fp) >> 16; + if (srcYStart >= pageInfo.height) srcYStart = pageInfo.height - 1; + if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; + if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1; + if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; + + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + // Calculate source X range with bounds checking + uint32_t srcXStart = (static_cast(dstX) * scaleInv_fp) >> 16; + uint32_t srcXEnd = (static_cast(dstX + 1) * scaleInv_fp) >> 16; + if (srcXStart >= pageInfo.width) srcXStart = pageInfo.width - 1; + if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; + if (srcXEnd <= srcXStart) srcXEnd = srcXStart + 1; + if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; + + // Area averaging: sum grayscale values (0-255 range) + uint32_t graySum = 0; + uint32_t totalCount = 0; + + for (uint32_t srcY = srcYStart; srcY < srcYEnd && srcY < pageInfo.height; srcY++) { + for (uint32_t srcX = srcXStart; srcX < srcXEnd && srcX < pageInfo.width; srcX++) { + uint8_t grayValue = 255; // Default: white + + if (bitDepth == 2) { + // XTH 2-bit mode: pixel value 0-3 + // Bounds check for column index + if (srcX < pageInfo.width) { + const size_t colIndex = pageInfo.width - 1 - srcX; + const size_t byteInCol = srcY / 8; + const size_t bitInByte = 7 - (srcY % 8); + const size_t byteOffset = colIndex * colBytes + byteInCol; + // Bounds check for buffer access + if (byteOffset < planeSize) { + const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; + const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; + const uint8_t pixelValue = (bit1 << 1) | bit2; + // Convert 2-bit (0-3) to grayscale: 0=black, 3=white + // pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity) + grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0 + } + } + } else { + // 1-bit mode + const size_t byteIdx = srcY * srcRowBytes + srcX / 8; + const size_t bitIdx = 7 - (srcX % 8); + // Bounds check for buffer access + if (byteIdx < bitmapSize) { + const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1; + // XTC polarity: 1=black, 0=white + grayValue = pixelBit ? 0 : 255; + } + } + + graySum += grayValue; + totalCount++; + } + } + + // Calculate average grayscale and quantize to 1-bit with noise dithering + uint8_t avgGray = (totalCount > 0) ? static_cast(graySum / totalCount) : 255; + + // Hash-based noise dithering for 1-bit output + uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); // 0-255 + const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 + + // Quantize to 1-bit: 0=black, 1=white + uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0; + + // Pack 1-bit value into row buffer (MSB first, 8 pixels per byte) + const size_t byteIndex = dstX / 8; + const size_t bitOffset = 7 - (dstX % 8); + // Bounds check for row buffer access + if (byteIndex < rowSize) { + if (oneBit) { + rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white + } else { + rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black + } + } + } + + // Write row (already padded to 4-byte boundary by rowSize) + thumbBmp.write(rowBuffer, rowSize); + } + + free(rowBuffer); + thumbBmp.close(); + free(pageBuffer); + + Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight, + getThumbBmpPath().c_str()); + return true; +} + uint32_t Xtc::getPageCount() const { if (!loaded || !parser) { return 0; diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index e5bce102..7413ef47 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -62,6 +62,9 @@ class Xtc { // Cover image support (for sleep screen) std::string getCoverBmpPath() const; bool generateCoverBmp() const; + // Thumbnail support (for Continue Reading card) + std::string getThumbBmpPath() const; + bool generateThumbBmp() const; // Page access uint32_t getPageCount() const; diff --git a/platformio.ini b/platformio.ini index 703b9348..cbe47fe9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -2,7 +2,7 @@ default_envs = default [crosspoint] -version = 0.13.1 +version = 0.14.0 [base] platform = espressif32 @ 6.12.0 @@ -47,6 +47,7 @@ lib_deps = SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager ArduinoJson @ 7.4.2 QRCode @ 0.0.1 + links2004/WebSockets @ ^2.4.1 [env:default] extends = base diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 1ca9ea74..17b5d053 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 = 17; +constexpr uint8_t SETTINGS_COUNT = 18; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -47,6 +47,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writePod(outputFile, textAntiAliasing); serialization::writePod(outputFile, hideBatteryPercentage); + serialization::writePod(outputFile, longPressChapterSkip); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -113,6 +114,8 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hideBatteryPercentage); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, longPressChapterSkip); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d5f91039..a5641aad 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -90,6 +90,8 @@ class CrossPointSettings { char opdsServerUrl[128] = ""; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; + // Long-press chapter skip on side buttons + uint8_t longPressChapterSkip = 1; ~CrossPointSettings() = default; diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index 31cb2acb..91aa2536 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -5,7 +5,7 @@ #include namespace { -constexpr uint8_t STATE_FILE_VERSION = 1; +constexpr uint8_t STATE_FILE_VERSION = 2; constexpr char STATE_FILE[] = "/.crosspoint/state.bin"; } // namespace @@ -19,6 +19,7 @@ bool CrossPointState::saveToFile() const { serialization::writePod(outputFile, STATE_FILE_VERSION); serialization::writeString(outputFile, openEpubPath); + serialization::writePod(outputFile, lastSleepImage); outputFile.close(); return true; } @@ -31,13 +32,18 @@ bool CrossPointState::loadFromFile() { uint8_t version; serialization::readPod(inputFile, version); - if (version != STATE_FILE_VERSION) { + if (version > STATE_FILE_VERSION) { Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version); inputFile.close(); return false; } serialization::readString(inputFile, openEpubPath); + if (version >= 2) { + serialization::readPod(inputFile, lastSleepImage); + } else { + lastSleepImage = 0; + } inputFile.close(); return true; diff --git a/src/CrossPointState.h b/src/CrossPointState.h index f060a0c6..87ce4e96 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -8,6 +8,7 @@ class CrossPointState { public: std::string openEpubPath; + uint8_t lastSleepImage; ~CrossPointState() = default; // Get singleton instance diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 3305a16d..bf2b5857 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "CrossPointSettings.h" @@ -80,7 +81,13 @@ void SleepActivity::renderCustomSleepScreen() const { const auto numFiles = files.size(); if (numFiles > 0) { // Generate a random number between 1 and numFiles - const auto randomFileIndex = random(numFiles); + auto randomFileIndex = random(numFiles); + // If we picked the same image as last time, reroll + while (numFiles > 1 && randomFileIndex == APP_STATE.lastSleepImage) { + randomFileIndex = random(numFiles); + } + APP_STATE.lastSleepImage = randomFileIndex; + APP_STATE.saveToFile(); const auto filename = "/sleep/" + files[randomFileIndex]; FsFile file; if (SdMan.openFileForRead("SLP", filename, file)) { @@ -201,6 +208,7 @@ void SleepActivity::renderCoverSleepScreen() const { std::string coverBmpPath; bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP; + // Check if the current book is XTC, TXT, or EPUB if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") || StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) { // Handle XTC file @@ -216,6 +224,20 @@ void SleepActivity::renderCoverSleepScreen() const { } coverBmpPath = lastXtc.getCoverBmpPath(); + } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) { + // Handle TXT file - looks for cover image in the same folder + Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint"); + if (!lastTxt.load()) { + Serial.println("[SLP] Failed to load last TXT"); + return renderDefaultSleepScreen(); + } + + if (!lastTxt.generateCoverBmp()) { + Serial.println("[SLP] No cover image found for TXT file"); + return renderDefaultSleepScreen(); + } + + coverBmpPath = lastTxt.getCoverBmpPath(); } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) { // Handle EPUB file Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 4e3eb6db..14ec1c53 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -1,8 +1,10 @@ #include "HomeActivity.h" +#include #include #include #include +#include #include #include @@ -46,7 +48,7 @@ void HomeActivity::onEnter() { lastBookTitle = lastBookTitle.substr(lastSlash + 1); } - // If epub, try to load the metadata for title/author + // If epub, try to load the metadata for title/author and cover if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) { Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); epub.load(false); @@ -56,10 +58,31 @@ void HomeActivity::onEnter() { if (!epub.getAuthor().empty()) { lastBookAuthor = std::string(epub.getAuthor()); } - } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { - lastBookTitle.resize(lastBookTitle.length() - 5); - } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { - lastBookTitle.resize(lastBookTitle.length() - 4); + // Try to generate thumbnail image for Continue Reading card + if (epub.generateThumbBmp()) { + coverBmpPath = epub.getThumbBmpPath(); + hasCoverImage = true; + } + } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") || + StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { + // Handle XTC file + Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint"); + if (xtc.load()) { + if (!xtc.getTitle().empty()) { + lastBookTitle = std::string(xtc.getTitle()); + } + // Try to generate thumbnail image for Continue Reading card + if (xtc.generateThumbBmp()) { + coverBmpPath = xtc.getThumbBmpPath(); + hasCoverImage = true; + } + } + // Remove extension from title if we don't have metadata + if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { + lastBookTitle.resize(lastBookTitle.length() - 5); + } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { + lastBookTitle.resize(lastBookTitle.length() - 4); + } } } @@ -69,7 +92,7 @@ void HomeActivity::onEnter() { updateRequired = true; xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", - 4096, // Stack size + 4096, // Stack size (increased for cover image rendering) this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -87,6 +110,51 @@ void HomeActivity::onExit() { } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; + + // Free the stored cover buffer if any + freeCoverBuffer(); +} + +bool HomeActivity::storeCoverBuffer() { + uint8_t* frameBuffer = renderer.getFrameBuffer(); + if (!frameBuffer) { + return false; + } + + // Free any existing buffer first + freeCoverBuffer(); + + const size_t bufferSize = GfxRenderer::getBufferSize(); + coverBuffer = static_cast(malloc(bufferSize)); + if (!coverBuffer) { + return false; + } + + memcpy(coverBuffer, frameBuffer, bufferSize); + return true; +} + +bool HomeActivity::restoreCoverBuffer() { + if (!coverBuffer) { + return false; + } + + uint8_t* frameBuffer = renderer.getFrameBuffer(); + if (!frameBuffer) { + return false; + } + + const size_t bufferSize = GfxRenderer::getBufferSize(); + memcpy(frameBuffer, coverBuffer, bufferSize); + return true; +} + +void HomeActivity::freeCoverBuffer() { + if (coverBuffer) { + free(coverBuffer); + coverBuffer = nullptr; + } + coverBufferStored = false; } void HomeActivity::loop() { @@ -138,8 +206,12 @@ void HomeActivity::displayTaskLoop() { } } -void HomeActivity::render() const { - renderer.clearScreen(); +void HomeActivity::render() { + // If we have a stored cover buffer, restore it instead of clearing + const bool bufferRestored = coverBufferStored && restoreCoverBuffer(); + if (!bufferRestored) { + renderer.clearScreen(); + } const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); @@ -154,34 +226,101 @@ void HomeActivity::render() const { constexpr int bookY = 30; const bool bookSelected = hasContinueReading && selectorIndex == 0; + // Bookmark dimensions (used in multiple places) + const int bookmarkWidth = bookWidth / 8; + const int bookmarkHeight = bookHeight / 5; + const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; + const int bookmarkY = bookY + 5; + // Draw book card regardless, fill with message based on `hasContinueReading` { - if (bookSelected) { - renderer.fillRect(bookX, bookY, bookWidth, bookHeight); - } else { - renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + // Draw cover image as background if available (inside the box) + // Only load from SD on first render, then use stored buffer + if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) { + // First time: load cover from SD and render + FsFile file; + if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + // Calculate position to center image within the book card + int coverX, coverY; + + if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { + const float imgRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + const float boxRatio = static_cast(bookWidth) / static_cast(bookHeight); + + if (imgRatio > boxRatio) { + coverX = bookX; + coverY = bookY + (bookHeight - static_cast(bookWidth / imgRatio)) / 2; + } else { + coverX = bookX + (bookWidth - static_cast(bookHeight * imgRatio)) / 2; + coverY = bookY; + } + } else { + coverX = bookX + (bookWidth - bitmap.getWidth()) / 2; + coverY = bookY + (bookHeight - bitmap.getHeight()) / 2; + } + + // Draw the cover image centered within the book card + renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight); + + // Draw border around the card + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + + // No bookmark ribbon when cover is shown - it would just cover the art + + // Store the buffer with cover image for fast navigation + coverBufferStored = storeCoverBuffer(); + coverRendered = true; + + // First render: if selected, draw selection indicators now + if (bookSelected) { + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + } + } + file.close(); + } + } else if (!bufferRestored && !coverRendered) { + // No cover image: draw border or fill, plus bookmark as visual flair + if (bookSelected) { + renderer.fillRect(bookX, bookY, bookWidth, bookHeight); + } else { + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + } + + // Draw bookmark ribbon when no cover image (visual decoration) + if (hasContinueReading) { + const int notchDepth = bookmarkHeight / 3; + const int centerX = bookmarkX + bookmarkWidth / 2; + + const int xPoints[5] = { + bookmarkX, // top-left + bookmarkX + bookmarkWidth, // top-right + bookmarkX + bookmarkWidth, // bottom-right + centerX, // center notch point + bookmarkX // bottom-left + }; + const int yPoints[5] = { + bookmarkY, // top-left + bookmarkY, // top-right + bookmarkY + bookmarkHeight, // bottom-right + bookmarkY + bookmarkHeight - notchDepth, // center notch point + bookmarkY + bookmarkHeight // bottom-left + }; + + // Draw bookmark ribbon (inverted if selected) + renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected); + } } - // Bookmark icon in the top-right corner of the card - const int bookmarkWidth = bookWidth / 8; - const int bookmarkHeight = bookHeight / 5; - const int bookmarkX = bookX + bookWidth - bookmarkWidth - 8; - constexpr int bookmarkY = bookY + 1; - - // Main bookmark body (solid) - renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, !bookSelected); - - // Carve out an inverted triangle notch at the bottom center to create angled points - const int notchHeight = bookmarkHeight / 2; // depth of the notch - for (int i = 0; i < notchHeight; ++i) { - const int y = bookmarkY + bookmarkHeight - 1 - i; - const int xStart = bookmarkX + i; - const int width = bookmarkWidth - 2 * i; - if (width <= 0) { - break; - } - // Draw a horizontal strip in the opposite color to "cut" the notch - renderer.fillRect(xStart, y, width, 1, bookSelected); + // If buffer was restored, draw selection indicators if needed + if (bufferRestored && bookSelected && coverRendered) { + // Draw selection border (no bookmark inversion needed since cover has no bookmark) + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + } else if (!coverRendered && !bufferRestored) { + // Selection border already handled above in the no-cover case } } @@ -218,18 +357,25 @@ void HomeActivity::render() const { lines.back().append("..."); while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { - lines.back().resize(lines.back().size() - 5); + // Remove "..." first, then remove one UTF-8 char, then add "..." back + lines.back().resize(lines.back().size() - 3); // Remove "..." + StringUtils::utf8RemoveLastChar(lines.back()); lines.back().append("..."); } break; } int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); - while (wordWidth > maxLineWidth && i.size() > 5) { - // Word itself is too long, trim it - i.resize(i.size() - 5); - i.append("..."); - wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); + while (wordWidth > maxLineWidth && !i.empty()) { + // Word itself is too long, trim it (UTF-8 safe) + StringUtils::utf8RemoveLastChar(i); + // Check if we have room for ellipsis + std::string withEllipsis = i + "..."; + wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); + if (wordWidth <= maxLineWidth) { + i = withEllipsis; + break; + } } int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); @@ -261,24 +407,85 @@ void HomeActivity::render() const { // Vertically center the title block within the card int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; + // If cover image was rendered, draw white box behind title and author + if (coverRendered) { + constexpr int boxPadding = 8; + // Calculate the max text width for the box + int maxTextWidth = 0; + for (const auto& line : lines) { + const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); + if (lineWidth > maxTextWidth) { + maxTextWidth = lineWidth; + } + } + if (!lastBookAuthor.empty()) { + std::string trimmedAuthor = lastBookAuthor; + while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { + StringUtils::utf8RemoveLastChar(trimmedAuthor); + } + if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < + renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { + trimmedAuthor.append("..."); + } + const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); + if (authorWidth > maxTextWidth) { + maxTextWidth = authorWidth; + } + } + + const int boxWidth = maxTextWidth + boxPadding * 2; + const int boxHeight = totalTextHeight + boxPadding * 2; + const int boxX = (pageWidth - boxWidth) / 2; + const int boxY = titleYStart - boxPadding; + + // Draw white filled box + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + // Draw black border around the box + renderer.drawRect(boxX, boxY, boxWidth, boxHeight, true); + } + for (const auto& line : lines) { - renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); + renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected || coverRendered); titleYStart += renderer.getLineHeight(UI_12_FONT_ID); } if (!lastBookAuthor.empty()) { titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; std::string trimmedAuthor = lastBookAuthor; - // Trim author if too long + // Trim author if too long (UTF-8 safe) + bool wasTrimmed = false; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - trimmedAuthor.resize(trimmedAuthor.size() - 5); + StringUtils::utf8RemoveLastChar(trimmedAuthor); + wasTrimmed = true; + } + if (wasTrimmed && !trimmedAuthor.empty()) { + // Make room for ellipsis + while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && + !trimmedAuthor.empty()) { + StringUtils::utf8RemoveLastChar(trimmedAuthor); + } trimmedAuthor.append("..."); } - renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); + renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected || coverRendered); } - renderer.drawCenteredText(UI_10_FONT_ID, bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2, - "Continue Reading", !bookSelected); + // "Continue Reading" label at the bottom + const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; + if (coverRendered) { + // Draw white box behind "Continue Reading" text + const char* continueText = "Continue Reading"; + const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText); + constexpr int continuePadding = 6; + const int continueBoxWidth = continueTextWidth + continuePadding * 2; + const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; + const int continueBoxX = (pageWidth - continueBoxWidth) / 2; + const int continueBoxY = continueY - continuePadding / 2; + renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, false); + renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, true); + renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, true); + } else { + renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected); + } } else { // No book to continue reading const int y = diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 797efd5c..52963514 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -14,8 +14,13 @@ class HomeActivity final : public Activity { bool updateRequired = false; bool hasContinueReading = false; bool hasOpdsUrl = false; + bool hasCoverImage = false; + bool coverRendered = false; // Track if cover has been rendered once + bool coverBufferStored = false; // Track if cover buffer is stored + uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image std::string lastBookTitle; std::string lastBookAuthor; + std::string coverBmpPath; const std::function onContinueReading; const std::function onMyLibraryOpen; const std::function onSettingsOpen; @@ -24,8 +29,11 @@ class HomeActivity final : public Activity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); - void render() const; + void render(); int getMenuItemCount() const; + bool storeCoverBuffer(); // Store frame buffer for cover image + bool restoreCoverBuffer(); // Restore frame buffer from stored cover + void freeCoverBuffer(); // Free the stored cover buffer public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index dde05614..35ad58ba 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -83,9 +84,8 @@ void CrossPointWebServerActivity::onExit() { dnsServer = nullptr; } - // CRITICAL: Wait for LWIP stack to flush any pending packets - Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis()); - delay(500); + // Brief wait for LWIP stack to flush pending packets + delay(50); // Disconnect WiFi gracefully if (isApMode) { @@ -95,11 +95,11 @@ void CrossPointWebServerActivity::onExit() { Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame } - delay(100); // Allow disconnect frame to be sent + delay(30); // Allow disconnect frame to be sent Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis()); WiFi.mode(WIFI_OFF); - delay(100); // Allow WiFi hardware to fully power down + delay(30); // Allow WiFi hardware to power down Serial.printf("[%lu] [WEBACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -283,8 +283,28 @@ void CrossPointWebServerActivity::loop() { dnsServer->processNextRequest(); } - // Handle web server requests - call handleClient multiple times per loop - // to improve responsiveness and upload throughput + // STA mode: Monitor WiFi connection health + if (!isApMode && webServer && webServer->isRunning()) { + static unsigned long lastWifiCheck = 0; + if (millis() - lastWifiCheck > 2000) { // Check every 2 seconds + lastWifiCheck = millis(); + const wl_status_t wifiStatus = WiFi.status(); + if (wifiStatus != WL_CONNECTED) { + Serial.printf("[%lu] [WEBACT] WiFi disconnected! Status: %d\n", millis(), wifiStatus); + // Show error and exit gracefully + state = WebServerActivityState::SHUTTING_DOWN; + updateRequired = true; + return; + } + // Log weak signal warnings + const int rssi = WiFi.RSSI(); + if (rssi < -75) { + Serial.printf("[%lu] [WEBACT] Warning: Weak WiFi signal: %d dBm\n", millis(), rssi); + } + } + } + + // Handle web server requests - maximize throughput with watchdog safety if (webServer && webServer->isRunning()) { const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; @@ -294,17 +314,32 @@ void CrossPointWebServerActivity::loop() { timeSinceLastHandleClient); } - // Call handleClient multiple times to process pending requests faster - // This is critical for upload performance - HTTP file uploads send data - // in chunks and each handleClient() call processes incoming data - constexpr int HANDLE_CLIENT_ITERATIONS = 10; - for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) { + // Reset watchdog BEFORE processing - HTTP header parsing can be slow + esp_task_wdt_reset(); + + // Process HTTP requests in tight loop for maximum throughput + // More iterations = more data processed per main loop cycle + constexpr int MAX_ITERATIONS = 500; + for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { webServer->handleClient(); + // Reset watchdog every 32 iterations + if ((i & 0x1F) == 0x1F) { + esp_task_wdt_reset(); + } + // Yield and check for exit button every 64 iterations + if ((i & 0x3F) == 0x3F) { + yield(); + // Check for exit button inside loop for responsiveness + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onGoBack(); + return; + } + } } lastHandleClientTime = millis(); } - // Handle exit on Back button + // Handle exit on Back button (also check outside loop) if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onGoBack(); return; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index b394de3f..074cd513 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -170,7 +170,7 @@ void EpubReaderActivity::loop() { return; } - const bool skipChapter = mappedInput.getHeldTime() > skipChapterMs; + const bool skipChapter = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipChapterMs; if (skipChapter) { // We don't want to delete the section mid-render, so grab the semaphore diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 4922a739..fb9058a6 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -2,6 +2,8 @@ #include "Epub.h" #include "EpubReaderActivity.h" +#include "Txt.h" +#include "TxtReaderActivity.h" #include "Xtc.h" #include "XtcReaderActivity.h" #include "activities/util/FullScreenMessageActivity.h" @@ -19,6 +21,12 @@ bool ReaderActivity::isXtcFile(const std::string& path) { return StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtch"); } +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"; +} + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!SdMan.exists(path.c_str())) { Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); @@ -49,6 +57,21 @@ std::unique_ptr ReaderActivity::loadXtc(const std::string& path) { return nullptr; } +std::unique_ptr ReaderActivity::loadTxt(const std::string& path) { + if (!SdMan.exists(path.c_str())) { + Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); + return nullptr; + } + + auto txt = std::unique_ptr(new Txt(path, "/.crosspoint")); + if (txt->load()) { + return txt; + } + + Serial.printf("[%lu] [ ] Failed to load TXT\n", millis()); + return nullptr; +} + void ReaderActivity::onSelectBookFile(const std::string& path) { currentBookPath = path; // Track current book path exitActivity(); @@ -66,6 +89,18 @@ void ReaderActivity::onSelectBookFile(const std::string& path) { delay(2000); goToLibrary(); } + } else if (isTxtFile(path)) { + // Load TXT file + auto txt = loadTxt(path); + if (txt) { + onGoToTxtReader(std::move(txt)); + } else { + exitActivity(); + enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load TXT", + EpdFontFamily::REGULAR, EInkDisplay::HALF_REFRESH)); + delay(2000); + onGoToFileSelection(); + } } else { // Load EPUB file auto epub = loadEpub(path); @@ -103,6 +138,15 @@ void ReaderActivity::onGoToXtcReader(std::unique_ptr xtc) { renderer, mappedInput, std::move(xtc), [this, xtcPath] { goToLibrary(xtcPath); }, [this] { onGoBack(); })); } +void ReaderActivity::onGoToTxtReader(std::unique_ptr txt) { + const auto txtPath = txt->getPath(); + currentBookPath = txtPath; + exitActivity(); + enterNewActivity(new TxtReaderActivity( + renderer, mappedInput, std::move(txt), [this, txtPath] { onGoToFileSelection(txtPath); }, + [this] { onGoBack(); })); +} + void ReaderActivity::onEnter() { ActivityWithSubactivity::onEnter(); @@ -120,6 +164,13 @@ void ReaderActivity::onEnter() { return; } onGoToXtcReader(std::move(xtc)); + } else if (isTxtFile(initialBookPath)) { + auto txt = loadTxt(initialBookPath); + if (!txt) { + onGoBack(); + return; + } + onGoToTxtReader(std::move(txt)); } else { auto epub = loadEpub(initialBookPath); if (!epub) { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 2aa557ba..1ecd15b4 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -5,6 +5,7 @@ class Epub; class Xtc; +class Txt; class ReaderActivity final : public ActivityWithSubactivity { std::string initialBookPath; @@ -13,13 +14,16 @@ class ReaderActivity final : public ActivityWithSubactivity { const std::function onGoToLibrary; static std::unique_ptr loadEpub(const std::string& path); static std::unique_ptr loadXtc(const std::string& path); + static std::unique_ptr loadTxt(const std::string& path); static bool isXtcFile(const std::string& path); + static bool isTxtFile(const std::string& path); static std::string extractFolderPath(const std::string& filePath); void onSelectBookFile(const std::string& path); void goToLibrary(const std::string& fromBookPath = ""); void onGoToEpubReader(std::unique_ptr epub); void onGoToXtcReader(std::unique_ptr xtc); + void onGoToTxtReader(std::unique_ptr txt); public: explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath, diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp new file mode 100644 index 00000000..db725320 --- /dev/null +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -0,0 +1,700 @@ +#include "TxtReaderActivity.h" + +#include +#include +#include +#include + +#include "CrossPointSettings.h" +#include "CrossPointState.h" +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "fontIds.h" + +namespace { +constexpr unsigned long goHomeMs = 1000; +constexpr int statusBarMargin = 25; +constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading + +// Cache file magic and version +constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" +constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes +} // namespace + +void TxtReaderActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void TxtReaderActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + if (!txt) { + return; + } + + // Configure screen orientation based on settings + switch (SETTINGS.orientation) { + case CrossPointSettings::ORIENTATION::PORTRAIT: + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + break; + case CrossPointSettings::ORIENTATION::LANDSCAPE_CW: + renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise); + break; + case CrossPointSettings::ORIENTATION::INVERTED: + renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted); + break; + case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW: + renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise); + break; + default: + break; + } + + renderingMutex = xSemaphoreCreateMutex(); + + txt->setupCacheDir(); + + // Save current txt as last opened file + APP_STATE.openEpubPath = txt->getPath(); + APP_STATE.saveToFile(); + + // Trigger first update + updateRequired = true; + + xTaskCreate(&TxtReaderActivity::taskTrampoline, "TxtReaderActivityTask", + 6144, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void TxtReaderActivity::onExit() { + ActivityWithSubactivity::onExit(); + + // Reset orientation back to portrait for the rest of the UI + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + + // Wait until not rendering to delete task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + pageOffsets.clear(); + currentPageLines.clear(); + txt.reset(); +} + +void TxtReaderActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + // Long press BACK (1s+) goes directly to home + if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { + onGoHome(); + return; + } + + // Short press BACK goes to file selection + if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { + onGoBack(); + 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); + + if (!prevReleased && !nextReleased) { + return; + } + + if (prevReleased && currentPage > 0) { + currentPage--; + updateRequired = true; + } else if (nextReleased && currentPage < totalPages - 1) { + currentPage++; + updateRequired = true; + } +} + +void TxtReaderActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + renderScreen(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void TxtReaderActivity::initializeReader() { + if (initialized) { + return; + } + + // Store current settings for cache validation + cachedFontId = SETTINGS.getReaderFontId(); + cachedScreenMargin = SETTINGS.screenMargin; + cachedParagraphAlignment = SETTINGS.paragraphAlignment; + + // Calculate viewport dimensions + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginTop += cachedScreenMargin; + orientedMarginLeft += cachedScreenMargin; + orientedMarginRight += cachedScreenMargin; + orientedMarginBottom += statusBarMargin; + + viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; + const int viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; + const int lineHeight = renderer.getLineHeight(cachedFontId); + + linesPerPage = viewportHeight / lineHeight; + if (linesPerPage < 1) linesPerPage = 1; + + Serial.printf("[%lu] [TRS] Viewport: %dx%d, lines per page: %d\n", millis(), viewportWidth, viewportHeight, + linesPerPage); + + // Try to load cached page index first + if (!loadPageIndexCache()) { + // Cache not found, build page index + buildPageIndex(); + // Save to cache for next time + savePageIndexCache(); + } + + // Load saved progress + loadProgress(); + + initialized = true; +} + +void TxtReaderActivity::buildPageIndex() { + pageOffsets.clear(); + pageOffsets.push_back(0); // First page starts at offset 0 + + size_t offset = 0; + const size_t fileSize = txt->getFileSize(); + int lastProgressPercent = -1; + + Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); + + // Progress bar dimensions (matching EpubReaderActivity style) + constexpr int barWidth = 200; + constexpr int barHeight = 10; + constexpr int boxMargin = 20; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing..."); + const int boxWidth = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2; + const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3; + const int boxX = (renderer.getScreenWidth() - boxWidth) / 2; + constexpr int boxY = 50; + const int barX = boxX + (boxWidth - barWidth) / 2; + const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; + + // Draw initial progress box + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Indexing..."); + renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); + renderer.drawRect(barX, barY, barWidth, barHeight); + renderer.displayBuffer(); + + while (offset < fileSize) { + std::vector tempLines; + size_t nextOffset = offset; + + if (!loadPageAtOffset(offset, tempLines, nextOffset)) { + break; + } + + if (nextOffset <= offset) { + // No progress made, avoid infinite loop + break; + } + + offset = nextOffset; + if (offset < fileSize) { + pageOffsets.push_back(offset); + } + + // Update progress bar every 10% (matching EpubReaderActivity logic) + int progressPercent = (offset * 100) / fileSize; + if (lastProgressPercent / 10 != progressPercent / 10) { + lastProgressPercent = progressPercent; + + // Fill progress bar + const int fillWidth = (barWidth - 2) * progressPercent / 100; + renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + } + + // Yield to other tasks periodically + if (pageOffsets.size() % 20 == 0) { + vTaskDelay(1); + } + } + + totalPages = pageOffsets.size(); + Serial.printf("[%lu] [TRS] Built page index: %d pages\n", millis(), totalPages); +} + +bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { + outLines.clear(); + const size_t fileSize = txt->getFileSize(); + + if (offset >= fileSize) { + return false; + } + + // Read a chunk from file + size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset); + auto* buffer = static_cast(malloc(chunkSize + 1)); + if (!buffer) { + Serial.printf("[%lu] [TRS] Failed to allocate %zu bytes\n", millis(), chunkSize); + return false; + } + + if (!txt->readContent(buffer, offset, chunkSize)) { + free(buffer); + return false; + } + buffer[chunkSize] = '\0'; + + // Parse lines from buffer + size_t pos = 0; + + while (pos < chunkSize && static_cast(outLines.size()) < linesPerPage) { + // Find end of line + size_t lineEnd = pos; + while (lineEnd < chunkSize && buffer[lineEnd] != '\n') { + lineEnd++; + } + + // Check if we have a complete line + bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize); + + if (!lineComplete && static_cast(outLines.size()) > 0) { + // Incomplete line and we already have some lines, stop here + break; + } + + // Calculate the actual length of line content in the buffer (excluding newline) + size_t lineContentLen = lineEnd - pos; + + // Check for carriage return + bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r'); + size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen; + + // Extract line content for display (without CR/LF) + std::string line(reinterpret_cast(buffer + pos), displayLen); + + // Track position within this source line (in bytes from pos) + size_t lineBytePos = 0; + + // Word wrap if needed + while (!line.empty() && static_cast(outLines.size()) < linesPerPage) { + int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + + if (lineWidth <= viewportWidth) { + outLines.push_back(line); + lineBytePos = displayLen; // Consumed entire display content + line.clear(); + break; + } + + // Find break point + size_t breakPos = line.length(); + while (breakPos > 0 && renderer.getTextWidth(cachedFontId, line.substr(0, breakPos).c_str()) > viewportWidth) { + // Try to break at space + size_t spacePos = line.rfind(' ', breakPos - 1); + if (spacePos != std::string::npos && spacePos > 0) { + breakPos = spacePos; + } else { + // Break at character boundary for UTF-8 + breakPos--; + // Make sure we don't break in the middle of a UTF-8 sequence + while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) { + breakPos--; + } + } + } + + if (breakPos == 0) { + breakPos = 1; + } + + outLines.push_back(line.substr(0, breakPos)); + + // Skip space at break point + size_t skipChars = breakPos; + if (breakPos < line.length() && line[breakPos] == ' ') { + skipChars++; + } + lineBytePos += skipChars; + line = line.substr(skipChars); + } + + // Determine how much of the source buffer we consumed + if (line.empty()) { + // Fully consumed this source line, move past the newline + pos = lineEnd + 1; + } else { + // Partially consumed - page is full mid-line + // Move pos to where we stopped in the line (NOT past the line) + pos = pos + lineBytePos; + break; + } + } + + // Ensure we make progress even if calculations go wrong + if (pos == 0 && !outLines.empty()) { + // Fallback: at minimum, consume something to avoid infinite loop + pos = 1; + } + + nextOffset = offset + pos; + + // Make sure we don't go past the file + if (nextOffset > fileSize) { + nextOffset = fileSize; + } + + free(buffer); + + return !outLines.empty(); +} + +void TxtReaderActivity::renderScreen() { + if (!txt) { + return; + } + + // Initialize reader if not done + if (!initialized) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + initializeReader(); + } + + if (pageOffsets.empty()) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty file", true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + // Bounds check + if (currentPage < 0) currentPage = 0; + if (currentPage >= totalPages) currentPage = totalPages - 1; + + // Load current page content + size_t offset = pageOffsets[currentPage]; + size_t nextOffset; + currentPageLines.clear(); + loadPageAtOffset(offset, currentPageLines, nextOffset); + + renderer.clearScreen(); + renderPage(); + + // Save progress + saveProgress(); +} + +void TxtReaderActivity::renderPage() { + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginTop += cachedScreenMargin; + orientedMarginLeft += cachedScreenMargin; + orientedMarginRight += cachedScreenMargin; + orientedMarginBottom += statusBarMargin; + + const int lineHeight = renderer.getLineHeight(cachedFontId); + const int contentWidth = viewportWidth; + + // Render text lines with alignment + auto renderLines = [&]() { + int y = orientedMarginTop; + for (const auto& line : currentPageLines) { + if (!line.empty()) { + int x = orientedMarginLeft; + + // Apply text alignment + switch (cachedParagraphAlignment) { + case CrossPointSettings::LEFT_ALIGN: + default: + // x already set to left margin + break; + case CrossPointSettings::CENTER_ALIGN: { + int textWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + x = orientedMarginLeft + (contentWidth - textWidth) / 2; + break; + } + case CrossPointSettings::RIGHT_ALIGN: { + int textWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + x = orientedMarginLeft + contentWidth - textWidth; + break; + } + case CrossPointSettings::JUSTIFIED: + // For plain text, justified is treated as left-aligned + // (true justification would require word spacing adjustments) + break; + } + + renderer.drawText(cachedFontId, x, y, line.c_str()); + } + y += lineHeight; + } + }; + + // First pass: BW rendering + renderLines(); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); + + if (pagesUntilFullRefresh <= 1) { + renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); + } else { + renderer.displayBuffer(); + pagesUntilFullRefresh--; + } + + // Grayscale rendering pass (for anti-aliased fonts) + if (SETTINGS.textAntiAliasing) { + // Save BW buffer for restoration after grayscale pass + renderer.storeBwBuffer(); + + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + renderLines(); + renderer.copyGrayscaleLsbBuffers(); + + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + renderLines(); + renderer.copyGrayscaleMsbBuffers(); + + renderer.displayGrayBuffer(); + renderer.setRenderMode(GfxRenderer::BW); + + // Restore BW buffer + renderer.restoreBwBuffer(); + } +} + +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 showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + + 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()); + renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, + progressStr.c_str()); + } + + if (showBattery) { + ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY); + } + + if (showTitle) { + const int titleMarginLeft = 50 + 30 + orientedMarginLeft; + const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight; + const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight; + + std::string title = txt->getTitle(); + int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + while (titleWidth > availableTextWidth && title.length() > 11) { + title.replace(title.length() - 8, 8, "..."); + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + } + + renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); + } +} + +void TxtReaderActivity::saveProgress() const { + FsFile f; + if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentPage & 0xFF; + data[1] = (currentPage >> 8) & 0xFF; + data[2] = 0; + data[3] = 0; + f.write(data, 4); + f.close(); + } +} + +void TxtReaderActivity::loadProgress() { + FsFile f; + if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + currentPage = data[0] + (data[1] << 8); + if (currentPage >= totalPages) { + currentPage = totalPages - 1; + } + if (currentPage < 0) { + currentPage = 0; + } + Serial.printf("[%lu] [TRS] Loaded progress: page %d/%d\n", millis(), currentPage, totalPages); + } + f.close(); + } +} + +bool TxtReaderActivity::loadPageIndexCache() { + // Cache file format (using serialization module): + // - uint32_t: magic "TXTI" + // - uint8_t: cache version + // - uint32_t: file size (to validate cache) + // - int32_t: viewport width + // - int32_t: lines per page + // - int32_t: font ID (to invalidate cache on font change) + // - int32_t: screen margin (to invalidate cache on margin change) + // - uint8_t: paragraph alignment (to invalidate cache on alignment change) + // - uint32_t: total pages count + // - N * uint32_t: page offsets + + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!SdMan.openFileForRead("TRS", cachePath, f)) { + Serial.printf("[%lu] [TRS] No page index cache found\n", millis()); + return false; + } + + // Read and validate header using serialization module + uint32_t magic; + serialization::readPod(f, magic); + if (magic != CACHE_MAGIC) { + Serial.printf("[%lu] [TRS] Cache magic mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + uint8_t version; + serialization::readPod(f, version); + if (version != CACHE_VERSION) { + Serial.printf("[%lu] [TRS] Cache version mismatch (%d != %d), rebuilding\n", millis(), version, CACHE_VERSION); + f.close(); + return false; + } + + uint32_t fileSize; + serialization::readPod(f, fileSize); + if (fileSize != txt->getFileSize()) { + Serial.printf("[%lu] [TRS] Cache file size mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + int32_t cachedWidth; + serialization::readPod(f, cachedWidth); + if (cachedWidth != viewportWidth) { + Serial.printf("[%lu] [TRS] Cache viewport width mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + int32_t cachedLines; + serialization::readPod(f, cachedLines); + if (cachedLines != linesPerPage) { + Serial.printf("[%lu] [TRS] Cache lines per page mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + int32_t fontId; + serialization::readPod(f, fontId); + if (fontId != cachedFontId) { + Serial.printf("[%lu] [TRS] Cache font ID mismatch (%d != %d), rebuilding\n", millis(), fontId, cachedFontId); + f.close(); + return false; + } + + int32_t margin; + serialization::readPod(f, margin); + if (margin != cachedScreenMargin) { + Serial.printf("[%lu] [TRS] Cache screen margin mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + uint8_t alignment; + serialization::readPod(f, alignment); + if (alignment != cachedParagraphAlignment) { + Serial.printf("[%lu] [TRS] Cache paragraph alignment mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + uint32_t numPages; + serialization::readPod(f, numPages); + + // Read page offsets + pageOffsets.clear(); + pageOffsets.reserve(numPages); + + for (uint32_t i = 0; i < numPages; i++) { + uint32_t offset; + serialization::readPod(f, offset); + pageOffsets.push_back(offset); + } + + f.close(); + totalPages = pageOffsets.size(); + Serial.printf("[%lu] [TRS] Loaded page index cache: %d pages\n", millis(), totalPages); + return true; +} + +void TxtReaderActivity::savePageIndexCache() const { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!SdMan.openFileForWrite("TRS", cachePath, f)) { + Serial.printf("[%lu] [TRS] Failed to save page index cache\n", millis()); + return; + } + + // Write header using serialization module + serialization::writePod(f, CACHE_MAGIC); + serialization::writePod(f, CACHE_VERSION); + serialization::writePod(f, static_cast(txt->getFileSize())); + serialization::writePod(f, static_cast(viewportWidth)); + serialization::writePod(f, static_cast(linesPerPage)); + serialization::writePod(f, static_cast(cachedFontId)); + serialization::writePod(f, static_cast(cachedScreenMargin)); + serialization::writePod(f, cachedParagraphAlignment); + serialization::writePod(f, static_cast(pageOffsets.size())); + + // Write page offsets + for (size_t offset : pageOffsets) { + serialization::writePod(f, static_cast(offset)); + } + + f.close(); + Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages); +} diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h new file mode 100644 index 00000000..41ccbfbb --- /dev/null +++ b/src/activities/reader/TxtReaderActivity.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "activities/ActivityWithSubactivity.h" + +class TxtReaderActivity final : public ActivityWithSubactivity { + std::unique_ptr txt; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int currentPage = 0; + int totalPages = 1; + int pagesUntilFullRefresh = 0; + bool updateRequired = false; + const std::function onGoBack; + const std::function onGoHome; + + // Streaming text reader - stores file offsets for each page + std::vector pageOffsets; // File offset for start of each page + std::vector currentPageLines; + int linesPerPage = 0; + int viewportWidth = 0; + bool initialized = false; + + // Cached settings for cache validation (different fonts/margins require re-indexing) + int cachedFontId = 0; + int cachedScreenMargin = 0; + uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void renderScreen(); + void renderPage(); + void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; + + void initializeReader(); + bool loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset); + void buildPageIndex(); + bool loadPageIndexCache(); + void savePageIndexCache() const; + void saveProgress() const; + void loadProgress(); + + public: + explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt, + const std::function& onGoBack, const std::function& onGoHome) + : ActivityWithSubactivity("TxtReader", renderer, mappedInput), + txt(std::move(txt)), + onGoBack(onGoBack), + onGoHome(onGoHome) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index f22850a9..efa0b9e1 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -13,7 +13,7 @@ // Define the static settings list namespace { -constexpr int settingsCount = 19; +constexpr int settingsCount = 20; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), @@ -29,6 +29,7 @@ const SettingInfo settingsList[settingsCount] = { {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}), SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, {"Prev, Next", "Next, Prev"}), + SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), SettingInfo::Enum("Reader Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}), SettingInfo::Enum("Reader Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}), diff --git a/src/main.cpp b/src/main.cpp index 05e41230..e315fad7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -45,18 +45,19 @@ GfxRenderer renderer(einkDisplay); Activity* currentActivity; // Fonts -EpdFont bookerly12RegularFont(&bookerly_12_regular); -EpdFont bookerly12BoldFont(&bookerly_12_bold); -EpdFont bookerly12ItalicFont(&bookerly_12_italic); -EpdFont bookerly12BoldItalicFont(&bookerly_12_bolditalic); -EpdFontFamily bookerly12FontFamily(&bookerly12RegularFont, &bookerly12BoldFont, &bookerly12ItalicFont, - &bookerly12BoldItalicFont); EpdFont bookerly14RegularFont(&bookerly_14_regular); EpdFont bookerly14BoldFont(&bookerly_14_bold); EpdFont bookerly14ItalicFont(&bookerly_14_italic); EpdFont bookerly14BoldItalicFont(&bookerly_14_bolditalic); EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont, &bookerly14ItalicFont, &bookerly14BoldItalicFont); +#ifndef OMIT_FONTS +EpdFont bookerly12RegularFont(&bookerly_12_regular); +EpdFont bookerly12BoldFont(&bookerly_12_bold); +EpdFont bookerly12ItalicFont(&bookerly_12_italic); +EpdFont bookerly12BoldItalicFont(&bookerly_12_bolditalic); +EpdFontFamily bookerly12FontFamily(&bookerly12RegularFont, &bookerly12BoldFont, &bookerly12ItalicFont, + &bookerly12BoldItalicFont); EpdFont bookerly16RegularFont(&bookerly_16_regular); EpdFont bookerly16BoldFont(&bookerly_16_bold); EpdFont bookerly16ItalicFont(&bookerly_16_italic); @@ -119,6 +120,7 @@ EpdFont opendyslexic14ItalicFont(&opendyslexic_14_italic); EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic); EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont, &opendyslexic14BoldItalicFont); +#endif // OMIT_FONTS EpdFont smallFont(¬osans_8_regular); EpdFontFamily smallFontFamily(&smallFont); @@ -252,10 +254,12 @@ void onGoHome() { void setupDisplayAndFonts() { einkDisplay.begin(); Serial.printf("[%lu] [ ] Display initialized\n", millis()); - renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily); renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily); +#ifndef OMIT_FONTS + renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily); renderer.insertFont(BOOKERLY_16_FONT_ID, bookerly16FontFamily); renderer.insertFont(BOOKERLY_18_FONT_ID, bookerly18FontFamily); + renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily); renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily); renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily); @@ -264,6 +268,7 @@ void setupDisplayAndFonts() { renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, opendyslexic10FontFamily); renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, opendyslexic12FontFamily); renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, opendyslexic14FontFamily); +#endif // OMIT_FONTS renderer.insertFont(UI_10_FONT_ID, ui10FontFamily); renderer.insertFont(UI_12_FONT_ID, ui12FontFamily); renderer.insertFont(SMALL_FONT_ID, smallFontFamily); @@ -318,6 +323,7 @@ void setup() { // Clear app state to avoid getting into a boot loop if the epub doesn't load const auto path = APP_STATE.openEpubPath; APP_STATE.openEpubPath = ""; + APP_STATE.lastSleepImage = 0; APP_STATE.saveToFile(); onGoToReader(path); } diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 8703c2ae..23ba36ba 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -15,6 +16,18 @@ 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]); + +// Static pointer for WebSocket callback (WebSocketsServer requires C-style callback) +CrossPointWebServer* wsInstance = nullptr; + +// WebSocket upload state +FsFile wsUploadFile; +String wsUploadFileName; +String wsUploadPath; +size_t wsUploadSize = 0; +size_t wsUploadReceived = 0; +unsigned long wsUploadStartTime = 0; +bool wsUploadInProgress = false; } // namespace // File listing page template - now using generated headers: @@ -86,12 +99,22 @@ void CrossPointWebServer::begin() { Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); server->begin(); + + // Start WebSocket server for fast binary uploads + Serial.printf("[%lu] [WEB] Starting WebSocket server on port %d...\n", millis(), wsPort); + wsServer.reset(new WebSocketsServer(wsPort)); + wsInstance = const_cast(this); + wsServer->begin(); + wsServer->onEvent(wsEventCallback); + Serial.printf("[%lu] [WEB] WebSocket server started\n", millis()); + running = true; Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); // Show the correct IP based on network mode const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str()); + Serial.printf("[%lu] [WEB] WebSocket at ws://%s:%d/\n", millis(), ipAddr.c_str(), wsPort); Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); } @@ -107,16 +130,29 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap()); - // Add delay to allow any in-flight handleClient() calls to complete - delay(100); - Serial.printf("[%lu] [WEB] Waited 100ms for handleClient to finish\n", millis()); + // Close any in-progress WebSocket upload + if (wsUploadInProgress && wsUploadFile) { + wsUploadFile.close(); + wsUploadInProgress = false; + } + + // Stop WebSocket server + if (wsServer) { + Serial.printf("[%lu] [WEB] Stopping WebSocket server...\n", millis()); + wsServer->close(); + wsServer.reset(); + wsInstance = nullptr; + Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis()); + } + + // Brief delay to allow any in-flight handleClient() calls to complete + delay(20); server->stop(); Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap()); - // Add another delay before deletion to ensure server->stop() completes - delay(50); - Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis()); + // Brief delay before deletion + delay(10); server.reset(); Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis()); @@ -148,6 +184,11 @@ void CrossPointWebServer::handleClient() const { } server->handleClient(); + + // Handle WebSocket events + if (wsServer) { + wsServer->loop(); + } } void CrossPointWebServer::handleRoot() const { @@ -229,7 +270,8 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function 0 && uploadFile) { + esp_task_wdt_reset(); // Reset watchdog before potentially slow SD write + const unsigned long writeStart = millis(); + const size_t written = uploadFile.write(uploadBuffer, uploadBufferPos); + totalWriteTime += millis() - writeStart; + writeCount++; + esp_task_wdt_reset(); // Reset watchdog after SD write + + if (written != uploadBufferPos) { + Serial.printf("[%lu] [WEB] [UPLOAD] Buffer flush failed: expected %d, wrote %d\n", millis(), uploadBufferPos, + written); + uploadBufferPos = 0; + return false; + } + uploadBufferPos = 0; + } + return true; +} + void CrossPointWebServer::handleUpload() const { - static unsigned long lastWriteTime = 0; - static unsigned long uploadStartTime = 0; static size_t lastLoggedSize = 0; + // Reset watchdog at start of every upload callback - HTTP parsing can be slow + esp_task_wdt_reset(); + // Safety check: ensure server is still valid if (!running || !server) { Serial.printf("[%lu] [WEB] [UPLOAD] ERROR: handleUpload called but server not running!\n", millis()); @@ -315,13 +390,18 @@ void CrossPointWebServer::handleUpload() const { const HTTPUpload& upload = server->upload(); if (upload.status == UPLOAD_FILE_START) { + // Reset watchdog - this is the critical 1% crash point + esp_task_wdt_reset(); + uploadFileName = upload.filename; uploadSize = 0; uploadSuccess = false; uploadError = ""; uploadStartTime = millis(); - lastWriteTime = millis(); lastLoggedSize = 0; + uploadBufferPos = 0; + totalWriteTime = 0; + writeCount = 0; // Get upload path from query parameter (defaults to root if not specified) // Note: We use query parameter instead of form data because multipart form @@ -348,60 +428,82 @@ void CrossPointWebServer::handleUpload() const { if (!filePath.endsWith("/")) filePath += "/"; filePath += uploadFileName; - // Check if file already exists + // Check if file already exists - SD operations can be slow + esp_task_wdt_reset(); if (SdMan.exists(filePath.c_str())) { Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str()); + esp_task_wdt_reset(); SdMan.remove(filePath.c_str()); } - // Open file for writing + // Open file for writing - this can be slow due to FAT cluster allocation + esp_task_wdt_reset(); if (!SdMan.openFileForWrite("WEB", filePath, uploadFile)) { uploadError = "Failed to create file on SD card"; Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); return; } + esp_task_wdt_reset(); Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str()); } else if (upload.status == UPLOAD_FILE_WRITE) { if (uploadFile && uploadError.isEmpty()) { - const unsigned long writeStartTime = millis(); - const size_t written = uploadFile.write(upload.buf, upload.currentSize); - const unsigned long writeEndTime = millis(); - const unsigned long writeDuration = writeEndTime - writeStartTime; + // Buffer incoming data and flush when buffer is full + // This reduces SD card write operations and improves throughput + const uint8_t* data = upload.buf; + size_t remaining = upload.currentSize; - if (written != upload.currentSize) { - uploadError = "Failed to write to SD card - disk may be full"; - uploadFile.close(); - Serial.printf("[%lu] [WEB] [UPLOAD] WRITE ERROR - expected %d, wrote %d\n", millis(), upload.currentSize, - written); - } else { - uploadSize += written; + while (remaining > 0) { + const size_t space = UPLOAD_BUFFER_SIZE - uploadBufferPos; + const size_t toCopy = (remaining < space) ? remaining : space; - // Log progress every 50KB or if write took >100ms - if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) { - const unsigned long timeSinceStart = millis() - uploadStartTime; - const unsigned long timeSinceLastWrite = millis() - lastWriteTime; - const float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0); + memcpy(uploadBuffer + uploadBufferPos, data, toCopy); + uploadBufferPos += toCopy; + data += toCopy; + remaining -= toCopy; - Serial.printf( - "[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu " - "ms\n", - millis(), uploadSize, uploadSize / 1024.0, kbps, writeDuration, timeSinceLastWrite); - lastLoggedSize = uploadSize; + // Flush buffer when full + if (uploadBufferPos >= UPLOAD_BUFFER_SIZE) { + if (!flushUploadBuffer()) { + uploadError = "Failed to write to SD card - disk may be full"; + uploadFile.close(); + return; + } } - lastWriteTime = millis(); + } + + uploadSize += upload.currentSize; + + // Log progress every 100KB + if (uploadSize - lastLoggedSize >= 102400) { + const unsigned long elapsed = millis() - uploadStartTime; + const float kbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0; + Serial.printf("[%lu] [WEB] [UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes\n", millis(), uploadSize, + uploadSize / 1024.0, kbps, writeCount); + lastLoggedSize = uploadSize; } } } else if (upload.status == UPLOAD_FILE_END) { if (uploadFile) { + // Flush any remaining buffered data + if (!flushUploadBuffer()) { + uploadError = "Failed to write final data to SD card"; + } uploadFile.close(); if (uploadError.isEmpty()) { uploadSuccess = true; - Serial.printf("[%lu] [WEB] Upload complete: %s (%d bytes)\n", millis(), uploadFileName.c_str(), uploadSize); + const unsigned long elapsed = millis() - uploadStartTime; + const float avgKbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0; + const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0; + Serial.printf("[%lu] [WEB] [UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)\n", millis(), + uploadFileName.c_str(), uploadSize, elapsed, avgKbps); + Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(), + writeCount, totalWriteTime, writePercent); } } } else if (upload.status == UPLOAD_FILE_ABORTED) { + uploadBufferPos = 0; // Discard buffered data if (uploadFile) { uploadFile.close(); // Try to delete the incomplete file @@ -555,3 +657,143 @@ void CrossPointWebServer::handleDelete() const { server->send(500, "text/plain", "Failed to delete item"); } } + +// WebSocket callback trampoline +void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { + if (wsInstance) { + wsInstance->onWebSocketEvent(num, type, payload, length); + } +} + +// WebSocket event handler for fast binary uploads +// Protocol: +// 1. Client sends TEXT message: "START:::" +// 2. Client sends BINARY messages with file data chunks +// 3. Server sends TEXT "PROGRESS::" after each chunk +// 4. Server sends TEXT "DONE" or "ERROR:" when complete +void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { + switch (type) { + case WStype_DISCONNECTED: + Serial.printf("[%lu] [WS] Client %u disconnected\n", millis(), num); + // Clean up any in-progress upload + if (wsUploadInProgress && wsUploadFile) { + wsUploadFile.close(); + // Delete incomplete file + String filePath = wsUploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += wsUploadFileName; + SdMan.remove(filePath.c_str()); + Serial.printf("[%lu] [WS] Deleted incomplete upload: %s\n", millis(), filePath.c_str()); + } + wsUploadInProgress = false; + break; + + case WStype_CONNECTED: { + Serial.printf("[%lu] [WS] Client %u connected\n", millis(), num); + break; + } + + case WStype_TEXT: { + // Parse control messages + String msg = String((char*)payload); + Serial.printf("[%lu] [WS] Text from client %u: %s\n", millis(), num, msg.c_str()); + + if (msg.startsWith("START:")) { + // Parse: START::: + int firstColon = msg.indexOf(':', 6); + int secondColon = msg.indexOf(':', firstColon + 1); + + if (firstColon > 0 && secondColon > 0) { + wsUploadFileName = msg.substring(6, firstColon); + wsUploadSize = msg.substring(firstColon + 1, secondColon).toInt(); + wsUploadPath = msg.substring(secondColon + 1); + wsUploadReceived = 0; + wsUploadStartTime = millis(); + + // Ensure path is valid + if (!wsUploadPath.startsWith("/")) wsUploadPath = "/" + wsUploadPath; + if (wsUploadPath.length() > 1 && wsUploadPath.endsWith("/")) { + wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1); + } + + // Build file path + String filePath = wsUploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += wsUploadFileName; + + Serial.printf("[%lu] [WS] Starting upload: %s (%d bytes) to %s\n", millis(), wsUploadFileName.c_str(), + wsUploadSize, filePath.c_str()); + + // Check if file exists and remove it + esp_task_wdt_reset(); + if (SdMan.exists(filePath.c_str())) { + SdMan.remove(filePath.c_str()); + } + + // Open file for writing + esp_task_wdt_reset(); + if (!SdMan.openFileForWrite("WS", filePath, wsUploadFile)) { + wsServer->sendTXT(num, "ERROR:Failed to create file"); + wsUploadInProgress = false; + return; + } + esp_task_wdt_reset(); + + wsUploadInProgress = true; + wsServer->sendTXT(num, "READY"); + } else { + wsServer->sendTXT(num, "ERROR:Invalid START format"); + } + } + break; + } + + case WStype_BIN: { + if (!wsUploadInProgress || !wsUploadFile) { + wsServer->sendTXT(num, "ERROR:No upload in progress"); + return; + } + + // Write binary data directly to file + esp_task_wdt_reset(); + size_t written = wsUploadFile.write(payload, length); + esp_task_wdt_reset(); + + if (written != length) { + wsUploadFile.close(); + wsUploadInProgress = false; + wsServer->sendTXT(num, "ERROR:Write failed - disk full?"); + return; + } + + wsUploadReceived += written; + + // Send progress update (every 64KB or at end) + static size_t lastProgressSent = 0; + if (wsUploadReceived - lastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) { + String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize); + wsServer->sendTXT(num, progress); + lastProgressSent = wsUploadReceived; + } + + // Check if upload complete + if (wsUploadReceived >= wsUploadSize) { + wsUploadFile.close(); + wsUploadInProgress = false; + + unsigned long elapsed = millis() - wsUploadStartTime; + float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0; + + Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), + wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); + + wsServer->sendTXT(num, "DONE"); + lastProgressSent = 0; + } + break; + } + + default: + break; + } +} diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 1be07b4a..ecc2d3d2 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include @@ -34,9 +35,15 @@ class CrossPointWebServer { private: std::unique_ptr server = nullptr; + std::unique_ptr wsServer = nullptr; bool running = false; bool apMode = false; // true when running in AP mode, false for STA mode uint16_t port = 80; + uint16_t wsPort = 81; // WebSocket port + + // WebSocket upload state + void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); + static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length); // File scanning void scanFiles(const char* path, const std::function& callback) const; diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index 017c6870..c4de3a05 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -2,13 +2,23 @@ #include #include +#include #include #include +#include "util/UrlUtils.h" + bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { - const std::unique_ptr client(new WiFiClientSecure()); - client->setInsecure(); + // Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP + std::unique_ptr client; + if (UrlUtils::isHttpsUrl(url)) { + auto* secureClient = new WiFiClientSecure(); + secureClient->setInsecure(); + client.reset(secureClient); + } else { + client.reset(new WiFiClient()); + } HTTPClient http; Serial.printf("[%lu] [HTTP] Fetching: %s\n", millis(), url.c_str()); @@ -33,8 +43,15 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath, ProgressCallback progress) { - const std::unique_ptr client(new WiFiClientSecure()); - client->setInsecure(); + // Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP + std::unique_ptr client; + if (UrlUtils::isHttpsUrl(url)) { + auto* secureClient = new WiFiClientSecure(); + secureClient->setInsecure(); + client.reset(secureClient); + } else { + client.reset(new WiFiClient()); + } HTTPClient http; Serial.printf("[%lu] [HTTP] Downloading: %s\n", millis(), url.c_str()); diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index 4afd9de4..d831af0a 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -5,7 +5,7 @@ #include namespace { -constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest"; +constexpr char latestReleaseUrl[] = "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/latest"; } OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 08c0a0be..bfdbe3cc 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -816,6 +816,151 @@ } let failedUploadsGlobal = []; +let wsConnection = null; +const WS_PORT = 81; +const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability + +// Get WebSocket URL based on current page location +function getWsUrl() { + const host = window.location.hostname; + return `ws://${host}:${WS_PORT}/`; +} + +// Upload file via WebSocket (faster, binary protocol) +function uploadFileWebSocket(file, onProgress, onComplete, onError) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(getWsUrl()); + let uploadStarted = false; + let sendingChunks = false; + + ws.binaryType = 'arraybuffer'; + + ws.onopen = function() { + console.log('[WS] Connected, starting upload:', file.name); + // Send start message: START::: + ws.send(`START:${file.name}:${file.size}:${currentPath}`); + }; + + ws.onmessage = async function(event) { + const msg = event.data; + console.log('[WS] Message:', msg); + + if (msg === 'READY') { + uploadStarted = true; + sendingChunks = true; + + // Small delay to let connection stabilize + await new Promise(r => setTimeout(r, 50)); + + try { + // Send file in chunks + const totalSize = file.size; + let offset = 0; + + while (offset < totalSize && ws.readyState === WebSocket.OPEN) { + const chunkSize = Math.min(WS_CHUNK_SIZE, totalSize - offset); + const chunk = file.slice(offset, offset + chunkSize); + const buffer = await chunk.arrayBuffer(); + + // Wait for buffer to clear - more aggressive backpressure + while (ws.bufferedAmount > WS_CHUNK_SIZE * 2 && ws.readyState === WebSocket.OPEN) { + await new Promise(r => setTimeout(r, 5)); + } + + if (ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket closed during upload'); + } + + ws.send(buffer); + offset += chunkSize; + + // Update local progress - cap at 95% since server still needs to write + // Final 100% shown when server confirms DONE + if (onProgress) { + const cappedOffset = Math.min(offset, Math.floor(totalSize * 0.95)); + onProgress(cappedOffset, totalSize); + } + } + + sendingChunks = false; + console.log('[WS] All chunks sent, waiting for DONE'); + } catch (err) { + console.error('[WS] Error sending chunks:', err); + sendingChunks = false; + ws.close(); + reject(err); + } + } else if (msg.startsWith('PROGRESS:')) { + // Server confirmed progress - log for debugging but don't update UI + // (local progress is smoother, server progress causes jumping) + console.log('[WS] Server progress:', msg); + } else if (msg === 'DONE') { + // Show 100% when server confirms completion + if (onProgress) onProgress(file.size, file.size); + ws.close(); + if (onComplete) onComplete(); + resolve(); + } else if (msg.startsWith('ERROR:')) { + const error = msg.substring(6); + ws.close(); + if (onError) onError(error); + reject(new Error(error)); + } + }; + + ws.onerror = function(event) { + console.error('[WS] Error:', event); + if (!uploadStarted) { + reject(new Error('WebSocket connection failed')); + } else if (!sendingChunks) { + reject(new Error('WebSocket error during upload')); + } + }; + + ws.onclose = function(event) { + console.log('[WS] Connection closed, code:', event.code, 'reason:', event.reason); + if (sendingChunks) { + reject(new Error('WebSocket closed unexpectedly')); + } + }; + }); +} + +// Upload file via HTTP (fallback method) +function uploadFileHTTP(file, onProgress, onComplete, onError) { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('file', file); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); + + xhr.upload.onprogress = function(e) { + if (e.lengthComputable && onProgress) { + onProgress(e.loaded, e.total); + } + }; + + xhr.onload = function() { + if (xhr.status === 200) { + if (onComplete) onComplete(); + resolve(); + } else { + const error = xhr.responseText || 'Upload failed'; + if (onError) onError(error); + reject(new Error(error)); + } + }; + + xhr.onerror = function() { + const error = 'Network error'; + if (onError) onError(error); + reject(new Error(error)); + }; + + xhr.send(formData); + }); +} function uploadFile() { const fileInput = document.getElementById('fileInput'); @@ -836,8 +981,9 @@ function uploadFile() { let currentIndex = 0; const failedFiles = []; + let useWebSocket = true; // Try WebSocket first - function uploadNextFile() { + async function uploadNextFile() { if (currentIndex >= files.length) { // All files processed - show summary if (failedFiles.length === 0) { @@ -845,67 +991,71 @@ function uploadFile() { progressText.textContent = 'All uploads complete!'; setTimeout(() => { closeUploadModal(); - hydrate(); // Refresh file list instead of reloading + hydrate(); }, 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 + hydrate(); }, 2000); } return; } const file = files[currentIndex]; - const formData = new FormData(); - formData.append('file', file); - - 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); - progressFill.style.width = '0%'; - progressFill.style.backgroundColor = '#4caf50'; - progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`; + progressFill.style.backgroundColor = '#27ae60'; + const methodText = useWebSocket ? ' [WS]' : ' [HTTP]'; + progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText}`; - xhr.upload.onprogress = function (e) { - if (e.lengthComputable) { - const percent = Math.round((e.loaded / e.total) * 100); - progressFill.style.width = percent + '%'; - progressText.textContent = - `Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`; - } + const onProgress = (loaded, total) => { + const percent = Math.round((loaded / total) * 100); + progressFill.style.width = percent + '%'; + const speed = ''; // Could calculate speed here + progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${percent}%`; }; - xhr.onload = function () { - if (xhr.status === 200) { - currentIndex++; - uploadNextFile(); // upload next file - } else { - // Track failure and continue with next file - failedFiles.push({ name: file.name, error: xhr.responseText, file: file }); - currentIndex++; - uploadNextFile(); - } - }; - - xhr.onerror = function () { - // Track network error and continue with next file - failedFiles.push({ name: file.name, error: 'network error', file: file }); + const onComplete = () => { currentIndex++; uploadNextFile(); }; - xhr.send(formData); + const onError = (error) => { + failedFiles.push({ name: file.name, error: error, file: file }); + currentIndex++; + uploadNextFile(); + }; + + try { + if (useWebSocket) { + await uploadFileWebSocket(file, onProgress, null, null); + onComplete(); + } else { + await uploadFileHTTP(file, onProgress, null, null); + onComplete(); + } + } catch (error) { + console.error('Upload error:', error); + if (useWebSocket && error.message === 'WebSocket connection failed') { + // Fall back to HTTP for all subsequent uploads + console.log('WebSocket failed, falling back to HTTP'); + useWebSocket = false; + // Retry this file with HTTP + try { + await uploadFileHTTP(file, onProgress, null, null); + onComplete(); + } catch (httpError) { + onError(httpError.message); + } + } else { + onError(error.message); + } + } } uploadNextFile(); diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index e296d378..e56bc9df 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -49,4 +49,23 @@ bool checkFileExtension(const std::string& fileName, const char* extension) { return true; } +size_t utf8RemoveLastChar(std::string& str) { + if (str.empty()) return 0; + size_t pos = str.size() - 1; + // Walk back to find the start of the last UTF-8 character + // UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF) + while (pos > 0 && (static_cast(str[pos]) & 0xC0) == 0x80) { + --pos; + } + str.resize(pos); + return pos; +} + +// Truncate string by removing N UTF-8 characters from the end +void utf8TruncateChars(std::string& str, const size_t numChars) { + for (size_t i = 0; i < numChars && !str.empty(); ++i) { + utf8RemoveLastChar(str); + } +} + } // namespace StringUtils diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h index f846cf17..e001d7b3 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -16,4 +16,10 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); */ bool checkFileExtension(const std::string& fileName, const char* extension); +// UTF-8 safe string truncation - removes one character from the end +// Returns the new size after removing one UTF-8 character +size_t utf8RemoveLastChar(std::string& str); + +// Truncate string by removing N UTF-8 characters from the end +void utf8TruncateChars(std::string& str, size_t numChars); } // namespace StringUtils diff --git a/src/util/UrlUtils.cpp b/src/util/UrlUtils.cpp index 0eeeae3a..bbf5ac15 100644 --- a/src/util/UrlUtils.cpp +++ b/src/util/UrlUtils.cpp @@ -2,6 +2,8 @@ namespace UrlUtils { +bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0; } + std::string ensureProtocol(const std::string& url) { if (url.find("://") == std::string::npos) { return "http://" + url; diff --git a/src/util/UrlUtils.h b/src/util/UrlUtils.h index d88ca13c..6428161b 100644 --- a/src/util/UrlUtils.h +++ b/src/util/UrlUtils.h @@ -3,6 +3,11 @@ namespace UrlUtils { +/** + * Check if URL uses HTTPS protocol + */ +bool isHttpsUrl(const std::string& url); + /** * Prepend http:// if no protocol specified (server will redirect to https if needed) */