#include "SleepActivity.h" #include #include #include #include #include #include #include #include #include #include #include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "components/UITheme.h" #include "fontIds.h" #include "images/Logo120.h" #include "util/BookSettings.h" #include "util/StringUtils.h" // Sleep cover refresh strategy when dithered letterbox fill is active: // 1 = Double FAST_REFRESH (clear to white, then render content -- avoids HALF_REFRESH crosstalk) // 0 = Standard HALF_REFRESH (original behavior) #define USE_SLEEP_DOUBLE_FAST_REFRESH 1 namespace { // Number of source pixels along the image edge to average for the dominant color constexpr int EDGE_SAMPLE_DEPTH = 20; // Map a 2-bit quantized pixel value to an 8-bit grayscale value constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; } // Letterbox fill data: one average gray value per edge (top/bottom or left/right). struct LetterboxFillData { uint8_t avgA = 128; // average gray of edge A (top or left) uint8_t avgB = 128; // average gray of edge B (bottom or right) int letterboxA = 0; // pixel size of the first letterbox area (top or left) int letterboxB = 0; // pixel size of the second letterbox area (bottom or right) bool horizontal = false; // true = top/bottom letterbox, false = left/right bool valid = false; }; // Snap an 8-bit gray value to the nearest of the 4 e-ink levels: 0, 85, 170, 255. uint8_t snapToEinkLevel(uint8_t gray) { // Thresholds at midpoints: 42, 127, 212 if (gray < 43) return 0; if (gray < 128) return 85; if (gray < 213) return 170; return 255; } // 4x4 Bayer ordered dithering matrix, values 0-255. // Produces a structured halftone pattern for 4-level quantization. // clang-format off constexpr uint8_t BAYER_4X4[4][4] = { { 0, 128, 32, 160}, {192, 64, 224, 96}, { 48, 176, 16, 144}, {240, 112, 208, 80} }; // clang-format on // Ordered (Bayer) dithering for 4-level e-ink display. // Maps an 8-bit gray value to a 2-bit level (0-3) using the Bayer matrix // to produce a structured, repeating halftone pattern. uint8_t quantizeBayerDither(int gray, int x, int y) { const int threshold = BAYER_4X4[y & 3][x & 3]; const int scaled = gray * 3; if (scaled < 255) { return (scaled + threshold >= 255) ? 1 : 0; } else if (scaled < 510) { return ((scaled - 255) + threshold >= 255) ? 2 : 1; } else { return ((scaled - 510) + threshold >= 255) ? 3 : 2; } } // --- Edge average cache --- // Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep. constexpr uint8_t EDGE_CACHE_VERSION = 2; bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxFillData& data) { FsFile file; if (!Storage.openFileForRead("SLP", path, file)) return false; uint8_t version; serialization::readPod(file, version); if (version != EDGE_CACHE_VERSION) { file.close(); return false; } uint16_t cachedW, cachedH; serialization::readPod(file, cachedW); serialization::readPod(file, cachedH); if (cachedW != static_cast(screenWidth) || cachedH != static_cast(screenHeight)) { file.close(); return false; } uint8_t horizontal; serialization::readPod(file, horizontal); data.horizontal = (horizontal != 0); serialization::readPod(file, data.avgA); serialization::readPod(file, data.avgB); int16_t lbA, lbB; serialization::readPod(file, lbA); serialization::readPod(file, lbB); data.letterboxA = lbA; data.letterboxB = lbB; file.close(); data.valid = true; LOG_DBG("SLP", "Loaded edge cache from %s (avgA=%d, avgB=%d)", path.c_str(), data.avgA, data.avgB); return true; } bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxFillData& data) { if (!data.valid) return false; FsFile file; if (!Storage.openFileForWrite("SLP", path, file)) return false; serialization::writePod(file, EDGE_CACHE_VERSION); serialization::writePod(file, static_cast(screenWidth)); serialization::writePod(file, static_cast(screenHeight)); serialization::writePod(file, static_cast(data.horizontal ? 1 : 0)); serialization::writePod(file, data.avgA); serialization::writePod(file, data.avgB); serialization::writePod(file, static_cast(data.letterboxA)); serialization::writePod(file, static_cast(data.letterboxB)); file.close(); LOG_DBG("SLP", "Saved edge cache to %s", path.c_str()); return true; } // Read the bitmap once to compute a single average gray value for the top/bottom or left/right edges. // Only computes running sums -- no per-pixel arrays, no malloc beyond row buffers. // After sampling the bitmap is rewound via rewindToData(). LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight, float scale, float cropX, float cropY) { LetterboxFillData data; const int cropPixX = static_cast(std::floor(bitmap.getWidth() * cropX / 2.0f)); const int cropPixY = static_cast(std::floor(bitmap.getHeight() * cropY / 2.0f)); const int visibleWidth = bitmap.getWidth() - 2 * cropPixX; const int visibleHeight = bitmap.getHeight() - 2 * cropPixY; if (visibleWidth <= 0 || visibleHeight <= 0) return data; const int outputRowSize = (bitmap.getWidth() + 3) / 4; auto* outputRow = static_cast(malloc(outputRowSize)); auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); if (!outputRow || !rowBytes) { free(outputRow); free(rowBytes); return data; } if (imgY > 0) { // Top/bottom letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH rows data.horizontal = true; const int scaledHeight = static_cast(std::round(static_cast(visibleHeight) * scale)); data.letterboxA = imgY; data.letterboxB = pageHeight - imgY - scaledHeight; if (data.letterboxB < 0) data.letterboxB = 0; const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight); uint64_t sumTop = 0, sumBot = 0; int countTop = 0, countBot = 0; for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break; const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue; const int outY = logicalY - cropPixY; const bool inTop = (outY < sampleRows); const bool inBot = (outY >= visibleHeight - sampleRows); if (!inTop && !inBot) continue; for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; const uint8_t gray = val2bitToGray(val); if (inTop) { sumTop += gray; countTop++; } if (inBot) { sumBot += gray; countBot++; } } } data.avgA = countTop > 0 ? static_cast(sumTop / countTop) : 128; data.avgB = countBot > 0 ? static_cast(sumBot / countBot) : 128; data.valid = true; } else if (imgX > 0) { // Left/right letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH columns data.horizontal = false; const int scaledWidth = static_cast(std::round(static_cast(visibleWidth) * scale)); data.letterboxA = imgX; data.letterboxB = pageWidth - imgX - scaledWidth; if (data.letterboxB < 0) data.letterboxB = 0; const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth); uint64_t sumLeft = 0, sumRight = 0; int countLeft = 0, countRight = 0; for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break; const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue; for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) { const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; sumLeft += val2bitToGray(val); countLeft++; } for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; sumRight += val2bitToGray(val); countRight++; } } data.avgA = countLeft > 0 ? static_cast(sumLeft / countLeft) : 128; data.avgB = countRight > 0 ? static_cast(sumRight / countRight) : 128; data.valid = true; } bitmap.rewindToData(); free(outputRow); free(rowBytes); return data; } // Draw letterbox fill in the areas around the cover image. // DITHERED: fills with the edge average using Bayer ordered dithering to approximate the color. // SOLID: snaps edge average to nearest e-ink level (0/85/170/255) for a clean uniform fill. // Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB). void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uint8_t fillMode) { if (!data.valid) return; const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID); const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0; const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0; if (data.horizontal) { if (data.letterboxA > 0) { for (int y = 0; y < data.letterboxA; y++) for (int x = 0; x < renderer.getScreenWidth(); x++) { const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y); renderer.drawPixelGray(x, y, lv); } } if (data.letterboxB > 0) { const int start = renderer.getScreenHeight() - data.letterboxB; for (int y = start; y < renderer.getScreenHeight(); y++) for (int x = 0; x < renderer.getScreenWidth(); x++) { const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y); renderer.drawPixelGray(x, y, lv); } } } else { if (data.letterboxA > 0) { for (int x = 0; x < data.letterboxA; x++) for (int y = 0; y < renderer.getScreenHeight(); y++) { const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y); renderer.drawPixelGray(x, y, lv); } } if (data.letterboxB > 0) { const int start = renderer.getScreenWidth() - data.letterboxB; for (int x = start; x < renderer.getScreenWidth(); x++) for (int y = 0; y < renderer.getScreenHeight(); y++) { const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y); renderer.drawPixelGray(x, y, lv); } } } } } // namespace void SleepActivity::onEnter() { Activity::onEnter(); GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); switch (SETTINGS.sleepScreen) { case (CrossPointSettings::SLEEP_SCREEN_MODE::BLANK): return renderBlankSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM): return renderCustomSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER): case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM): return renderCoverSleepScreen(); default: return renderDefaultSleepScreen(); } } void SleepActivity::renderCustomSleepScreen() const { // Check if we have a /sleep directory auto dir = Storage.open("/sleep"); if (dir && dir.isDirectory()) { std::vector 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") { LOG_DBG("SLP", "Skipping non-.bmp file name: %s", name); file.close(); continue; } Bitmap bitmap(file); if (bitmap.parseHeaders() != BmpReaderError::Ok) { LOG_DBG("SLP", "Skipping invalid BMP file: %s", 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 filename = "/sleep/" + files[randomFileIndex]; FsFile file; if (Storage.openFileForRead("SLP", filename, file)) { LOG_DBG("SLP", "Randomly loading: /sleep/%s", files[randomFileIndex].c_str()); delay(100); Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap); 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; if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) { Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("SLP", "Loading: /sleep.bmp"); renderBitmapSleepScreen(bitmap); return; } } renderDefaultSleepScreen(); } void SleepActivity::renderDefaultSleepScreen() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); renderer.drawImage(Logo120, (pageWidth - 120) / 2, (pageHeight - 120) / 2, 120, 120); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, tr(STR_CROSSPOINT), true, EpdFontFamily::BOLD); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, tr(STR_SLEEPING)); // Make sleep screen dark unless light is selected in settings if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::LIGHT) { renderer.invertScreen(); } renderer.displayBuffer(HalDisplay::HALF_REFRESH); } void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath, uint8_t fillModeOverride) const { int x, y; const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); float cropX = 0, cropY = 0; LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight); // Always compute aspect-ratio-preserving scale and position (supports both larger and smaller images) float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio); if (ratio > screenRatio) { // image wider than viewport ratio, needs to be centered vertically if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { cropX = 1.0f - (screenRatio / ratio); LOG_DBG("SLP", "Cropping bitmap x: %f", cropX); ratio = (1.0f - cropX) * static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); } x = 0; y = std::round((static_cast(pageHeight) - static_cast(pageWidth) / ratio) / 2); LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y); } else { // image taller than or equal to viewport ratio, needs to be centered horizontally if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { cropY = 1.0f - (ratio / screenRatio); LOG_DBG("SLP", "Cropping bitmap y: %f", cropY); ratio = static_cast(bitmap.getWidth()) / ((1.0f - cropY) * static_cast(bitmap.getHeight())); } x = std::round((static_cast(pageWidth) - static_cast(pageHeight) * ratio) / 2); y = 0; LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x); } LOG_DBG("SLP", "drawing to %d x %d", x, y); // Compute the scale factor (same formula as drawBitmap) so we can map screen coords to source coords const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth(); const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight(); const float scale = std::min(static_cast(pageWidth) / effectiveWidth, static_cast(pageHeight) / effectiveHeight); // Determine letterbox fill settings (per-book override takes precedence) const uint8_t fillMode = (fillModeOverride != BookSettings::USE_GLOBAL && fillModeOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT) ? fillModeOverride : SETTINGS.sleepScreenLetterboxFill; const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE); static const char* fillModeNames[] = {"dithered", "solid", "none"}; const char* fillModeName = (fillMode < 3) ? fillModeNames[fillMode] : "unknown"; // Compute edge averages if letterbox fill is requested (try cache first) LetterboxFillData fillData; const bool hasLetterbox = (x > 0 || y > 0); if (hasLetterbox && wantFill) { bool cacheLoaded = false; if (!edgeCachePath.empty()) { cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData); } if (!cacheLoaded) { LOG_DBG("SLP", "Letterbox detected (x=%d, y=%d), computing edge averages for %s fill", x, y, fillModeName); fillData = computeEdgeAverages(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY); if (fillData.valid && !edgeCachePath.empty()) { saveEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData); } } if (fillData.valid) { LOG_DBG("SLP", "Letterbox fill: %s, horizontal=%d, avgA=%d, avgB=%d, letterboxA=%d, letterboxB=%d", fillModeName, fillData.horizontal, fillData.avgA, fillData.avgB, fillData.letterboxA, fillData.letterboxB); } } renderer.clearScreen(); const bool hasGreyscale = bitmap.hasGreyscale() && SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER; const bool isInverted = SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE; #if USE_SLEEP_DOUBLE_FAST_REFRESH const bool useDoubleFast = fillData.valid && fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_DITHERED; #else const bool useDoubleFast = false; #endif if (useDoubleFast) { // Double FAST_REFRESH technique: avoids HALF_REFRESH crosstalk with dithered letterbox. // Pass 1: clear to white baseline renderer.clearScreen(); renderer.displayBuffer(HalDisplay::FAST_REFRESH); // Pass 2: render actual content and display if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); if (isInverted) renderer.invertScreen(); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } else { // Standard path: single HALF_REFRESH if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); if (isInverted) renderer.invertScreen(); renderer.displayBuffer(HalDisplay::HALF_REFRESH); } if (hasGreyscale) { bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.copyGrayscaleLsbBuffers(); bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.copyGrayscaleMsbBuffers(); renderer.displayGrayBuffer(); renderer.setRenderMode(GfxRenderer::BW); } } void SleepActivity::renderCoverSleepScreen() const { void (SleepActivity::*renderNoCoverSleepScreen)() const; switch (SETTINGS.sleepScreen) { case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM): renderNoCoverSleepScreen = &SleepActivity::renderCustomSleepScreen; break; default: renderNoCoverSleepScreen = &SleepActivity::renderDefaultSleepScreen; break; } if (APP_STATE.openEpubPath.empty()) { return (this->*renderNoCoverSleepScreen)(); } std::string coverBmpPath; std::string bookCachePath; 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 Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastXtc.load()) { LOG_ERR("SLP", "Failed to load last XTC"); return (this->*renderNoCoverSleepScreen)(); } if (!lastXtc.generateCoverBmp()) { LOG_DBG("SLP", "XTC cover generation failed, trying placeholder"); PlaceholderCoverGenerator::generate(lastXtc.getCoverBmpPath(), lastXtc.getTitle(), lastXtc.getAuthor(), 480, 800); } if (!Storage.exists(lastXtc.getCoverBmpPath().c_str())) { LOG_ERR("SLP", "Failed to generate XTC cover bmp"); return (this->*renderNoCoverSleepScreen)(); } coverBmpPath = lastXtc.getCoverBmpPath(); bookCachePath = lastXtc.getCachePath(); } 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()) { LOG_ERR("SLP", "Failed to load last TXT"); return (this->*renderNoCoverSleepScreen)(); } if (!lastTxt.generateCoverBmp()) { LOG_DBG("SLP", "TXT cover generation failed, trying placeholder"); PlaceholderCoverGenerator::generate(lastTxt.getCoverBmpPath(), lastTxt.getTitle(), "", 480, 800); } if (!Storage.exists(lastTxt.getCoverBmpPath().c_str())) { LOG_ERR("SLP", "No cover image found for TXT file"); return (this->*renderNoCoverSleepScreen)(); } coverBmpPath = lastTxt.getCoverBmpPath(); bookCachePath = lastTxt.getCachePath(); } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) { // Handle EPUB file Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); // Skip loading css since we only need metadata here if (!lastEpub.load(true, true)) { LOG_ERR("SLP", "Failed to load last epub"); return (this->*renderNoCoverSleepScreen)(); } if (!lastEpub.generateCoverBmp(cropped)) { LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder"); if (!PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(), lastEpub.getAuthor(), 480, 800)) { LOG_DBG("SLP", "Placeholder generation failed, creating X-pattern marker"); lastEpub.generateInvalidFormatCoverBmp(cropped); } } if (!Epub::isValidThumbnailBmp(lastEpub.getCoverBmpPath(cropped))) { LOG_ERR("SLP", "Failed to generate cover bmp"); return (this->*renderNoCoverSleepScreen)(); } coverBmpPath = lastEpub.getCoverBmpPath(cropped); bookCachePath = lastEpub.getCachePath(); } else { return (this->*renderNoCoverSleepScreen)(); } // Load per-book letterbox fill override (falls back to global if not set) uint8_t fillModeOverride = BookSettings::USE_GLOBAL; if (!bookCachePath.empty()) { auto bookSettings = BookSettings::load(bookCachePath); fillModeOverride = bookSettings.letterboxFillOverride; } FsFile file; if (Storage.openFileForRead("SLP", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str()); // Derive edge cache path from cover BMP path (e.g. cover.bmp -> cover_edges.bin) std::string edgeCachePath; const auto dotPos = coverBmpPath.rfind(".bmp"); if (dotPos != std::string::npos) { edgeCachePath = coverBmpPath.substr(0, dotPos) + "_edges.bin"; } renderBitmapSleepScreen(bitmap, edgeCachePath, fillModeOverride); return; } } return (this->*renderNoCoverSleepScreen)(); } void SleepActivity::renderBlankSleepScreen() const { renderer.clearScreen(); renderer.displayBuffer(HalDisplay::HALF_REFRESH); }