From 5c3828efe887f442eb5a26f648101d394e97d6b5 Mon Sep 17 00:00:00 2001 From: cottongin Date: Sat, 24 Jan 2026 02:01:53 -0500 Subject: [PATCH] nice --- lib/Epub/Epub.cpp | 222 +++++++++++++++ lib/Epub/Epub.h | 4 + lib/GfxRenderer/GfxRenderer.cpp | 15 +- lib/GfxRenderer/GfxRenderer.h | 4 +- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 20 +- lib/JpegToBmpConverter/JpegToBmpConverter.h | 10 +- lib/Txt/Txt.cpp | 183 ++++++++++++ lib/Txt/Txt.h | 9 + lib/Xtc/Xtc.cpp | 267 ++++++++++++++++++ lib/Xtc/Xtc.h | 6 + src/activities/home/MyLibraryActivity.cpp | 67 ++++- src/activities/reader/EpubReaderActivity.cpp | 42 +++ src/activities/reader/TxtReaderActivity.cpp | 43 ++- src/activities/reader/XtcReaderActivity.cpp | 40 +++ 14 files changed, 908 insertions(+), 24 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index c583332..7165c90 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -573,6 +573,228 @@ bool Epub::generateThumbBmp() const { return false; } +std::string Epub::getMicroThumbBmpPath() const { return cachePath + "/micro_thumb.bmp"; } + +bool Epub::generateMicroThumbBmp() const { + // Already generated, return true + if (SdMan.exists(getMicroThumbBmpPath().c_str())) { + return true; + } + + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] Cannot generate micro 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 micro thumbnail\n", millis()); + return false; + } + + if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || + coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { + Serial.printf("[%lu] [EBP] Generating micro thumb BMP from JPG cover image\n", millis()); + const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; + + // Check if temp JPEG already exists (from generateAllCovers), otherwise extract it + bool needsCleanup = false; + if (!SdMan.exists(coverJpgTempPath.c_str())) { + FsFile coverJpg; + if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + readItemContentsToStream(coverImageHref, coverJpg, 1024); + coverJpg.close(); + needsCleanup = true; + } + + FsFile coverJpg; + if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + + FsFile microThumbBmp; + if (!SdMan.openFileForWrite("EBP", getMicroThumbBmpPath(), microThumbBmp)) { + coverJpg.close(); + return false; + } + // Use very small target size for Recent Books list (45x60 pixels) + // Generate 1-bit BMP for fast rendering + constexpr int MICRO_THUMB_TARGET_WIDTH = 45; + constexpr int MICRO_THUMB_TARGET_HEIGHT = 60; + const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize( + coverJpg, microThumbBmp, MICRO_THUMB_TARGET_WIDTH, MICRO_THUMB_TARGET_HEIGHT); + coverJpg.close(); + microThumbBmp.close(); + + if (needsCleanup) { + SdMan.remove(coverJpgTempPath.c_str()); + } + + if (!success) { + Serial.printf("[%lu] [EBP] Failed to generate micro thumb BMP from JPG cover image\n", millis()); + SdMan.remove(getMicroThumbBmpPath().c_str()); + } + Serial.printf("[%lu] [EBP] Generated micro 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 micro thumbnail\n", millis()); + } + + return false; +} + +bool Epub::generateAllCovers(const std::function& progressCallback) const { + // Check if all covers already exist - quick exit if nothing to do + const bool hasThumb = SdMan.exists(getThumbBmpPath().c_str()); + const bool hasMicroThumb = SdMan.exists(getMicroThumbBmpPath().c_str()); + const bool hasCoverFit = SdMan.exists(getCoverBmpPath(false).c_str()); + const bool hasCoverCrop = SdMan.exists(getCoverBmpPath(true).c_str()); + + if (hasThumb && hasMicroThumb && hasCoverFit && hasCoverCrop) { + Serial.printf("[%lu] [EBP] All covers already cached\n", millis()); + if (progressCallback) progressCallback(100); + return true; + } + + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] Cannot generate covers, cache not loaded\n", millis()); + return false; + } + + const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + if (coverImageHref.empty()) { + Serial.printf("[%lu] [EBP] No known cover image\n", millis()); + return false; + } + + // Only process JPG/JPEG covers + if (coverImageHref.substr(coverImageHref.length() - 4) != ".jpg" && + coverImageHref.substr(coverImageHref.length() - 5) != ".jpeg") { + Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping all cover generation\n", millis()); + return false; + } + + Serial.printf("[%lu] [EBP] Generating all covers (thumb:%d, micro:%d, fit:%d, crop:%d)\n", millis(), !hasThumb, + !hasMicroThumb, !hasCoverFit, !hasCoverCrop); + + // Extract JPEG once to temp file + const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; + { + FsFile coverJpg; + if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { + Serial.printf("[%lu] [EBP] Failed to create temp cover file\n", millis()); + return false; + } + readItemContentsToStream(coverImageHref, coverJpg, 1024); + coverJpg.close(); + } + + // Get JPEG dimensions once for FIT/CROP calculations + int jpegWidth = 0, jpegHeight = 0; + { + FsFile coverJpg; + if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { + JpegToBmpConverter::getJpegDimensions(coverJpg, jpegWidth, jpegHeight); + coverJpg.close(); + } + } + + // Progress tracking: 4 covers = 25% each + // Helper to create sub-progress callback that maps 0-100% to a portion of overall progress + auto makeSubProgress = [&progressCallback](int startPercent, int endPercent) { + if (!progressCallback) return std::function(nullptr); + return std::function([&progressCallback, startPercent, endPercent](int subPercent) { + const int overallProgress = startPercent + (subPercent * (endPercent - startPercent)) / 100; + progressCallback(overallProgress); + }); + }; + + // Generate thumb (240x400, 1-bit) if missing - progress 0-25% + if (!hasThumb) { + FsFile coverJpg, thumbBmp; + if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) && + SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) { + 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, makeSubProgress(0, 25)); + coverJpg.close(); + thumbBmp.close(); + if (!success) { + SdMan.remove(getThumbBmpPath().c_str()); + } + Serial.printf("[%lu] [EBP] Generated thumb: %s\n", millis(), success ? "yes" : "no"); + } + } + if (progressCallback) progressCallback(25); + + // Generate micro thumb (45x60, 1-bit) if missing - progress 25-50% + if (!hasMicroThumb) { + FsFile coverJpg, microThumbBmp; + if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) && + SdMan.openFileForWrite("EBP", getMicroThumbBmpPath(), microThumbBmp)) { + constexpr int MICRO_THUMB_TARGET_WIDTH = 45; + constexpr int MICRO_THUMB_TARGET_HEIGHT = 60; + const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize( + coverJpg, microThumbBmp, MICRO_THUMB_TARGET_WIDTH, MICRO_THUMB_TARGET_HEIGHT, makeSubProgress(25, 50)); + coverJpg.close(); + microThumbBmp.close(); + if (!success) { + SdMan.remove(getMicroThumbBmpPath().c_str()); + } + Serial.printf("[%lu] [EBP] Generated micro thumb: %s\n", millis(), success ? "yes" : "no"); + } + } + if (progressCallback) progressCallback(50); + + // Generate cover_fit (480xProportional, 2-bit) if missing - progress 50-75% + if (!hasCoverFit && jpegWidth > 0 && jpegHeight > 0) { + FsFile coverJpg, coverBmp; + if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) && + SdMan.openFileForWrite("EBP", getCoverBmpPath(false), coverBmp)) { + const int targetWidth = 480; + const int targetHeight = (480 * jpegHeight) / jpegWidth; + const bool success = + JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth, targetHeight, makeSubProgress(50, 75)); + coverJpg.close(); + coverBmp.close(); + if (!success) { + SdMan.remove(getCoverBmpPath(false).c_str()); + } + Serial.printf("[%lu] [EBP] Generated cover_fit: %s\n", millis(), success ? "yes" : "no"); + } + } + if (progressCallback) progressCallback(75); + + // Generate cover_crop (Proportionalx800, 2-bit) if missing - progress 75-100% + if (!hasCoverCrop && jpegWidth > 0 && jpegHeight > 0) { + FsFile coverJpg, coverBmp; + if (SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg) && + SdMan.openFileForWrite("EBP", getCoverBmpPath(true), coverBmp)) { + const int targetHeight = 800; + const int targetWidth = (800 * jpegWidth) / jpegHeight; + const bool success = + JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth, targetHeight, makeSubProgress(75, 100)); + coverJpg.close(); + coverBmp.close(); + if (!success) { + SdMan.remove(getCoverBmpPath(true).c_str()); + } + Serial.printf("[%lu] [EBP] Generated cover_crop: %s\n", millis(), success ? "yes" : "no"); + } + } + if (progressCallback) progressCallback(100); + + // Clean up temp JPEG + SdMan.remove(coverJpgTempPath.c_str()); + Serial.printf("[%lu] [EBP] All cover generation complete\n", millis()); + + return true; +} + 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 4f948c7..c3854f0 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -53,6 +54,9 @@ class Epub { bool generateCoverBmp(bool cropped = false) const; std::string getThumbBmpPath() const; bool generateThumbBmp() const; + std::string getMicroThumbBmpPath() const; + bool generateMicroThumbBmp() const; + bool generateAllCovers(const std::function& progressCallback = nullptr) 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/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index eb079d7..7cacfb6 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -153,10 +153,10 @@ 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 { + const float cropX, const float cropY, const bool invert) 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); + drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight, invert); return; } @@ -264,7 +264,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con } void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth, - const int maxHeight) const { + const int maxHeight, const bool invert) const { float scale = 1.0f; // Calculate scale to fit within maxWidth/maxHeight (supports both up and down scaling) @@ -324,8 +324,12 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, 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) { + // val < 3 means black pixel, val == 3 means white pixel + // When inverted: draw white pixels as black, skip black pixels + const bool isBlackPixel = (val < 3); + const bool shouldDraw = invert ? !isBlackPixel : isBlackPixel; + + if (shouldDraw) { // Draw to all X positions this source pixel maps to (for upscaling, this fills gaps) for (int screenX = screenXStart; screenX < screenXEnd; screenX++) { if (screenX < 0) continue; @@ -333,7 +337,6 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, drawPixel(screenX, screenY, true); } } - // White pixels (val == 3) are not drawn (leave background) } } } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index f105b56..662a30f 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -67,8 +67,8 @@ class GfxRenderer { void fillRect(int x, int y, int width, int height, bool state = true) const; 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; + float cropY = 0, bool invert = false) const; + void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, bool invert = false) const; void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const; // Text diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 0f5c7be..fea0eaf 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -200,7 +200,7 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un // Internal implementation with configurable target size and bit depth bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, - bool oneBit) { + bool oneBit, const std::function& progressCallback) { Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight); @@ -524,6 +524,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm } } } + + // Report progress after each MCU row + if (progressCallback) { + const int progress = ((mcuY + 1) * 100) / imageInfo.m_MCUSPerCol; + progressCallback(progress); + } } // Clean up @@ -551,19 +557,21 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm // Core function: Convert JPEG file to 2-bit BMP (uses default target size) bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { - return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false); + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, nullptr); } // 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); + int targetMaxHeight, + const std::function& progressCallback) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false, progressCallback); } // 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); + int targetMaxHeight, + const std::function& progressCallback) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true, progressCallback); } // Get JPEG dimensions without full conversion diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index 98fe7b4..eb72dae 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -1,5 +1,7 @@ #pragma once +#include + class FsFile; class Print; class ZipFile; @@ -8,14 +10,16 @@ class JpegToBmpConverter { static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, unsigned char* pBytes_actually_read, void* pCallback_data); static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, - bool oneBit); + bool oneBit, const std::function& progressCallback = nullptr); 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); + static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight, + const std::function& progressCallback = nullptr); // 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); + static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight, + const std::function& progressCallback = nullptr); // Get JPEG dimensions without full conversion static bool getJpegDimensions(FsFile& jpegFile, int& width, int& height); }; diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp index 52c75ed..c0fa8df 100644 --- a/lib/Txt/Txt.cpp +++ b/lib/Txt/Txt.cpp @@ -169,6 +169,189 @@ bool Txt::generateCoverBmp() const { return false; } +std::string Txt::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } + +bool Txt::generateThumbBmp() const { + // Already generated, return true + if (SdMan.exists(getThumbBmpPath().c_str())) { + return true; + } + + std::string coverImagePath = findCoverImage(); + if (coverImagePath.empty()) { + Serial.printf("[%lu] [TXT] No cover image found for thumbnail\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")); + + if (isJpg) { + // Convert JPG to 1-bit BMP thumbnail + Serial.printf("[%lu] [TXT] Generating thumb BMP from JPG cover image\n", millis()); + FsFile coverJpg, thumbBmp; + if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) { + return false; + } + if (!SdMan.openFileForWrite("TXT", getThumbBmpPath(), thumbBmp)) { + coverJpg.close(); + return false; + } + 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(); + + if (!success) { + Serial.printf("[%lu] [TXT] Failed to generate thumb BMP from JPG cover image\n", millis()); + SdMan.remove(getThumbBmpPath().c_str()); + } else { + Serial.printf("[%lu] [TXT] Generated thumb BMP from JPG cover image\n", millis()); + } + return success; + } + + // For BMP files, just copy cover.bmp to thumb.bmp (no scaling for BMP) + if (generateCoverBmp() && SdMan.exists(getCoverBmpPath().c_str())) { + FsFile src, dst; + if (SdMan.openFileForRead("TXT", getCoverBmpPath(), src)) { + if (SdMan.openFileForWrite("TXT", 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] [TXT] Copied cover to thumb\n", millis()); + return SdMan.exists(getThumbBmpPath().c_str()); + } + + return false; +} + +std::string Txt::getMicroThumbBmpPath() const { return cachePath + "/micro_thumb.bmp"; } + +bool Txt::generateMicroThumbBmp() const { + // Already generated, return true + if (SdMan.exists(getMicroThumbBmpPath().c_str())) { + return true; + } + + std::string coverImagePath = findCoverImage(); + if (coverImagePath.empty()) { + Serial.printf("[%lu] [TXT] No cover image found for micro thumbnail\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")); + + if (isJpg) { + // Convert JPG to 1-bit BMP micro thumbnail + Serial.printf("[%lu] [TXT] Generating micro thumb BMP from JPG cover image\n", millis()); + FsFile coverJpg, microThumbBmp; + if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) { + return false; + } + if (!SdMan.openFileForWrite("TXT", getMicroThumbBmpPath(), microThumbBmp)) { + coverJpg.close(); + return false; + } + constexpr int MICRO_THUMB_TARGET_WIDTH = 45; + constexpr int MICRO_THUMB_TARGET_HEIGHT = 60; + const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, microThumbBmp, + MICRO_THUMB_TARGET_WIDTH, MICRO_THUMB_TARGET_HEIGHT); + coverJpg.close(); + microThumbBmp.close(); + + if (!success) { + Serial.printf("[%lu] [TXT] Failed to generate micro thumb BMP from JPG cover image\n", millis()); + SdMan.remove(getMicroThumbBmpPath().c_str()); + } else { + Serial.printf("[%lu] [TXT] Generated micro thumb BMP from JPG cover image\n", millis()); + } + return success; + } + + // For BMP files, just copy cover.bmp to micro_thumb.bmp (no scaling for BMP) + if (generateCoverBmp() && SdMan.exists(getCoverBmpPath().c_str())) { + FsFile src, dst; + if (SdMan.openFileForRead("TXT", getCoverBmpPath(), src)) { + if (SdMan.openFileForWrite("TXT", getMicroThumbBmpPath(), 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] [TXT] Copied cover to micro thumb\n", millis()); + return SdMan.exists(getMicroThumbBmpPath().c_str()); + } + + return false; +} + +bool Txt::generateAllCovers(const std::function& progressCallback) const { + // Check if all covers already exist + const bool hasCover = SdMan.exists(getCoverBmpPath().c_str()); + const bool hasThumb = SdMan.exists(getThumbBmpPath().c_str()); + const bool hasMicroThumb = SdMan.exists(getMicroThumbBmpPath().c_str()); + + if (hasCover && hasThumb && hasMicroThumb) { + Serial.printf("[%lu] [TXT] All covers already cached\n", millis()); + if (progressCallback) progressCallback(100); + return true; + } + + std::string coverImagePath = findCoverImage(); + if (coverImagePath.empty()) { + Serial.printf("[%lu] [TXT] No cover image found, skipping cover generation\n", millis()); + return false; + } + + Serial.printf("[%lu] [TXT] Generating all covers (cover:%d, thumb:%d, micro:%d)\n", millis(), !hasCover, !hasThumb, + !hasMicroThumb); + + // Generate each cover type that's missing with progress updates + if (!hasCover) { + (void)generateCoverBmp(); + } + if (progressCallback) progressCallback(33); + + if (!hasThumb) { + (void)generateThumbBmp(); + } + if (progressCallback) progressCallback(66); + + if (!hasMicroThumb) { + (void)generateMicroThumbBmp(); + } + if (progressCallback) progressCallback(100); + + Serial.printf("[%lu] [TXT] All cover generation complete\n", millis()); + return true; +} + bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const { if (!loaded) { return false; diff --git a/lib/Txt/Txt.h b/lib/Txt/Txt.h index b75c773..fd6ad02 100644 --- a/lib/Txt/Txt.h +++ b/lib/Txt/Txt.h @@ -2,6 +2,7 @@ #include +#include #include #include @@ -27,6 +28,14 @@ class Txt { [[nodiscard]] std::string getCoverBmpPath() const; [[nodiscard]] bool generateCoverBmp() const; [[nodiscard]] std::string findCoverImage() const; + // Thumbnail support (for Continue Reading card) + [[nodiscard]] std::string getThumbBmpPath() const; + [[nodiscard]] bool generateThumbBmp() const; + // Micro thumbnail support (for Recent Books list) + [[nodiscard]] std::string getMicroThumbBmpPath() const; + [[nodiscard]] bool generateMicroThumbBmp() const; + // Generate all covers at once (for pre-generation on book open) + [[nodiscard]] bool generateAllCovers(const std::function& progressCallback = nullptr) 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 c79421d..d05e3f6 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -554,6 +554,273 @@ bool Xtc::generateThumbBmp() const { return true; } +std::string Xtc::getMicroThumbBmpPath() const { return cachePath + "/micro_thumb.bmp"; } + +bool Xtc::generateMicroThumbBmp() const { + // Already generated + if (SdMan.exists(getMicroThumbBmpPath().c_str())) { + return true; + } + + if (!loaded || !parser) { + Serial.printf("[%lu] [XTC] Cannot generate micro 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 micro thumbnail (45x60 for Recent Books list) + constexpr int MICRO_THUMB_TARGET_WIDTH = 45; + constexpr int MICRO_THUMB_TARGET_HEIGHT = 60; + + // Calculate scale factor to fit within target dimensions + float scaleX = static_cast(MICRO_THUMB_TARGET_WIDTH) / pageInfo.width; + float scaleY = static_cast(MICRO_THUMB_TARGET_HEIGHT) / pageInfo.height; + float scale = (scaleX < scaleY) ? scaleX : scaleY; + + uint16_t microThumbWidth = static_cast(pageInfo.width * scale); + uint16_t microThumbHeight = static_cast(pageInfo.height * scale); + + // Ensure minimum size + if (microThumbWidth < 1) microThumbWidth = 1; + if (microThumbHeight < 1) microThumbHeight = 1; + + Serial.printf("[%lu] [XTC] Generating micro thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width, + pageInfo.height, microThumbWidth, microThumbHeight, 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 micro thumb\n", millis()); + free(pageBuffer); + return false; + } + + // Create micro thumbnail BMP file - use 1-bit format + FsFile microThumbBmp; + if (!SdMan.openFileForWrite("XTC", getMicroThumbBmpPath(), microThumbBmp)) { + Serial.printf("[%lu] [XTC] Failed to create micro thumb BMP file\n", millis()); + free(pageBuffer); + return false; + } + + // Write 1-bit BMP header + const uint32_t rowSize = (microThumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes + const uint32_t imageSize = rowSize * microThumbHeight; + const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette + + // File header + microThumbBmp.write('B'); + microThumbBmp.write('M'); + microThumbBmp.write(reinterpret_cast(&fileSize), 4); + uint32_t reserved = 0; + microThumbBmp.write(reinterpret_cast(&reserved), 4); + uint32_t dataOffset = 14 + 40 + 8; + microThumbBmp.write(reinterpret_cast(&dataOffset), 4); + + // DIB header + uint32_t dibHeaderSize = 40; + microThumbBmp.write(reinterpret_cast(&dibHeaderSize), 4); + int32_t widthVal = microThumbWidth; + microThumbBmp.write(reinterpret_cast(&widthVal), 4); + int32_t heightVal = -static_cast(microThumbHeight); // Negative for top-down + microThumbBmp.write(reinterpret_cast(&heightVal), 4); + uint16_t planes = 1; + microThumbBmp.write(reinterpret_cast(&planes), 2); + uint16_t bitsPerPixel = 1; + microThumbBmp.write(reinterpret_cast(&bitsPerPixel), 2); + uint32_t compression = 0; + microThumbBmp.write(reinterpret_cast(&compression), 4); + microThumbBmp.write(reinterpret_cast(&imageSize), 4); + int32_t ppmX = 2835; + microThumbBmp.write(reinterpret_cast(&ppmX), 4); + int32_t ppmY = 2835; + microThumbBmp.write(reinterpret_cast(&ppmY), 4); + uint32_t colorsUsed = 2; + microThumbBmp.write(reinterpret_cast(&colorsUsed), 4); + uint32_t colorsImportant = 2; + microThumbBmp.write(reinterpret_cast(&colorsImportant), 4); + + // Color palette + uint8_t palette[8] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White + }; + microThumbBmp.write(palette, 8); + + // Allocate row buffer + uint8_t* rowBuffer = static_cast(malloc(rowSize)); + if (!rowBuffer) { + free(pageBuffer); + microThumbBmp.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 < microThumbHeight; dstY++) { + memset(rowBuffer, 0xFF, rowSize); // Start with all white + + 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 < microThumbWidth; dstX++) { + 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 + 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; + + if (bitDepth == 2) { + 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; + 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; + grayValue = (3 - pixelValue) * 85; + } + } + } else { + const size_t byteIdx = srcY * srcRowBytes + srcX / 8; + const size_t bitIdx = 7 - (srcX % 8); + if (byteIdx < bitmapSize) { + const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1; + grayValue = pixelBit ? 255 : 0; + } + } + + graySum += grayValue; + totalCount++; + } + } + + uint8_t avgGray = (totalCount > 0) ? static_cast(graySum / totalCount) : 255; + + // Hash-based noise dithering + uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); + const int adjustedThreshold = 128 + ((threshold - 128) / 2); + + uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0; + + const size_t byteIndex = dstX / 8; + const size_t bitOffset = 7 - (dstX % 8); + if (byteIndex < rowSize) { + if (oneBit) { + rowBuffer[byteIndex] |= (1 << bitOffset); + } else { + rowBuffer[byteIndex] &= ~(1 << bitOffset); + } + } + } + + microThumbBmp.write(rowBuffer, rowSize); + } + + free(rowBuffer); + microThumbBmp.close(); + free(pageBuffer); + + Serial.printf("[%lu] [XTC] Generated micro thumb BMP (%dx%d): %s\n", millis(), microThumbWidth, microThumbHeight, + getMicroThumbBmpPath().c_str()); + return true; +} + +bool Xtc::generateAllCovers(const std::function& progressCallback) const { + // Check if all covers already exist + const bool hasCover = SdMan.exists(getCoverBmpPath().c_str()); + const bool hasThumb = SdMan.exists(getThumbBmpPath().c_str()); + const bool hasMicroThumb = SdMan.exists(getMicroThumbBmpPath().c_str()); + + if (hasCover && hasThumb && hasMicroThumb) { + Serial.printf("[%lu] [XTC] All covers already cached\n", millis()); + if (progressCallback) progressCallback(100); + return true; + } + + if (!loaded || !parser) { + Serial.printf("[%lu] [XTC] Cannot generate covers, file not loaded\n", millis()); + return false; + } + + Serial.printf("[%lu] [XTC] Generating all covers (cover:%d, thumb:%d, micro:%d)\n", millis(), !hasCover, !hasThumb, + !hasMicroThumb); + + // Generate each cover type that's missing with progress updates + if (!hasCover) { + generateCoverBmp(); + } + if (progressCallback) progressCallback(33); + + if (!hasThumb) { + generateThumbBmp(); + } + if (progressCallback) progressCallback(66); + + if (!hasMicroThumb) { + generateMicroThumbBmp(); + } + if (progressCallback) progressCallback(100); + + Serial.printf("[%lu] [XTC] All cover generation complete\n", millis()); + 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 7413ef4..1a2242d 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -7,6 +7,7 @@ #pragma once +#include #include #include #include @@ -65,6 +66,11 @@ class Xtc { // Thumbnail support (for Continue Reading card) std::string getThumbBmpPath() const; bool generateThumbBmp() const; + // Micro thumbnail support (for Recent Books list) + std::string getMicroThumbBmpPath() const; + bool generateMicroThumbBmp() const; + // Generate all covers at once (for pre-generation on book open) + bool generateAllCovers(const std::function& progressCallback = nullptr) const; // Page access uint32_t getPageCount() const; diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index f40fa29..b15115d 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -1,5 +1,6 @@ #include "MyLibraryActivity.h" +#include #include #include @@ -20,6 +21,24 @@ constexpr int LINE_HEIGHT = 30; constexpr int RECENTS_LINE_HEIGHT = 65; // Increased for two-line items constexpr int LEFT_MARGIN = 20; constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator +constexpr int MICRO_THUMB_WIDTH = 45; +constexpr int MICRO_THUMB_HEIGHT = 60; +constexpr int THUMB_RIGHT_MARGIN = 50; // Space from right edge for thumbnail + +// Helper function to get the micro-thumb path for a book based on its file path +std::string getMicroThumbPathForBook(const std::string& bookPath) { + // Calculate cache path using same hash method as Epub/Xtc/Txt classes + const size_t hash = std::hash{}(bookPath); + + if (StringUtils::checkFileExtension(bookPath, ".epub")) { + return "/.crosspoint/epub_" + std::to_string(hash) + "/micro_thumb.bmp"; + } else if (StringUtils::checkFileExtension(bookPath, ".xtc") || StringUtils::checkFileExtension(bookPath, ".xtch")) { + return "/.crosspoint/xtc_" + std::to_string(hash) + "/micro_thumb.bmp"; + } else if (StringUtils::checkFileExtension(bookPath, ".txt") || StringUtils::checkFileExtension(bookPath, ".TXT")) { + return "/.crosspoint/txt_" + std::to_string(hash) + "/micro_thumb.bmp"; + } + return ""; +} // Timing thresholds constexpr int SKIP_PAGE_MS = 700; @@ -481,10 +500,49 @@ void MyLibraryActivity::renderRecentTab() const { renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN, RECENTS_LINE_HEIGHT); + // Calculate available text width (leaving space for thumbnail on the right) + const int textMaxWidth = pageWidth - LEFT_MARGIN - RIGHT_MARGIN - MICRO_THUMB_WIDTH - 10; + const int thumbX = pageWidth - THUMB_RIGHT_MARGIN - MICRO_THUMB_WIDTH; + // Draw items for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) { const auto& book = recentBooks[i]; const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT; + const bool isSelected = (i == selectorIndex); + + // Try to load and draw micro-thumbnail + const std::string microThumbPath = getMicroThumbPathForBook(book.path); + bool hasThumb = false; + if (!microThumbPath.empty() && SdMan.exists(microThumbPath.c_str())) { + FsFile thumbFile; + if (SdMan.openFileForRead("MYL", microThumbPath, thumbFile)) { + Bitmap bitmap(thumbFile); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + // Calculate actual drawn size (scaled to fit within max dimensions, preserving aspect ratio) + const int bmpW = bitmap.getWidth(); + const int bmpH = bitmap.getHeight(); + const float scaleX = static_cast(MICRO_THUMB_WIDTH) / static_cast(bmpW); + const float scaleY = static_cast(MICRO_THUMB_HEIGHT) / static_cast(bmpH); + const float scale = std::min(scaleX, scaleY); + const int drawnW = static_cast(bmpW * scale); + const int drawnH = static_cast(bmpH * scale); + + // Center thumbnail vertically within the row using actual drawn height + const int thumbY = y + (RECENTS_LINE_HEIGHT - drawnH) / 2; + // When selected, clear only the actual drawn area to white first + // (drawBitmap1Bit only draws pixels, it doesn't clear, so we need the white background) + if (isSelected) { + renderer.fillRect(thumbX, thumbY, drawnW, drawnH, false); + } + renderer.drawBitmap(bitmap, thumbX, thumbY, MICRO_THUMB_WIDTH, MICRO_THUMB_HEIGHT, 0, 0, isSelected); + hasThumb = true; + } + thumbFile.close(); + } + } + + // Use full width if no thumbnail, otherwise use reduced width + const int availableWidth = hasThumb ? textMaxWidth : (pageWidth - LEFT_MARGIN - RIGHT_MARGIN); // Line 1: Title std::string title = book.title; @@ -500,14 +558,13 @@ void MyLibraryActivity::renderRecentTab() const { title.resize(dot); } } - auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); - renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), i != selectorIndex); + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), availableWidth); + renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), !isSelected); // Line 2: Author if (!book.author.empty()) { - auto truncatedAuthor = - renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), i != selectorIndex); + auto truncatedAuthor = renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), availableWidth); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), !isSelected); } } } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 565189c..babfb99 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -58,6 +58,47 @@ void EpubReaderActivity::onEnter() { epub->setupCacheDir(); + // Check if cover generation is needed and do it NOW (blocking) + const bool needsThumb = !SdMan.exists(epub->getThumbBmpPath().c_str()); + const bool needsMicroThumb = !SdMan.exists(epub->getMicroThumbBmpPath().c_str()); + const bool needsCoverFit = !SdMan.exists(epub->getCoverBmpPath(false).c_str()); + const bool needsCoverCrop = !SdMan.exists(epub->getCoverBmpPath(true).c_str()); + + if (needsThumb || needsMicroThumb || needsCoverFit || needsCoverCrop) { + // Show "Preparing book... [X%]" popup, updating every 3 seconds + constexpr int boxMargin = 20; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Preparing book... [100%]"); + const int boxWidth = textWidth + boxMargin * 2; + const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; + const int boxX = (renderer.getScreenWidth() - boxWidth) / 2; + constexpr int boxY = 50; + + unsigned long lastUpdate = 0; + + // Draw initial popup + renderer.clearScreen(); + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); + renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]"); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + + // Generate covers with progress callback + epub->generateAllCovers([&](int percent) { + const unsigned long now = millis(); + if ((now - lastUpdate) >= 3000) { + lastUpdate = now; + + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); + + char progressStr[32]; + snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent); + renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + } + }); + } + FsFile f; if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { uint8_t data[4]; @@ -486,6 +527,7 @@ void EpubReaderActivity::renderScreen() { Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); } + // Save progress FsFile f; if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { uint8_t data[4]; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 5195967..f3050ad 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -56,6 +56,47 @@ void TxtReaderActivity::onEnter() { txt->setupCacheDir(); + // Check if cover generation is needed and do it NOW (blocking) + const bool needsCover = !SdMan.exists(txt->getCoverBmpPath().c_str()); + const bool needsThumb = !SdMan.exists(txt->getThumbBmpPath().c_str()); + const bool needsMicroThumb = !SdMan.exists(txt->getMicroThumbBmpPath().c_str()); + const bool hasCoverImage = !txt->findCoverImage().empty(); + + if (hasCoverImage && (needsCover || needsThumb || needsMicroThumb)) { + // Show "Preparing book... [X%]" popup, updating every 3 seconds + constexpr int boxMargin = 20; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Preparing book... [100%]"); + const int boxWidth = textWidth + boxMargin * 2; + const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; + const int boxX = (renderer.getScreenWidth() - boxWidth) / 2; + constexpr int boxY = 50; + + unsigned long lastUpdate = 0; + + // Draw initial popup + renderer.clearScreen(); + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); + renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]"); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + + // Generate covers with progress callback + txt->generateAllCovers([&](int percent) { + const unsigned long now = millis(); + if ((now - lastUpdate) >= 3000) { + lastUpdate = now; + + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); + + char progressStr[32]; + snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent); + renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + } + }); + } + // Save current txt as last opened file APP_STATE.openEpubPath = txt->getPath(); APP_STATE.saveToFile(); @@ -445,8 +486,6 @@ void TxtReaderActivity::renderScreen() { renderer.clearScreen(); renderPage(); - - // Save progress saveProgress(); } diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index ce4bc11..ff9903d 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -40,6 +40,46 @@ void XtcReaderActivity::onEnter() { xtc->setupCacheDir(); + // Check if cover generation is needed and do it NOW (blocking) + const bool needsCover = !SdMan.exists(xtc->getCoverBmpPath().c_str()); + const bool needsThumb = !SdMan.exists(xtc->getThumbBmpPath().c_str()); + const bool needsMicroThumb = !SdMan.exists(xtc->getMicroThumbBmpPath().c_str()); + + if (needsCover || needsThumb || needsMicroThumb) { + // Show "Preparing book... [X%]" popup, updating every 3 seconds + constexpr int boxMargin = 20; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Preparing book... [100%]"); + const int boxWidth = textWidth + boxMargin * 2; + const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; + const int boxX = (renderer.getScreenWidth() - boxWidth) / 2; + constexpr int boxY = 50; + + unsigned long lastUpdate = 0; + + // Draw initial popup + renderer.clearScreen(); + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); + renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]"); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + + // Generate covers with progress callback + xtc->generateAllCovers([&](int percent) { + const unsigned long now = millis(); + if ((now - lastUpdate) >= 3000) { + lastUpdate = now; + + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); + + char progressStr[32]; + snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent); + renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + } + }); + } + // Load saved progress loadProgress();