942 lines
36 KiB
C++
942 lines
36 KiB
C++
#include "SleepActivity.h"
|
|
|
|
#include <Epub.h>
|
|
#include <Epub/converters/PngToFramebufferConverter.h>
|
|
#include <FsHelpers.h>
|
|
#include <GfxRenderer.h>
|
|
#include <HalStorage.h>
|
|
#include <I18n.h>
|
|
#include <PNGdec.h>
|
|
#include <Serialization.h>
|
|
#include <Txt.h>
|
|
#include <Xtc.h>
|
|
#include <esp_system.h>
|
|
|
|
#include <algorithm>
|
|
#include <memory>
|
|
#include <new>
|
|
|
|
#include "../reader/EpubReaderActivity.h"
|
|
#include "../reader/TxtReaderActivity.h"
|
|
#include "../reader/XtcReaderActivity.h"
|
|
#include "CrossPointSettings.h"
|
|
#include "CrossPointState.h"
|
|
#include "components/UITheme.h"
|
|
#include "fontIds.h"
|
|
#include "images/Logo120.h"
|
|
|
|
namespace {
|
|
|
|
// Context passed through PNGdec's decode() user-pointer to the per-scanline draw callback.
|
|
struct PngOverlayCtx {
|
|
const GfxRenderer* renderer;
|
|
int screenW;
|
|
int screenH;
|
|
int srcWidth;
|
|
int dstWidth;
|
|
int dstX;
|
|
int dstY;
|
|
float yScale;
|
|
int lastDstY;
|
|
// Color-key transparency (tRNS chunk) for TRUECOLOR and GRAYSCALE images.
|
|
// Initialized lazily on the first draw callback because tRNS is processed during decode(),
|
|
// not during open() — so hasAlpha()/getTransparentColor() are only valid once decode() starts.
|
|
// -2 = not yet read; -1 = no color key; >=0 = 0x00RRGGBB (TRUECOLOR) or low-byte gray.
|
|
int32_t transparentColor;
|
|
PNG* pngObj; // for lazy-init of transparentColor on first callback
|
|
};
|
|
|
|
// PNGdec file I/O callbacks — mirror the pattern in PngToFramebufferConverter.cpp.
|
|
void* pngSleepOpen(const char* filename, int32_t* size) {
|
|
FsFile* f = new FsFile(); // NOLINT(cppcoreguidelines-owning-memory) — ownership transferred via void* to PNGdec callbacks
|
|
if (!Storage.openFileForRead("SLP", std::string(filename), *f)) {
|
|
delete f; // NOLINT(cppcoreguidelines-owning-memory)
|
|
return nullptr;
|
|
}
|
|
*size = f->size();
|
|
return f;
|
|
}
|
|
void pngSleepClose(void* handle) {
|
|
FsFile* f = reinterpret_cast<FsFile*>(handle);
|
|
if (f) {
|
|
f->close();
|
|
delete f; // NOLINT(cppcoreguidelines-owning-memory)
|
|
}
|
|
}
|
|
int32_t pngSleepRead(PNGFILE* pFile, uint8_t* pBuf, int32_t len) {
|
|
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
|
return f ? f->read(pBuf, len) : 0;
|
|
}
|
|
int32_t pngSleepSeek(PNGFILE* pFile, int32_t pos) {
|
|
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
|
if (!f) return -1;
|
|
return f->seek(pos);
|
|
}
|
|
|
|
bool renderPngSleepScreen(const std::string& filename, GfxRenderer& renderer, const BookOverlayInfo& overlayInfo) {
|
|
constexpr size_t MIN_FREE_HEAP = 60 * 1024; // PNG decoder ~42 KB + overhead
|
|
if (ESP.getFreeHeap() < MIN_FREE_HEAP) {
|
|
LOG_ERR("SLP", "Not enough heap for PNG sleep image: %s", filename.c_str());
|
|
return false;
|
|
}
|
|
|
|
const int pageWidth = renderer.getScreenWidth();
|
|
const int pageHeight = renderer.getScreenHeight();
|
|
|
|
renderer.clearScreen();
|
|
|
|
RenderConfig config;
|
|
config.x = 0;
|
|
config.y = 0;
|
|
config.maxWidth = pageWidth;
|
|
config.maxHeight = pageHeight;
|
|
config.useGrayscale = true;
|
|
config.useDithering = true;
|
|
config.ditherMode = ImageDitherMode::Bayer;
|
|
config.performanceMode = false;
|
|
config.useExactDimensions = false;
|
|
|
|
// Overlay drawing is shared across all three rendering passes (BW + LSB + MSB) so the
|
|
// text appears on every plane. Captured by reference so the lambda sees the renderer.
|
|
const auto drawOverlay = [&]() {
|
|
if (overlayInfo.progressText.empty()) {
|
|
return;
|
|
}
|
|
const int lineHeight12 = renderer.getLineHeight(BOOKERLY_12_FONT_ID);
|
|
const int lineHeight10 = renderer.getLineHeight(UI_10_FONT_ID);
|
|
constexpr int lineSpacing = 3;
|
|
constexpr int sectionSpacing = 10;
|
|
const int maxTextWidth = pageWidth - 20;
|
|
|
|
int textBlockHeight = 0;
|
|
if (!overlayInfo.title.empty()) {
|
|
textBlockHeight += lineHeight12;
|
|
if (!overlayInfo.author.empty()) {
|
|
textBlockHeight += lineSpacing;
|
|
} else if (!overlayInfo.progressText.empty()) {
|
|
textBlockHeight += sectionSpacing;
|
|
}
|
|
}
|
|
if (!overlayInfo.author.empty()) {
|
|
textBlockHeight += lineHeight10;
|
|
if (!overlayInfo.progressText.empty()) {
|
|
textBlockHeight += sectionSpacing;
|
|
}
|
|
}
|
|
if (!overlayInfo.progressText.empty()) {
|
|
textBlockHeight += lineHeight10;
|
|
}
|
|
|
|
const int overlayY = pageHeight - textBlockHeight - (lineHeight12 / 3) - (lineHeight10 * 2 / 3);
|
|
int y = overlayY + (lineHeight12 / 3);
|
|
if (!overlayInfo.title.empty()) {
|
|
const std::string title = renderer.truncatedText(BOOKERLY_12_FONT_ID, overlayInfo.title.c_str(), maxTextWidth);
|
|
renderer.drawText(BOOKERLY_12_FONT_ID, 10, y, title.c_str(), true);
|
|
y += lineHeight12;
|
|
if (!overlayInfo.author.empty()) {
|
|
y += lineSpacing;
|
|
} else if (!overlayInfo.progressText.empty()) {
|
|
y += sectionSpacing;
|
|
}
|
|
}
|
|
if (!overlayInfo.author.empty()) {
|
|
const std::string author = renderer.truncatedText(UI_10_FONT_ID, overlayInfo.author.c_str(), maxTextWidth);
|
|
renderer.drawText(UI_10_FONT_ID, 10, y, author.c_str(), true);
|
|
y += lineHeight10;
|
|
if (!overlayInfo.progressText.empty()) {
|
|
y += sectionSpacing;
|
|
}
|
|
}
|
|
if (!overlayInfo.progressText.empty()) {
|
|
const std::string progress =
|
|
renderer.truncatedText(UI_10_FONT_ID, overlayInfo.progressText.c_str(), maxTextWidth);
|
|
renderer.drawText(UI_10_FONT_ID, 10, y, progress.c_str(), true);
|
|
}
|
|
};
|
|
|
|
PngToFramebufferConverter decoder;
|
|
|
|
// Pass 1: BW plane — mirrors SleepActivity::renderBitmapSleepScreen so the BW carrier
|
|
// matches the 4-level quantization layered on top via the LSB/MSB planes.
|
|
renderer.setRenderMode(GfxRenderer::BW);
|
|
renderer.clearScreen();
|
|
if (!decoder.decodeToFramebuffer(filename, renderer, config)) {
|
|
LOG_DBG("SLP", "PNG sleep image decode failed: %s", filename.c_str());
|
|
return false;
|
|
}
|
|
drawOverlay();
|
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
|
|
|
// Pass 2: GRAYSCALE_LSB plane.
|
|
renderer.clearScreen(0x00);
|
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
|
if (!decoder.decodeToFramebuffer(filename, renderer, config)) {
|
|
LOG_DBG("SLP", "PNG sleep image LSB decode failed: %s", filename.c_str());
|
|
renderer.setRenderMode(GfxRenderer::BW);
|
|
return false;
|
|
}
|
|
drawOverlay();
|
|
renderer.copyGrayscaleLsbBuffers();
|
|
|
|
// Pass 3: GRAYSCALE_MSB plane.
|
|
renderer.clearScreen(0x00);
|
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
|
if (!decoder.decodeToFramebuffer(filename, renderer, config)) {
|
|
LOG_DBG("SLP", "PNG sleep image MSB decode failed: %s", filename.c_str());
|
|
renderer.setRenderMode(GfxRenderer::BW);
|
|
return false;
|
|
}
|
|
drawOverlay();
|
|
renderer.copyGrayscaleMsbBuffers();
|
|
|
|
renderer.displayGrayBuffer();
|
|
renderer.setRenderMode(GfxRenderer::BW);
|
|
return true;
|
|
}
|
|
|
|
// Per-scanline draw callback for PNG overlay compositing.
|
|
// Transparent pixels (alpha < 128) are skipped so the reader page shows through.
|
|
// Opaque pixels are drawn in their grayscale brightness (dark → black, light → white).
|
|
int pngOverlayDraw(PNGDRAW* pDraw) {
|
|
PngOverlayCtx* ctx = reinterpret_cast<PngOverlayCtx*>(pDraw->pUser);
|
|
|
|
// Lazy-init: tRNS chunk is processed during decode() before any IDAT data, so by the time
|
|
// the first draw callback fires, hasAlpha() / getTransparentColor() are already valid.
|
|
if (ctx->transparentColor == -2) {
|
|
const int pt = pDraw->iPixelType;
|
|
ctx->transparentColor = (pDraw->iHasAlpha && (pt == PNG_PIXEL_TRUECOLOR || pt == PNG_PIXEL_GRAYSCALE))
|
|
? (int32_t)ctx->pngObj->getTransparentColor()
|
|
: -1;
|
|
}
|
|
|
|
const int destY = ctx->dstY + (int)(pDraw->y * ctx->yScale);
|
|
if (destY == ctx->lastDstY) return 1; // skip duplicate rows from Y scaling
|
|
ctx->lastDstY = destY;
|
|
if (destY < 0 || destY >= ctx->screenH) return 1;
|
|
|
|
const int srcWidth = ctx->srcWidth;
|
|
const int dstWidth = ctx->dstWidth;
|
|
const uint8_t* pixels = pDraw->pPixels;
|
|
const int pixelType = pDraw->iPixelType;
|
|
const int hasAlpha = pDraw->iHasAlpha;
|
|
|
|
int srcX = 0, error = 0;
|
|
for (int dstX = 0; dstX < dstWidth; dstX++) {
|
|
const int outX = ctx->dstX + dstX;
|
|
if (outX >= 0 && outX < ctx->screenW) {
|
|
uint8_t alpha = 255, gray = 0;
|
|
switch (pixelType) {
|
|
case PNG_PIXEL_TRUECOLOR_ALPHA: {
|
|
const uint8_t* p = &pixels[srcX * 4];
|
|
alpha = p[3];
|
|
gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
|
break;
|
|
}
|
|
case PNG_PIXEL_GRAY_ALPHA:
|
|
gray = pixels[srcX * 2];
|
|
alpha = pixels[srcX * 2 + 1];
|
|
break;
|
|
case PNG_PIXEL_TRUECOLOR: {
|
|
const uint8_t* p = &pixels[srcX * 3];
|
|
gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
|
// tRNS color-key: if pixel matches the designated transparent color, skip it
|
|
if (ctx->transparentColor >= 0 && p[0] == (uint8_t)((ctx->transparentColor >> 16) & 0xFF) &&
|
|
p[1] == (uint8_t)((ctx->transparentColor >> 8) & 0xFF) &&
|
|
p[2] == (uint8_t)(ctx->transparentColor & 0xFF)) {
|
|
alpha = 0;
|
|
}
|
|
break;
|
|
}
|
|
case PNG_PIXEL_GRAYSCALE:
|
|
gray = pixels[srcX];
|
|
// tRNS color-key: transparent gray value stored in low byte
|
|
if (ctx->transparentColor >= 0 && gray == (uint8_t)(ctx->transparentColor & 0xFF)) {
|
|
alpha = 0;
|
|
}
|
|
break;
|
|
case PNG_PIXEL_INDEXED:
|
|
if (pDraw->pPalette) {
|
|
const uint8_t idx = pixels[srcX];
|
|
const uint8_t* p = &pDraw->pPalette[idx * 3];
|
|
gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
|
if (hasAlpha) alpha = pDraw->pPalette[768 + idx];
|
|
}
|
|
break;
|
|
default:
|
|
gray = pixels[srcX];
|
|
break;
|
|
}
|
|
|
|
if (alpha >= 128) {
|
|
ctx->renderer->drawPixel(outX, destY, gray < 128); // true = black, false = white
|
|
}
|
|
// alpha < 128: transparent — leave the reader page pixel intact
|
|
}
|
|
|
|
// Bresenham-style X stepping (handles downscaling; 1:1 when srcWidth == dstWidth)
|
|
error += srcWidth;
|
|
while (error >= dstWidth) {
|
|
error -= dstWidth;
|
|
srcX++;
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// Collects full paths of valid image files from /.sleep and /sleep, with no preference between
|
|
// the two directories. BMP files are validated by parsing their headers; invalid BMPs are skipped.
|
|
// When allowPng is true, .png files are also accepted (PNG validation happens later at decode time).
|
|
std::vector<std::string> collectSleepImages(bool allowPng) {
|
|
std::vector<std::string> files;
|
|
for (const char* sleepDir : {"/.sleep", "/sleep"}) {
|
|
auto dir = Storage.open(sleepDir);
|
|
if (!dir || !dir.isDirectory()) {
|
|
if (dir) dir.close();
|
|
continue;
|
|
}
|
|
char name[500];
|
|
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;
|
|
}
|
|
const bool isBmp = FsHelpers::hasBmpExtension(filename);
|
|
const bool isPng = allowPng && FsHelpers::hasPngExtension(filename);
|
|
if (!isBmp && !isPng) {
|
|
file.close();
|
|
continue;
|
|
}
|
|
if (isBmp) {
|
|
Bitmap bmp(file);
|
|
if (bmp.parseHeaders() != BmpReaderError::Ok) {
|
|
LOG_DBG("SLP", "Skipping invalid BMP file: %s", name);
|
|
file.close();
|
|
continue;
|
|
}
|
|
}
|
|
files.emplace_back(std::string(sleepDir) + "/" + filename);
|
|
file.close();
|
|
}
|
|
dir.close();
|
|
}
|
|
// Sort by full path so the order is deterministic across reboots — required for sequential
|
|
// pick mode, harmless for random pick mode.
|
|
std::sort(files.begin(), files.end());
|
|
return files;
|
|
}
|
|
|
|
// Picks the next file index based on the user's pick mode.
|
|
// RANDOM: uniform random with single reroll to avoid immediate repeats.
|
|
// SEQUENTIAL: advances from APP_STATE.lastSleepImage, wrapping at numFiles.
|
|
size_t pickSleepImageIndex(size_t numFiles) {
|
|
if (SETTINGS.sleepImagePickMode == CrossPointSettings::SLEEP_IMAGE_PICK_MODE::PICK_SEQUENTIAL) {
|
|
const size_t last = APP_STATE.lastSleepImage;
|
|
if (last == SIZE_MAX || last >= numFiles) return 0;
|
|
return (last + 1) % numFiles;
|
|
}
|
|
size_t idx = static_cast<size_t>(esp_random() % numFiles);
|
|
while (numFiles > 1 && APP_STATE.lastSleepImage != SIZE_MAX && idx == APP_STATE.lastSleepImage) {
|
|
idx = static_cast<size_t>(esp_random() % numFiles);
|
|
}
|
|
return idx;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void SleepActivity::onEnter() {
|
|
Activity::onEnter();
|
|
RenderLock lock(*this);
|
|
// For OVERLAY mode the popup is suppressed so the frame buffer (reader page) stays intact
|
|
if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::OVERLAY) {
|
|
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();
|
|
case (CrossPointSettings::SLEEP_SCREEN_MODE::OVERLAY):
|
|
return renderOverlaySleepScreen();
|
|
default:
|
|
return renderDefaultSleepScreen();
|
|
}
|
|
}
|
|
|
|
void SleepActivity::renderCustomSleepScreen() const {
|
|
const BookOverlayInfo overlayInfo{};
|
|
const bool shouldLoadOverlayInfo =
|
|
SETTINGS.sleepCoverOverlay != 0 && APP_STATE.lastSleepFromReader && !APP_STATE.openEpubPath.empty();
|
|
|
|
// An explicitly selected custom sleep image should override random images from /.sleep or /sleep.
|
|
FsFile explicitSleepFile;
|
|
if (Storage.openFileForRead("SLP", "/sleep.bmp", explicitSleepFile)) {
|
|
Bitmap bitmap(explicitSleepFile, true);
|
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
|
LOG_DBG("SLP", "Loading explicit custom sleep image: /sleep.bmp");
|
|
const BookOverlayInfo resolvedOverlayInfo =
|
|
shouldLoadOverlayInfo ? getBookOverlayInfo(APP_STATE.openEpubPath) : overlayInfo;
|
|
renderBitmapSleepScreen(bitmap, resolvedOverlayInfo);
|
|
explicitSleepFile.close();
|
|
return;
|
|
}
|
|
explicitSleepFile.close();
|
|
}
|
|
if (Storage.openFileForRead("SLP", "/sleep.png", explicitSleepFile)) {
|
|
explicitSleepFile.close();
|
|
const BookOverlayInfo resolvedOverlayInfo =
|
|
shouldLoadOverlayInfo ? getBookOverlayInfo(APP_STATE.openEpubPath) : overlayInfo;
|
|
LOG_DBG("SLP", "Loading explicit custom sleep image: /sleep.png");
|
|
if (renderPngSleepScreen("/sleep.png", renderer, resolvedOverlayInfo)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Collect valid BMP and PNG files from both /.sleep and /sleep directories (no preference between them)
|
|
const auto files = collectSleepImages(/*allowPng=*/true);
|
|
const auto numFiles = files.size();
|
|
if (numFiles > 0) {
|
|
const auto pickedIndex = pickSleepImageIndex(numFiles);
|
|
APP_STATE.lastSleepImage = pickedIndex;
|
|
APP_STATE.saveToFile();
|
|
const auto& filename = files[pickedIndex];
|
|
LOG_DBG("SLP", "Loading sleep image: %s", filename.c_str());
|
|
const BookOverlayInfo resolvedOverlayInfo =
|
|
shouldLoadOverlayInfo ? getBookOverlayInfo(APP_STATE.openEpubPath) : overlayInfo;
|
|
if (FsHelpers::hasPngExtension(filename)) {
|
|
if (renderPngSleepScreen(filename, renderer, resolvedOverlayInfo)) {
|
|
return;
|
|
}
|
|
} else {
|
|
FsFile file;
|
|
if (Storage.openFileForRead("SLP", filename, file)) {
|
|
delay(100);
|
|
Bitmap bitmap(file, true);
|
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
|
renderBitmapSleepScreen(bitmap, resolvedOverlayInfo);
|
|
file.close();
|
|
return;
|
|
}
|
|
file.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
BookOverlayInfo SleepActivity::getBookOverlayInfo(const std::string& bookPath) const {
|
|
BookOverlayInfo info;
|
|
|
|
if (FsHelpers::checkFileExtension(bookPath, ".xtc") || FsHelpers::checkFileExtension(bookPath, ".xtch")) {
|
|
Xtc xtc(bookPath, "/.crosspoint");
|
|
if (xtc.load()) {
|
|
info.title = xtc.getTitle();
|
|
info.author = xtc.getAuthor();
|
|
|
|
FsFile f;
|
|
if (Storage.openFileForRead("SLP", xtc.getCachePath() + "/progress.bin", f)) {
|
|
uint8_t data[4];
|
|
if (f.read(data, 4) == 4) {
|
|
uint32_t currentPage = static_cast<uint32_t>(data[0]) | (static_cast<uint32_t>(data[1]) << 8) |
|
|
(static_cast<uint32_t>(data[2]) << 16) | (static_cast<uint32_t>(data[3]) << 24);
|
|
uint32_t totalPages = xtc.getPageCount();
|
|
float progress = xtc.calculateProgress(currentPage) * 100.0f;
|
|
char buf[64];
|
|
snprintf(buf, sizeof(buf), tr(STR_OVERLAY_READING_PROGRESS), (unsigned long)currentPage + 1, totalPages,
|
|
progress);
|
|
info.progressText = buf;
|
|
}
|
|
f.close();
|
|
}
|
|
}
|
|
} else if (FsHelpers::checkFileExtension(bookPath, ".txt")) {
|
|
Txt txt(bookPath, "/.crosspoint");
|
|
if (txt.load()) {
|
|
info.title = txt.getTitle();
|
|
|
|
FsFile f;
|
|
if (Storage.openFileForRead("SLP", txt.getCachePath() + "/progress.bin", f)) {
|
|
uint8_t data[4];
|
|
if (f.read(data, 4) == 4) {
|
|
uint32_t currentPage = data[0] + (data[1] << 8);
|
|
|
|
uint32_t totalPages = 0;
|
|
FsFile indexFile;
|
|
if (Storage.openFileForRead("SLP", txt.getCachePath() + "/index.bin", indexFile)) {
|
|
uint32_t magic;
|
|
serialization::readPod(indexFile, magic);
|
|
uint8_t version;
|
|
serialization::readPod(indexFile, version);
|
|
static constexpr uint32_t INDEX_CACHE_MAGIC = 0x54585449; // "TXTI"
|
|
static constexpr uint8_t INDEX_CACHE_VERSION = 2;
|
|
if (magic == INDEX_CACHE_MAGIC && version == INDEX_CACHE_VERSION) {
|
|
indexFile.seek(32);
|
|
serialization::readPod(indexFile, totalPages);
|
|
}
|
|
indexFile.close();
|
|
}
|
|
|
|
if (totalPages > 0) {
|
|
float progress = (currentPage + 1) * 100.0f / totalPages;
|
|
char buf[64];
|
|
snprintf(buf, sizeof(buf), tr(STR_OVERLAY_READING_PROGRESS), (unsigned long)currentPage + 1, totalPages,
|
|
progress);
|
|
info.progressText = buf;
|
|
} else {
|
|
char buf[64];
|
|
snprintf(buf, sizeof(buf), tr(STR_OVERLAY_READING_PROGRESS_NO_TOTAL), (unsigned long)currentPage + 1);
|
|
info.progressText = buf;
|
|
}
|
|
}
|
|
f.close();
|
|
}
|
|
}
|
|
} else if (FsHelpers::checkFileExtension(bookPath, ".epub")) {
|
|
Epub epub(bookPath, "/.crosspoint");
|
|
if (epub.load(true, true)) {
|
|
info.title = epub.getTitle();
|
|
info.author = epub.getAuthor();
|
|
|
|
FsFile f;
|
|
if (Storage.openFileForRead("SLP", epub.getCachePath() + "/progress.bin", f)) {
|
|
uint8_t data[6];
|
|
const int dataSize = f.read(data, 6);
|
|
if (dataSize == 4 || dataSize == 6) {
|
|
int currentSpineIndex = data[0] + (data[1] << 8);
|
|
int currentPage = data[2] + (data[3] << 8);
|
|
int pageCount = (dataSize == 6) ? (data[4] + (data[5] << 8)) : 0;
|
|
if (pageCount > 0) {
|
|
float chapterProgress = static_cast<float>(currentPage) / static_cast<float>(pageCount);
|
|
float bookProgress = epub.calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
|
|
|
const int tocIndex = epub.getTocIndexForSpineIndex(currentSpineIndex);
|
|
if (tocIndex != -1) {
|
|
const auto tocItem = epub.getTocItem(tocIndex);
|
|
info.chapterName = tocItem.title;
|
|
char suffix[64];
|
|
snprintf(suffix, sizeof(suffix), tr(STR_OVERLAY_CHAPTER_PAGE_SUFFIX), currentPage + 1, pageCount,
|
|
bookProgress);
|
|
info.progressSuffix = suffix;
|
|
info.progressText = info.chapterName + info.progressSuffix;
|
|
} else {
|
|
char buf[80];
|
|
snprintf(buf, sizeof(buf), tr(STR_OVERLAY_READING_PROGRESS), (unsigned long)currentPage + 1,
|
|
(unsigned)pageCount, bookProgress);
|
|
info.progressText = buf;
|
|
}
|
|
} else {
|
|
char buf[64];
|
|
snprintf(buf, sizeof(buf), tr(STR_OVERLAY_READING_PROGRESS_NO_TOTAL), (unsigned long)currentPage + 1);
|
|
info.progressText = buf;
|
|
}
|
|
}
|
|
f.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
return info;
|
|
}
|
|
|
|
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOverlayInfo& overlayInfo) const {
|
|
int x, y;
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
float cropX = 0, cropY = 0;
|
|
const bool topAlignForCoverFit = (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::COVER ||
|
|
SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM) &&
|
|
SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::FIT;
|
|
|
|
LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight);
|
|
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
|
|
// image will scale, make sure placement is right
|
|
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
|
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
|
|
|
|
LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
|
|
if (ratio > screenRatio) {
|
|
// image wider than viewport ratio, scaled down image 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<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
|
}
|
|
x = 0;
|
|
y = topAlignForCoverFit
|
|
? 0
|
|
: std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
|
|
LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
|
|
} else {
|
|
// image taller than viewport ratio, scaled down image 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<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
|
|
}
|
|
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
|
|
y = 0;
|
|
LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
|
|
}
|
|
} else {
|
|
// center the image
|
|
x = (pageWidth - bitmap.getWidth()) / 2;
|
|
y = topAlignForCoverFit ? 0 : (pageHeight - bitmap.getHeight()) / 2;
|
|
}
|
|
|
|
LOG_DBG("SLP", "drawing to %d x %d", x, y);
|
|
renderer.clearScreen();
|
|
|
|
const bool hasGreyscale = bitmap.hasGreyscale() &&
|
|
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
|
|
|
|
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
|
|
|
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
|
|
renderer.invertScreen();
|
|
}
|
|
|
|
const uint8_t overlayMode = SETTINGS.sleepCoverOverlay;
|
|
const auto drawOverlay = [&]() {
|
|
const bool hasTitle = !overlayInfo.title.empty();
|
|
const bool hasProgress = !overlayInfo.progressText.empty();
|
|
const bool hasAuthor = !overlayInfo.author.empty();
|
|
// If there is no overlay progress text, do not draw the overlay background block.
|
|
if (!hasProgress) {
|
|
return;
|
|
}
|
|
|
|
const int lineHeight12 = renderer.getLineHeight(BOOKERLY_12_FONT_ID);
|
|
const int lineHeight10 = renderer.getLineHeight(UI_10_FONT_ID);
|
|
constexpr int lineSpacing = 3;
|
|
constexpr int sectionSpacing = 10;
|
|
const int availableWidth = pageWidth - 20;
|
|
|
|
int textBlockHeight = lineHeight10; // progress line (always present here)
|
|
if (hasTitle) {
|
|
textBlockHeight += lineHeight12;
|
|
textBlockHeight += hasAuthor ? lineSpacing : sectionSpacing;
|
|
}
|
|
if (hasAuthor) {
|
|
textBlockHeight += lineHeight10 + sectionSpacing;
|
|
}
|
|
|
|
const bool textBlack = (overlayMode != 3);
|
|
const int topPadding = lineHeight12 / 3;
|
|
const int bottomPadding = lineHeight10 * 2 / 3;
|
|
const int overlayHeight = textBlockHeight + topPadding + bottomPadding;
|
|
const int overlayY = pageHeight - overlayHeight;
|
|
|
|
if (overlayMode == 2) {
|
|
renderer.fillRectDither(0, overlayY, pageWidth, overlayHeight, Color::LightGray);
|
|
} else {
|
|
renderer.fillRect(0, overlayY, pageWidth, overlayHeight, overlayMode == 3);
|
|
}
|
|
|
|
int currentY = overlayY + topPadding;
|
|
|
|
if (hasTitle) {
|
|
const std::string titleStr =
|
|
renderer.truncatedText(BOOKERLY_12_FONT_ID, overlayInfo.title.c_str(), availableWidth, EpdFontFamily::BOLD);
|
|
renderer.drawCenteredText(BOOKERLY_12_FONT_ID, currentY, titleStr.c_str(), textBlack, EpdFontFamily::BOLD);
|
|
currentY += lineHeight12 + (hasAuthor ? lineSpacing : sectionSpacing);
|
|
}
|
|
|
|
if (hasAuthor) {
|
|
const std::string authorStr = renderer.truncatedText(UI_10_FONT_ID, overlayInfo.author.c_str(), availableWidth);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, currentY, authorStr.c_str(), textBlack);
|
|
currentY += lineHeight10 + sectionSpacing;
|
|
}
|
|
|
|
std::string progressStr;
|
|
if (!overlayInfo.chapterName.empty()) {
|
|
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, overlayInfo.progressSuffix.c_str());
|
|
const int maxChapterWidth = availableWidth - suffixWidth;
|
|
const std::string truncatedChapter =
|
|
maxChapterWidth > 0 ? renderer.truncatedText(UI_10_FONT_ID, overlayInfo.chapterName.c_str(), maxChapterWidth)
|
|
: "";
|
|
progressStr = truncatedChapter + overlayInfo.progressSuffix;
|
|
} else {
|
|
progressStr = renderer.truncatedText(UI_10_FONT_ID, overlayInfo.progressText.c_str(), availableWidth);
|
|
}
|
|
renderer.drawCenteredText(UI_10_FONT_ID, currentY, progressStr.c_str(), textBlack);
|
|
};
|
|
|
|
drawOverlay();
|
|
|
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
|
|
|
if (hasGreyscale) {
|
|
bitmap.rewindToData();
|
|
renderer.clearScreen(0x00);
|
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
|
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
|
drawOverlay();
|
|
renderer.copyGrayscaleLsbBuffers();
|
|
|
|
bitmap.rewindToData();
|
|
renderer.clearScreen(0x00);
|
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
|
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
|
drawOverlay();
|
|
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;
|
|
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
|
|
|
|
// Check if the current book is XTC, TXT, or EPUB
|
|
if (FsHelpers::hasXtcExtension(APP_STATE.openEpubPath)) {
|
|
// 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_ERR("SLP", "Failed to generate XTC cover bmp");
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
coverBmpPath = lastXtc.getCoverBmpPath();
|
|
} else if (FsHelpers::hasTxtExtension(APP_STATE.openEpubPath)) {
|
|
// 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_ERR("SLP", "No cover image found for TXT file");
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
coverBmpPath = lastTxt.getCoverBmpPath();
|
|
} else if (FsHelpers::hasEpubExtension(APP_STATE.openEpubPath)) {
|
|
// 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_ERR("SLP", "Failed to generate cover bmp");
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
coverBmpPath = lastEpub.getCoverBmpPath(cropped);
|
|
} else {
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
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());
|
|
const uint8_t overlayMode = SETTINGS.sleepCoverOverlay;
|
|
const BookOverlayInfo coverOverlayInfo =
|
|
overlayMode != 0 ? getBookOverlayInfo(APP_STATE.openEpubPath) : BookOverlayInfo{};
|
|
renderBitmapSleepScreen(bitmap, coverOverlayInfo);
|
|
file.close();
|
|
return;
|
|
}
|
|
file.close();
|
|
}
|
|
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
void SleepActivity::renderBlankSleepScreen() const {
|
|
renderer.clearScreen();
|
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
|
}
|
|
|
|
void SleepActivity::renderOverlaySleepScreen() const {
|
|
// Overlay pictures always use portrait orientation regardless of the reader's orientation preference.
|
|
const auto savedOrientation = renderer.getOrientation();
|
|
renderer.setOrientation(GfxRenderer::Portrait);
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
|
|
// Step 1: Ensure the frame buffer contains the reader page.
|
|
// When coming from a reader activity the frame buffer already holds the page.
|
|
// When coming from a non-reader activity we re-render it from the saved progress.
|
|
if (!APP_STATE.lastSleepFromReader && !APP_STATE.openEpubPath.empty()) {
|
|
const auto& path = APP_STATE.openEpubPath;
|
|
bool rendered = false;
|
|
|
|
if (FsHelpers::checkFileExtension(path, ".xtc") || FsHelpers::checkFileExtension(path, ".xtch")) {
|
|
rendered = XtcReaderActivity::drawCurrentPageToBuffer(path, renderer);
|
|
} else if (FsHelpers::checkFileExtension(path, ".txt")) {
|
|
rendered = TxtReaderActivity::drawCurrentPageToBuffer(path, renderer);
|
|
} else if (FsHelpers::checkFileExtension(path, ".epub")) {
|
|
rendered = EpubReaderActivity::drawCurrentPageToBuffer(path, renderer);
|
|
}
|
|
|
|
if (!rendered) {
|
|
LOG_DBG("SLP", "Page re-render failed, using white background");
|
|
renderer.clearScreen();
|
|
}
|
|
}
|
|
|
|
// Step 2: Load the overlay image using the same selection logic as renderCustomSleepScreen.
|
|
// BMP: white pixels are skipped (transparent via drawBitmap), black pixels composited on top.
|
|
// PNG: pixels with alpha < 128 are skipped; opaque pixels are drawn with their grayscale value.
|
|
auto tryDrawOverlay = [&](const std::string& filename) -> bool {
|
|
FsFile file;
|
|
if (!Storage.openFileForRead("SLP", filename, file)) return false;
|
|
Bitmap bitmap(file, true);
|
|
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
|
|
file.close();
|
|
return false;
|
|
}
|
|
|
|
int x, y;
|
|
float cropX = 0, cropY = 0;
|
|
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
|
|
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
|
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
|
|
if (ratio > screenRatio) {
|
|
x = 0;
|
|
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
|
|
} else {
|
|
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
|
|
y = 0;
|
|
}
|
|
} else {
|
|
x = (pageWidth - bitmap.getWidth()) / 2;
|
|
y = (pageHeight - bitmap.getHeight()) / 2;
|
|
}
|
|
|
|
// Draw without clearScreen so the reader page remains in the frame buffer beneath
|
|
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
|
file.close();
|
|
return true;
|
|
};
|
|
|
|
auto tryDrawPngOverlay = [&](const std::string& filename) -> bool {
|
|
constexpr size_t MIN_FREE_HEAP = 60 * 1024; // PNG decoder ~42 KB + overhead
|
|
if (ESP.getFreeHeap() < MIN_FREE_HEAP) {
|
|
LOG_ERR("SLP", "Not enough heap for PNG overlay decoder");
|
|
return false;
|
|
}
|
|
std::unique_ptr<PNG> png(new (std::nothrow) PNG());
|
|
if (!png) return false;
|
|
|
|
int rc = png->open(filename.c_str(), pngSleepOpen, pngSleepClose, pngSleepRead, pngSleepSeek, pngOverlayDraw);
|
|
if (rc != PNG_SUCCESS) {
|
|
LOG_DBG("SLP", "PNG open failed: %s (%d)", filename.c_str(), rc);
|
|
return false;
|
|
}
|
|
|
|
const int srcW = png->getWidth(), srcH = png->getHeight();
|
|
float yScale = 1.0f;
|
|
int dstW = srcW, dstH = srcH;
|
|
if (srcW > pageWidth || srcH > pageHeight) {
|
|
const float scaleX = (float)pageWidth / srcW, scaleY = (float)pageHeight / srcH;
|
|
const float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
|
dstW = (int)(srcW * scale);
|
|
dstH = (int)(srcH * scale);
|
|
yScale = (float)dstH / srcH;
|
|
}
|
|
|
|
PngOverlayCtx ctx;
|
|
ctx.renderer = &renderer;
|
|
ctx.screenW = pageWidth;
|
|
ctx.screenH = pageHeight;
|
|
ctx.srcWidth = srcW;
|
|
ctx.dstWidth = dstW;
|
|
ctx.dstX = (pageWidth - dstW) / 2;
|
|
ctx.dstY = (pageHeight - dstH) / 2;
|
|
ctx.yScale = yScale;
|
|
ctx.lastDstY = -1;
|
|
ctx.transparentColor = -2; // will be resolved on first draw callback (after tRNS is parsed)
|
|
ctx.pngObj = png;
|
|
|
|
rc = png->decode(&ctx, 0);
|
|
png->close();
|
|
return rc == PNG_SUCCESS;
|
|
};
|
|
|
|
// Collect images from both /.sleep and /sleep directories (no preference between them).
|
|
// Accepts both .bmp and .png files; .bmp headers are validated during the scan.
|
|
bool overlayDrawn = false;
|
|
const auto files = collectSleepImages(/*allowPng=*/true);
|
|
const auto numFiles = files.size();
|
|
if (numFiles > 0) {
|
|
const auto pickedIndex = pickSleepImageIndex(numFiles);
|
|
APP_STATE.lastSleepImage = pickedIndex;
|
|
APP_STATE.saveToFile();
|
|
const std::string& selected = files[pickedIndex];
|
|
if (FsHelpers::hasPngExtension(selected)) {
|
|
overlayDrawn = tryDrawPngOverlay(selected);
|
|
} else {
|
|
overlayDrawn = tryDrawOverlay(selected);
|
|
}
|
|
}
|
|
|
|
if (!overlayDrawn) {
|
|
overlayDrawn = tryDrawOverlay("/sleep.bmp");
|
|
}
|
|
if (!overlayDrawn) {
|
|
overlayDrawn = tryDrawPngOverlay("/sleep.png");
|
|
}
|
|
|
|
if (!overlayDrawn) {
|
|
LOG_DBG("SLP", "No overlay image found, displaying page without overlay");
|
|
}
|
|
|
|
renderer.setOrientation(savedOrientation);
|
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
|
}
|