This repository has been archived on 2026-02-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
crosspoint-reader/src/activities/boot_sleep/SleepActivity.cpp

599 lines
22 KiB
C++
Raw Normal View History

#include "SleepActivity.h"
2025-12-06 04:20:03 +11:00
#include <Epub.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
Add TXT file reader support (#240) ## Summary * **What is the goal of this PR?** Add support for reading plain text (.txt) files, enabling users to browse, read, and track progress in TXT documents alongside existing EPUB and XTC formats. * **What changes are included?** - New Txt library for loading and parsing plain text files - New TxtReaderActivity with streaming page rendering using 8KB chunks to handle large files without memory issues on ESP32-C3 - Page index caching system (index.bin) for instant re-open after sleep or app restart - Progress bar UI during initial file indexing (matching EPUB style) - Word wrapping with proper UTF-8 support - Cover image support for TXT files: - Primary: image with same filename as TXT (e.g., book.jpg for book.txt) - Fallback: cover.bmp/jpg/jpeg in the same folder - JPG to BMP conversion using existing converter - Sleep screen cover mode now works with TXT files - File browser now shows .txt files ## Additional Context * Add any other information that might be helpful for the reviewer * Memory constraints: The streaming approach was necessary because ESP32-C3 only has 320KB RAM. A 700KB TXT file cannot be loaded entirely into memory, so we read 8KB chunks and build a page offset index instead. * Cache invalidation: The page index cache automatically invalidates when file size, viewport width, or lines per page changes (e.g., font size or orientation change). * Performance: First open requires indexing (with progress bar), subsequent opens load from cache instantly. * Cover image format: PNG is detected but not supported for conversion (no PNG decoder available). Only BMP and JPG/JPEG work.
2026-01-14 19:36:40 +09:00
#include <Txt.h>
#include <Xtc.h>
2025-12-06 04:20:03 +11:00
#include <cmath>
#include "BookManager.h"
2025-12-15 23:17:23 +11:00
#include "CrossPointSettings.h"
#include "CrossPointState.h"
Aleo, Noto Sans, Open Dyslexic fonts (#163) ## Summary * Swap out Bookerly font due to licensing issues, replace default font with Aleo * I did a bunch of searching around for a nice replacement font, and this trumped several other like Literata, Merriwether, Vollkorn, etc * Add Noto Sans, and Open Dyslexic as font options * They can be selected in the settings screen * Add font size options (Small, Medium, Large, Extra Large) * Adjustable in settings * Swap out uses of reader font in headings and replaced with slightly larger Ubuntu font * Replaced PixelArial14 font as it was difficult to track down, replace with Space Grotesk * Remove auto formatting on generated font files * Massively speeds up formatting step now that there is a lot more CPP font source * Include fonts with their licenses in the repo ## Additional Context Line compression setting will follow | Font | Small | Medium | Large | X Large | | --- | --- | --- | --- | --- | | Aleo | ![IMG_5704](https://github.com/user-attachments/assets/7acb054f-ddef-4080-b3c8-590cfaf13115) | ![IMG_5705](https://github.com/user-attachments/assets/d4819036-5c89-486e-92c3-86094fa4d89a) | ![IMG_5706](https://github.com/user-attachments/assets/35caf622-d126-4396-9c3e-f927eba1e1f4) | ![IMG_5707](https://github.com/user-attachments/assets/af32370a-6244-400f-bea9-5c27db040b5b) | | Noto Sans | ![IMG_5708](https://github.com/user-attachments/assets/1f9264a5-c069-4e22-9099-a082bfcaabc5) | ![IMG_5709](https://github.com/user-attachments/assets/ef6b07fe-8d87-403a-b152-05f50b69b78e) | ![IMG_5710](https://github.com/user-attachments/assets/112a5d20-262c-4dc0-b67d-980b237e4607) | ![IMG_5711](https://github.com/user-attachments/assets/d25e0e1d-2ace-450d-96dd-618e4efd4805) | | Open Dyslexic | ![IMG_5712](https://github.com/user-attachments/assets/ead64690-f261-4fae-a4a2-0becd1162e2d) | ![IMG_5713](https://github.com/user-attachments/assets/59d60f7d-5142-4591-96b0-c04e0a4c6436) | ![IMG_5714](https://github.com/user-attachments/assets/bb6652cd-1790-46a3-93ea-2b8f70d0d36d) | ![IMG_5715](https://github.com/user-attachments/assets/496e7eb4-c81a-4232-83e9-9ba9148fdea4) |
2025-12-30 18:21:47 +10:00
#include "fontIds.h"
#include "images/CrossLarge.h"
#include "util/StringUtils.h"
namespace {
// Edge luminance cache file format (per-BMP):
// - 4 bytes: uint32_t file size (for cache invalidation)
// - 4 bytes: EdgeLuminance (top, bottom, left, right)
constexpr size_t EDGE_CACHE_SIZE = 8;
// Book-level edge cache file format (stored in book's cache directory):
// - 4 bytes: uint32_t cover BMP file size (for cache invalidation)
// - 4 bytes: EdgeLuminance (top, bottom, left, right)
// - 1 byte: cover mode (0=FIT, 1=CROP) - for EPUB mode invalidation
constexpr size_t BOOK_EDGE_CACHE_SIZE = 9;
} // namespace
void SleepActivity::onEnter() {
Activity::onEnter();
renderPopup("Entering Sleep...");
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) {
return renderBlankSleepScreen();
}
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) {
return renderCustomSleepScreen();
}
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::COVER) {
return renderCoverSleepScreen();
}
renderDefaultSleepScreen();
}
void SleepActivity::renderPopup(const char* message) const {
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
constexpr int margin = 20;
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
Aleo, Noto Sans, Open Dyslexic fonts (#163) ## Summary * Swap out Bookerly font due to licensing issues, replace default font with Aleo * I did a bunch of searching around for a nice replacement font, and this trumped several other like Literata, Merriwether, Vollkorn, etc * Add Noto Sans, and Open Dyslexic as font options * They can be selected in the settings screen * Add font size options (Small, Medium, Large, Extra Large) * Adjustable in settings * Swap out uses of reader font in headings and replaced with slightly larger Ubuntu font * Replaced PixelArial14 font as it was difficult to track down, replace with Space Grotesk * Remove auto formatting on generated font files * Massively speeds up formatting step now that there is a lot more CPP font source * Include fonts with their licenses in the repo ## Additional Context Line compression setting will follow | Font | Small | Medium | Large | X Large | | --- | --- | --- | --- | --- | | Aleo | ![IMG_5704](https://github.com/user-attachments/assets/7acb054f-ddef-4080-b3c8-590cfaf13115) | ![IMG_5705](https://github.com/user-attachments/assets/d4819036-5c89-486e-92c3-86094fa4d89a) | ![IMG_5706](https://github.com/user-attachments/assets/35caf622-d126-4396-9c3e-f927eba1e1f4) | ![IMG_5707](https://github.com/user-attachments/assets/af32370a-6244-400f-bea9-5c27db040b5b) | | Noto Sans | ![IMG_5708](https://github.com/user-attachments/assets/1f9264a5-c069-4e22-9099-a082bfcaabc5) | ![IMG_5709](https://github.com/user-attachments/assets/ef6b07fe-8d87-403a-b152-05f50b69b78e) | ![IMG_5710](https://github.com/user-attachments/assets/112a5d20-262c-4dc0-b67d-980b237e4607) | ![IMG_5711](https://github.com/user-attachments/assets/d25e0e1d-2ace-450d-96dd-618e4efd4805) | | Open Dyslexic | ![IMG_5712](https://github.com/user-attachments/assets/ead64690-f261-4fae-a4a2-0becd1162e2d) | ![IMG_5713](https://github.com/user-attachments/assets/59d60f7d-5142-4591-96b0-c04e0a4c6436) | ![IMG_5714](https://github.com/user-attachments/assets/bb6652cd-1790-46a3-93ea-2b8f70d0d36d) | ![IMG_5715](https://github.com/user-attachments/assets/496e7eb4-c81a-4232-83e9-9ba9148fdea4) |
2025-12-30 18:21:47 +10:00
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
// renderer.clearScreen();
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
}
void SleepActivity::renderCustomSleepScreen() const {
// Check if we have a /sleep directory
auto dir = SdMan.open("/sleep");
if (dir && dir.isDirectory()) {
std::vector<std::string> files;
char name[500];
// collect all valid BMP files
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
if (file.isDirectory()) {
file.close();
continue;
}
file.getName(name, sizeof(name));
auto filename = std::string(name);
if (filename[0] == '.') {
file.close();
continue;
}
if (filename.substr(filename.length() - 4) != ".bmp") {
Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), name);
file.close();
continue;
}
Bitmap bitmap(file);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), name);
file.close();
continue;
}
files.emplace_back(filename);
file.close();
}
const auto numFiles = files.size();
if (numFiles > 0) {
// Generate a random number between 1 and 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 bmpPath = "/sleep/" + files[randomFileIndex];
FsFile file;
if (SdMan.openFileForRead("SLP", bmpPath, file)) {
Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
delay(100);
Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
renderBitmapSleepScreen(bitmap, bmpPath);
dir.close();
return;
}
}
}
}
if (dir) dir.close();
// Look for sleep.bmp on the root of the sd card to determine if we should
// render a custom sleep screen instead of the default.
FsFile file;
const std::string rootSleepPath = "/sleep.bmp";
if (SdMan.openFileForRead("SLP", rootSleepPath, file)) {
Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis());
renderBitmapSleepScreen(bitmap, rootSleepPath);
return;
}
}
renderDefaultSleepScreen();
}
void SleepActivity::renderDefaultSleepScreen() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
2025-12-08 22:52:19 +11:00
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
// Make sleep screen dark unless light is selected in settings
if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::LIGHT) {
renderer.invertScreen();
}
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
}
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const {
int x, y;
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
float cropX = 0, cropY = 0;
2026-01-22 13:13:12 -05:00
int drawWidth = pageWidth;
int drawHeight = pageHeight;
int fillWidth = pageWidth; // Actual area the image will occupy
int fillHeight = pageHeight;
Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(),
pageWidth, pageHeight);
2026-01-22 13:13:12 -05:00
const float bitmapRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), bitmapRatio, screenRatio);
const auto coverMode = SETTINGS.sleepScreenCoverMode;
if (coverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::ACTUAL) {
// ACTUAL mode: Show image at actual size, centered (no scaling)
x = (pageWidth - bitmap.getWidth()) / 2;
y = (pageHeight - bitmap.getHeight()) / 2;
2026-01-22 13:13:12 -05:00
// Don't constrain to screen dimensions - drawBitmap will clip
drawWidth = 0;
drawHeight = 0;
fillWidth = bitmap.getWidth();
fillHeight = bitmap.getHeight();
2026-01-22 13:13:12 -05:00
Serial.printf("[%lu] [SLP] ACTUAL mode: centering at %d, %d\n", millis(), x, y);
} else if (coverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
// CROP mode: Scale to fill screen completely (may crop edges)
// Calculate crop values to fill the screen while maintaining aspect ratio
if (bitmapRatio > screenRatio) {
// Image is wider than screen ratio - crop horizontally
cropX = 1.0f - (screenRatio / bitmapRatio);
Serial.printf("[%lu] [SLP] CROP mode: cropping x by %f\n", millis(), cropX);
} else if (bitmapRatio < screenRatio) {
// Image is taller than screen ratio - crop vertically
cropY = 1.0f - (bitmapRatio / screenRatio);
Serial.printf("[%lu] [SLP] CROP mode: cropping y by %f\n", millis(), cropY);
}
// After cropping, the image should fill the screen exactly
x = 0;
y = 0;
fillWidth = pageWidth;
fillHeight = pageHeight;
2026-01-22 13:13:12 -05:00
Serial.printf("[%lu] [SLP] CROP mode: drawing at 0, 0 with crop %f, %f\n", millis(), cropX, cropY);
} else {
// FIT mode (default): Scale to fit entire image within screen (may have letterboxing)
// Calculate the scaled dimensions
float scale;
if (bitmapRatio > screenRatio) {
// Image is wider than screen ratio - fit to width
scale = static_cast<float>(pageWidth) / static_cast<float>(bitmap.getWidth());
} else {
// Image is taller than screen ratio - fit to height
scale = static_cast<float>(pageHeight) / static_cast<float>(bitmap.getHeight());
}
// Use ceil to ensure fill area covers all drawn pixels
fillWidth = static_cast<int>(std::ceil(bitmap.getWidth() * scale));
fillHeight = static_cast<int>(std::ceil(bitmap.getHeight() * scale));
2026-01-22 13:13:12 -05:00
// Center the scaled image
x = (pageWidth - fillWidth) / 2;
y = (pageHeight - fillHeight) / 2;
2026-01-22 13:13:12 -05:00
Serial.printf("[%lu] [SLP] FIT mode: scale %f, scaled size %d x %d, position %d, %d\n", millis(), scale,
fillWidth, fillHeight, x, y);
}
// Get edge luminance values (from cache or calculate)
const EdgeLuminance edges = getEdgeLuminance(bitmap, bmpPath);
const uint8_t topGray = quantizeGray(edges.top);
const uint8_t bottomGray = quantizeGray(edges.bottom);
const uint8_t leftGray = quantizeGray(edges.left);
const uint8_t rightGray = quantizeGray(edges.right);
Serial.printf("[%lu] [SLP] Edge luminance: T=%d B=%d L=%d R=%d -> gray levels T=%d B=%d L=%d R=%d\n",
millis(), edges.top, edges.bottom, edges.left, edges.right,
topGray, bottomGray, leftGray, rightGray);
// Clear screen to white first (default background)
renderer.clearScreen(0xFF);
// Fill letterbox regions with edge colors (BW pass)
// Top letterbox
if (y > 0) {
renderer.fillRectGray(0, 0, pageWidth, y, topGray);
}
// Bottom letterbox
if (y + fillHeight < pageHeight) {
renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray);
}
// Left letterbox
if (x > 0) {
renderer.fillRectGray(0, y, x, fillHeight, leftGray);
}
// Right letterbox
if (x + fillWidth < pageWidth) {
renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray);
}
Serial.printf("[%lu] [SLP] drawing bitmap at %d, %d\n", millis(), x, y);
2026-01-22 13:13:12 -05:00
renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY);
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
if (bitmap.hasGreyscale()) {
// Grayscale LSB pass
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
// Fill letterbox regions for LSB pass
if (y > 0) renderer.fillRectGray(0, 0, pageWidth, y, topGray);
if (y + fillHeight < pageHeight)
renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray);
if (x > 0) renderer.fillRectGray(0, y, x, fillHeight, leftGray);
if (x + fillWidth < pageWidth)
renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray);
2026-01-22 13:13:12 -05:00
renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY);
renderer.copyGrayscaleLsbBuffers();
// Grayscale MSB pass
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
// Fill letterbox regions for MSB pass
if (y > 0) renderer.fillRectGray(0, 0, pageWidth, y, topGray);
if (y + fillHeight < pageHeight)
renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray);
if (x > 0) renderer.fillRectGray(0, y, x, fillHeight, leftGray);
if (x + fillWidth < pageWidth)
renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray);
2026-01-22 13:13:12 -05:00
renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY);
renderer.copyGrayscaleMsbBuffers();
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
}
}
void SleepActivity::renderCoverSleepScreen() const {
if (APP_STATE.openEpubPath.empty()) {
return renderDefaultSleepScreen();
}
const bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
// Try to use cached edge data to skip book metadata loading
if (tryRenderCachedCoverSleep(APP_STATE.openEpubPath, cropped)) {
return;
}
// Cache miss - need to load book metadata and generate cover
Serial.printf("[%lu] [SLP] Cache miss, loading book metadata\n", millis());
std::string coverBmpPath;
Add TXT file reader support (#240) ## Summary * **What is the goal of this PR?** Add support for reading plain text (.txt) files, enabling users to browse, read, and track progress in TXT documents alongside existing EPUB and XTC formats. * **What changes are included?** - New Txt library for loading and parsing plain text files - New TxtReaderActivity with streaming page rendering using 8KB chunks to handle large files without memory issues on ESP32-C3 - Page index caching system (index.bin) for instant re-open after sleep or app restart - Progress bar UI during initial file indexing (matching EPUB style) - Word wrapping with proper UTF-8 support - Cover image support for TXT files: - Primary: image with same filename as TXT (e.g., book.jpg for book.txt) - Fallback: cover.bmp/jpg/jpeg in the same folder - JPG to BMP conversion using existing converter - Sleep screen cover mode now works with TXT files - File browser now shows .txt files ## Additional Context * Add any other information that might be helpful for the reviewer * Memory constraints: The streaming approach was necessary because ESP32-C3 only has 320KB RAM. A 700KB TXT file cannot be loaded entirely into memory, so we read 8KB chunks and build a page offset index instead. * Cache invalidation: The page index cache automatically invalidates when file size, viewport width, or lines per page changes (e.g., font size or orientation change). * Performance: First open requires indexing (with progress bar), subsequent opens load from cache instantly. * Cover image format: PNG is detected but not supported for conversion (no PNG decoder available). Only BMP and JPG/JPEG work.
2026-01-14 19:36:40 +09:00
// 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
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastXtc.load()) {
Serial.println("[SLP] Failed to load last XTC");
return renderDefaultSleepScreen();
}
if (!lastXtc.generateCoverBmp()) {
Serial.println("[SLP] Failed to generate XTC cover bmp");
return renderDefaultSleepScreen();
}
coverBmpPath = lastXtc.getCoverBmpPath();
Add TXT file reader support (#240) ## Summary * **What is the goal of this PR?** Add support for reading plain text (.txt) files, enabling users to browse, read, and track progress in TXT documents alongside existing EPUB and XTC formats. * **What changes are included?** - New Txt library for loading and parsing plain text files - New TxtReaderActivity with streaming page rendering using 8KB chunks to handle large files without memory issues on ESP32-C3 - Page index caching system (index.bin) for instant re-open after sleep or app restart - Progress bar UI during initial file indexing (matching EPUB style) - Word wrapping with proper UTF-8 support - Cover image support for TXT files: - Primary: image with same filename as TXT (e.g., book.jpg for book.txt) - Fallback: cover.bmp/jpg/jpeg in the same folder - JPG to BMP conversion using existing converter - Sleep screen cover mode now works with TXT files - File browser now shows .txt files ## Additional Context * Add any other information that might be helpful for the reviewer * Memory constraints: The streaming approach was necessary because ESP32-C3 only has 320KB RAM. A 700KB TXT file cannot be loaded entirely into memory, so we read 8KB chunks and build a page offset index instead. * Cache invalidation: The page index cache automatically invalidates when file size, viewport width, or lines per page changes (e.g., font size or orientation change). * Performance: First open requires indexing (with progress bar), subsequent opens load from cache instantly. * Cover image format: PNG is detected but not supported for conversion (no PNG decoder available). Only BMP and JPG/JPEG work.
2026-01-14 19:36:40 +09:00
} 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");
if (!lastEpub.load()) {
Serial.println("[SLP] Failed to load last epub");
return renderDefaultSleepScreen();
}
if (!lastEpub.generateCoverBmp(cropped)) {
Serial.println("[SLP] Failed to generate cover bmp");
return renderDefaultSleepScreen();
}
coverBmpPath = lastEpub.getCoverBmpPath(cropped);
} else {
return renderDefaultSleepScreen();
}
FsFile file;
if (SdMan.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
// Render the bitmap - this will calculate and cache edge luminance per-BMP
renderBitmapSleepScreen(bitmap, coverBmpPath);
// Also save to book-level edge cache for faster subsequent sleeps
const std::string edgeCachePath = getBookEdgeCachePath(APP_STATE.openEpubPath);
if (!edgeCachePath.empty()) {
// Get the edge luminance that was just calculated (it's now cached per-BMP)
const EdgeLuminance edges = getEdgeLuminance(bitmap, coverBmpPath);
const uint32_t bmpFileSize = bitmap.getFileSize();
const uint8_t coverMode = cropped ? 1 : 0;
FsFile cacheFile;
if (SdMan.openFileForWrite("SLP", edgeCachePath, cacheFile)) {
uint8_t cacheData[BOOK_EDGE_CACHE_SIZE];
cacheData[0] = bmpFileSize & 0xFF;
cacheData[1] = (bmpFileSize >> 8) & 0xFF;
cacheData[2] = (bmpFileSize >> 16) & 0xFF;
cacheData[3] = (bmpFileSize >> 24) & 0xFF;
cacheData[4] = edges.top;
cacheData[5] = edges.bottom;
cacheData[6] = edges.left;
cacheData[7] = edges.right;
cacheData[8] = coverMode;
cacheFile.write(cacheData, BOOK_EDGE_CACHE_SIZE);
cacheFile.close();
Serial.printf("[%lu] [SLP] Saved book-level edge cache to %s\n", millis(), edgeCachePath.c_str());
}
}
return;
}
}
renderDefaultSleepScreen();
}
void SleepActivity::renderBlankSleepScreen() const {
renderer.clearScreen();
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
}
std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) {
// Convert "/dir/file.bmp" to "/dir/.file.bmp.perim"
const size_t lastSlash = bmpPath.find_last_of('/');
if (lastSlash == std::string::npos) {
// No directory, just prepend dot
return "." + bmpPath + ".perim";
}
const std::string dir = bmpPath.substr(0, lastSlash + 1);
const std::string filename = bmpPath.substr(lastSlash + 1);
return dir + "." + filename + ".perim";
}
uint8_t SleepActivity::quantizeGray(uint8_t lum) {
// Quantize luminance (0-255) to 4-level grayscale (0-3)
// Thresholds tuned for X4 display gray levels
if (lum < 43) return 0; // black
if (lum < 128) return 1; // dark gray
if (lum < 213) return 2; // light gray
return 3; // white
}
EdgeLuminance SleepActivity::getEdgeLuminance(const Bitmap& bitmap, const std::string& bmpPath) const {
const std::string cachePath = getEdgeCachePath(bmpPath);
EdgeLuminance result = {128, 128, 128, 128}; // Default to neutral gray
// Try to read from cache
FsFile cacheFile;
if (SdMan.openFileForRead("SLP", cachePath, cacheFile)) {
uint8_t cacheData[EDGE_CACHE_SIZE];
if (cacheFile.read(cacheData, EDGE_CACHE_SIZE) == EDGE_CACHE_SIZE) {
// Extract cached file size
const uint32_t cachedSize = static_cast<uint32_t>(cacheData[0]) |
(static_cast<uint32_t>(cacheData[1]) << 8) |
(static_cast<uint32_t>(cacheData[2]) << 16) |
(static_cast<uint32_t>(cacheData[3]) << 24);
// Get current BMP file size from already-opened bitmap
const uint32_t currentSize = bitmap.getFileSize();
// Validate cache
if (cachedSize == currentSize && currentSize > 0) {
result.top = cacheData[4];
result.bottom = cacheData[5];
result.left = cacheData[6];
result.right = cacheData[7];
Serial.printf("[%lu] [SLP] Edge cache hit for %s: T=%d B=%d L=%d R=%d\n", millis(), bmpPath.c_str(),
result.top, result.bottom, result.left, result.right);
cacheFile.close();
return result;
}
Serial.printf("[%lu] [SLP] Edge cache invalid (size mismatch: %lu vs %lu)\n", millis(),
static_cast<unsigned long>(cachedSize), static_cast<unsigned long>(currentSize));
}
cacheFile.close();
}
// Cache miss - calculate edge luminance
Serial.printf("[%lu] [SLP] Calculating edge luminance for %s\n", millis(), bmpPath.c_str());
result = bitmap.detectEdgeLuminance(2); // Sample 2 pixels deep for stability
Serial.printf("[%lu] [SLP] Edge luminance detected: T=%d B=%d L=%d R=%d\n", millis(),
result.top, result.bottom, result.left, result.right);
// Get BMP file size from already-opened bitmap for cache
const uint32_t fileSize = bitmap.getFileSize();
// Save to cache
if (fileSize > 0 && SdMan.openFileForWrite("SLP", cachePath, cacheFile)) {
uint8_t cacheData[EDGE_CACHE_SIZE];
cacheData[0] = fileSize & 0xFF;
cacheData[1] = (fileSize >> 8) & 0xFF;
cacheData[2] = (fileSize >> 16) & 0xFF;
cacheData[3] = (fileSize >> 24) & 0xFF;
cacheData[4] = result.top;
cacheData[5] = result.bottom;
cacheData[6] = result.left;
cacheData[7] = result.right;
cacheFile.write(cacheData, EDGE_CACHE_SIZE);
cacheFile.close();
Serial.printf("[%lu] [SLP] Saved edge cache to %s\n", millis(), cachePath.c_str());
}
return result;
}
std::string SleepActivity::getBookEdgeCachePath(const std::string& bookPath) {
const std::string cacheDir = BookManager::getCacheDir(bookPath);
if (cacheDir.empty()) {
return "";
}
return cacheDir + "/edge.bin";
}
std::string SleepActivity::getCoverBmpPath(const std::string& cacheDir, const std::string& bookPath, bool cropped) {
if (cacheDir.empty()) {
return "";
}
// EPUB uses different paths for fit vs crop modes
if (StringUtils::checkFileExtension(bookPath, ".epub")) {
return cropped ? (cacheDir + "/cover_crop.bmp") : (cacheDir + "/cover_fit.bmp");
}
// XTC and TXT use a single cover.bmp
return cacheDir + "/cover.bmp";
}
bool SleepActivity::tryRenderCachedCoverSleep(const std::string& bookPath, bool cropped) const {
// Try to render cover sleep screen using cached edge data without loading book metadata
const std::string edgeCachePath = getBookEdgeCachePath(bookPath);
if (edgeCachePath.empty()) {
Serial.println("[SLP] Cannot get edge cache path");
return false;
}
// Check if edge cache exists
FsFile cacheFile;
if (!SdMan.openFileForRead("SLP", edgeCachePath, cacheFile)) {
Serial.println("[SLP] No edge cache file found");
return false;
}
// Read cache data
uint8_t cacheData[BOOK_EDGE_CACHE_SIZE];
if (cacheFile.read(cacheData, BOOK_EDGE_CACHE_SIZE) != BOOK_EDGE_CACHE_SIZE) {
Serial.println("[SLP] Edge cache file too small");
cacheFile.close();
return false;
}
cacheFile.close();
// Extract cached values
const uint32_t cachedBmpSize = static_cast<uint32_t>(cacheData[0]) |
(static_cast<uint32_t>(cacheData[1]) << 8) |
(static_cast<uint32_t>(cacheData[2]) << 16) |
(static_cast<uint32_t>(cacheData[3]) << 24);
EdgeLuminance cachedEdges;
cachedEdges.top = cacheData[4];
cachedEdges.bottom = cacheData[5];
cachedEdges.left = cacheData[6];
cachedEdges.right = cacheData[7];
const uint8_t cachedCoverMode = cacheData[8];
// Check if cover mode matches (for EPUB)
const uint8_t currentCoverMode = cropped ? 1 : 0;
if (StringUtils::checkFileExtension(bookPath, ".epub") && cachedCoverMode != currentCoverMode) {
Serial.printf("[SLP] Cover mode changed (cached=%d, current=%d), invalidating cache\n",
cachedCoverMode, currentCoverMode);
return false;
}
// Construct cover BMP path
const std::string cacheDir = BookManager::getCacheDir(bookPath);
const std::string coverBmpPath = getCoverBmpPath(cacheDir, bookPath, cropped);
if (coverBmpPath.empty()) {
Serial.println("[SLP] Cannot construct cover BMP path");
return false;
}
// Try to open the cover BMP
FsFile bmpFile;
if (!SdMan.openFileForRead("SLP", coverBmpPath, bmpFile)) {
Serial.printf("[SLP] Cover BMP not found: %s\n", coverBmpPath.c_str());
return false;
}
// Check if BMP file size matches cache
const uint32_t currentBmpSize = bmpFile.size();
if (currentBmpSize != cachedBmpSize || currentBmpSize == 0) {
Serial.printf("[SLP] BMP size mismatch (cached=%lu, current=%lu)\n",
static_cast<unsigned long>(cachedBmpSize), static_cast<unsigned long>(currentBmpSize));
bmpFile.close();
return false;
}
// Parse bitmap headers
Bitmap bitmap(bmpFile);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
Serial.println("[SLP] Failed to parse cached cover BMP");
bmpFile.close();
return false;
}
Serial.printf("[%lu] [SLP] Using cached cover sleep: %s (T=%d B=%d L=%d R=%d)\n", millis(),
coverBmpPath.c_str(), cachedEdges.top, cachedEdges.bottom, cachedEdges.left, cachedEdges.right);
// Render the bitmap with cached edge values
// We call renderBitmapSleepScreen which will use getEdgeLuminance internally,
// but since the per-BMP cache should also exist (same values), it will be a cache hit
renderBitmapSleepScreen(bitmap, coverBmpPath);
return true;
}