mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 22:57:50 +03:00
Add support for XTC (XTeink X4 native) ebook format with pre-rendered bitmap pages. Key changes: - Add Xtc library with XtcParser for reading XTC files - XTC format: 22-byte XTG page headers with 480x800 1-bit bitmaps - XtcReaderActivity for displaying XTC pages on e-ink display - Correct bit polarity: 0=black, 1=white in XTC format - FileSelectionActivity: detect and handle .xtc files - ReaderActivity: route to XtcReaderActivity for XTC files - Cover BMP generation from first XTC page XTC pages include pre-rendered status bar with page numbers and progress, so no additional overlay is needed.
312 lines
9.7 KiB
C++
312 lines
9.7 KiB
C++
/**
|
|
* XtcReaderActivity.cpp
|
|
*
|
|
* XTC ebook reader activity implementation
|
|
* Displays pre-rendered XTC pages on e-ink display
|
|
*/
|
|
|
|
#include "XtcReaderActivity.h"
|
|
|
|
#include <FsHelpers.h>
|
|
#include <GfxRenderer.h>
|
|
#include <InputManager.h>
|
|
|
|
#include "Battery.h"
|
|
#include "CrossPointSettings.h"
|
|
#include "CrossPointState.h"
|
|
#include "config.h"
|
|
|
|
namespace {
|
|
constexpr int pagesPerRefresh = 15;
|
|
constexpr unsigned long skipPageMs = 700;
|
|
constexpr unsigned long goHomeMs = 1000;
|
|
constexpr int statusBarHeight = 24;
|
|
constexpr int marginLeft = 10;
|
|
constexpr int marginRight = 10;
|
|
} // namespace
|
|
|
|
void XtcReaderActivity::taskTrampoline(void* param) {
|
|
auto* self = static_cast<XtcReaderActivity*>(param);
|
|
self->displayTaskLoop();
|
|
}
|
|
|
|
void XtcReaderActivity::onEnter() {
|
|
Activity::onEnter();
|
|
|
|
if (!xtc) {
|
|
return;
|
|
}
|
|
|
|
renderingMutex = xSemaphoreCreateMutex();
|
|
|
|
xtc->setupCacheDir();
|
|
|
|
// Load saved progress
|
|
loadProgress();
|
|
|
|
// Save current XTC as last opened book
|
|
APP_STATE.openEpubPath = xtc->getPath();
|
|
APP_STATE.saveToFile();
|
|
|
|
// Trigger first update
|
|
updateRequired = true;
|
|
|
|
xTaskCreate(&XtcReaderActivity::taskTrampoline, "XtcReaderActivityTask",
|
|
4096, // Stack size (smaller than EPUB since no parsing needed)
|
|
this, // Parameters
|
|
1, // Priority
|
|
&displayTaskHandle // Task handle
|
|
);
|
|
}
|
|
|
|
void XtcReaderActivity::onExit() {
|
|
Activity::onExit();
|
|
|
|
// Wait until not rendering to delete task
|
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
if (displayTaskHandle) {
|
|
vTaskDelete(displayTaskHandle);
|
|
displayTaskHandle = nullptr;
|
|
}
|
|
vSemaphoreDelete(renderingMutex);
|
|
renderingMutex = nullptr;
|
|
xtc.reset();
|
|
}
|
|
|
|
void XtcReaderActivity::loop() {
|
|
// Long press BACK (1s+) goes directly to home
|
|
if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= goHomeMs) {
|
|
onGoHome();
|
|
return;
|
|
}
|
|
|
|
// Short press BACK goes to file selection
|
|
if (inputManager.wasReleased(InputManager::BTN_BACK) && inputManager.getHeldTime() < goHomeMs) {
|
|
onGoBack();
|
|
return;
|
|
}
|
|
|
|
const bool prevReleased =
|
|
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
|
|
const bool nextReleased =
|
|
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT);
|
|
|
|
if (!prevReleased && !nextReleased) {
|
|
return;
|
|
}
|
|
|
|
// Handle end of book
|
|
if (currentPage >= xtc->getPageCount()) {
|
|
currentPage = xtc->getPageCount() - 1;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
const bool skipPages = inputManager.getHeldTime() > skipPageMs;
|
|
const int skipAmount = skipPages ? 10 : 1;
|
|
|
|
if (prevReleased) {
|
|
if (currentPage >= static_cast<uint32_t>(skipAmount)) {
|
|
currentPage -= skipAmount;
|
|
} else {
|
|
currentPage = 0;
|
|
}
|
|
updateRequired = true;
|
|
} else if (nextReleased) {
|
|
currentPage += skipAmount;
|
|
if (currentPage >= xtc->getPageCount()) {
|
|
currentPage = xtc->getPageCount(); // Allow showing "End of book"
|
|
}
|
|
updateRequired = true;
|
|
}
|
|
}
|
|
|
|
void XtcReaderActivity::displayTaskLoop() {
|
|
while (true) {
|
|
if (updateRequired) {
|
|
updateRequired = false;
|
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
renderScreen();
|
|
xSemaphoreGive(renderingMutex);
|
|
}
|
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
|
}
|
|
}
|
|
|
|
void XtcReaderActivity::renderScreen() {
|
|
if (!xtc) {
|
|
return;
|
|
}
|
|
|
|
// Bounds check
|
|
if (currentPage >= xtc->getPageCount()) {
|
|
// Show end of book screen
|
|
renderer.clearScreen();
|
|
renderer.drawCenteredText(UI_FONT_ID, 300, "End of book", true, BOLD);
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
renderPage();
|
|
saveProgress();
|
|
}
|
|
|
|
void XtcReaderActivity::renderPage() {
|
|
const uint16_t pageWidth = xtc->getPageWidth();
|
|
const uint16_t pageHeight = xtc->getPageHeight();
|
|
|
|
// Calculate buffer size for one page (XTC is always 1-bit monochrome)
|
|
const size_t pageBufferSize = ((pageWidth + 7) / 8) * pageHeight;
|
|
|
|
// Allocate page buffer
|
|
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize));
|
|
if (!pageBuffer) {
|
|
Serial.printf("[%lu] [XTR] Failed to allocate page buffer (%lu bytes)\n", millis(), pageBufferSize);
|
|
renderer.clearScreen();
|
|
renderer.drawCenteredText(UI_FONT_ID, 300, "Memory error", true, BOLD);
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
// Load page data
|
|
size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize);
|
|
if (bytesRead == 0) {
|
|
Serial.printf("[%lu] [XTR] Failed to load page %lu\n", millis(), currentPage);
|
|
free(pageBuffer);
|
|
renderer.clearScreen();
|
|
renderer.drawCenteredText(UI_FONT_ID, 300, "Page load error", true, BOLD);
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
// Clear screen first
|
|
renderer.clearScreen();
|
|
|
|
// Copy page bitmap using GfxRenderer's drawPixel
|
|
// XTC stores 1-bit packed data in portrait (480x800) format
|
|
const size_t srcRowBytes = (pageWidth + 7) / 8; // 60 bytes for 480 width
|
|
|
|
// XTC pages are pre-rendered with status bar included, so render full page
|
|
const uint16_t maxSrcY = pageHeight;
|
|
|
|
for (uint16_t srcY = 0; srcY < maxSrcY; srcY++) {
|
|
const size_t srcRowStart = srcY * srcRowBytes;
|
|
|
|
for (uint16_t srcX = 0; srcX < pageWidth; srcX++) {
|
|
// Read source pixel (MSB first, bit 7 = leftmost pixel)
|
|
const size_t srcByte = srcRowStart + srcX / 8;
|
|
const size_t srcBit = 7 - (srcX % 8);
|
|
const bool isBlack = !((pageBuffer[srcByte] >> srcBit) & 1); // XTC: 0 = black, 1 = white
|
|
|
|
// Use GfxRenderer's drawPixel with logical portrait coordinates
|
|
// drawPixel(x, y, state) where state=true draws black
|
|
if (isBlack) {
|
|
renderer.drawPixel(srcX, srcY, true);
|
|
}
|
|
// White pixels are already cleared by clearScreen()
|
|
}
|
|
}
|
|
|
|
free(pageBuffer);
|
|
|
|
// XTC pages already have status bar pre-rendered, no need to add our own
|
|
|
|
// Display with appropriate refresh
|
|
if (pagesUntilFullRefresh <= 1) {
|
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
|
pagesUntilFullRefresh = pagesPerRefresh;
|
|
} else {
|
|
renderer.displayBuffer();
|
|
pagesUntilFullRefresh--;
|
|
}
|
|
|
|
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu\n", millis(), currentPage + 1, xtc->getPageCount());
|
|
}
|
|
|
|
void XtcReaderActivity::renderStatusBar() const {
|
|
const int screenWidth = GfxRenderer::getScreenWidth();
|
|
const int screenHeight = GfxRenderer::getScreenHeight();
|
|
constexpr int textY = 776;
|
|
|
|
// Calculate progress
|
|
const uint8_t progress = xtc->calculateProgress(currentPage);
|
|
|
|
// Right aligned: page number and progress
|
|
const std::string progressText = std::to_string(currentPage + 1) + "/" + std::to_string(xtc->getPageCount()) + " " +
|
|
std::to_string(progress) + "%";
|
|
const int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressText.c_str());
|
|
renderer.drawText(SMALL_FONT_ID, screenWidth - marginRight - progressTextWidth, textY, progressText.c_str());
|
|
|
|
// Left aligned: battery
|
|
const uint16_t percentage = battery.readPercentage();
|
|
const std::string percentageText = std::to_string(percentage) + "%";
|
|
const int percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
|
|
renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str());
|
|
|
|
// Battery icon
|
|
constexpr int batteryWidth = 15;
|
|
constexpr int batteryHeight = 10;
|
|
constexpr int x = marginLeft;
|
|
constexpr int y = 783;
|
|
|
|
// Battery outline
|
|
renderer.drawLine(x, y, x + batteryWidth - 4, y);
|
|
renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
|
|
renderer.drawLine(x, y, x, y + batteryHeight - 1);
|
|
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
|
|
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
|
|
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
|
|
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
|
|
|
|
// Battery fill
|
|
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
|
|
if (filledWidth > batteryWidth - 5) {
|
|
filledWidth = batteryWidth - 5;
|
|
}
|
|
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
|
|
|
|
// Center: book title
|
|
const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft;
|
|
const int titleMarginRight = progressTextWidth + 30 + marginRight;
|
|
const int availableTextWidth = screenWidth - titleMarginLeft - titleMarginRight;
|
|
|
|
std::string title = xtc->getTitle();
|
|
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
|
while (titleWidth > availableTextWidth && title.length() > 11) {
|
|
title.replace(title.length() - 8, 8, "...");
|
|
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
|
}
|
|
|
|
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str());
|
|
}
|
|
|
|
void XtcReaderActivity::saveProgress() const {
|
|
File f;
|
|
if (FsHelpers::openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) {
|
|
uint8_t data[4];
|
|
data[0] = currentPage & 0xFF;
|
|
data[1] = (currentPage >> 8) & 0xFF;
|
|
data[2] = (currentPage >> 16) & 0xFF;
|
|
data[3] = (currentPage >> 24) & 0xFF;
|
|
f.write(data, 4);
|
|
f.close();
|
|
}
|
|
}
|
|
|
|
void XtcReaderActivity::loadProgress() {
|
|
File f;
|
|
if (FsHelpers::openFileForRead("XTR", xtc->getCachePath() + "/progress.bin", f)) {
|
|
uint8_t data[4];
|
|
if (f.read(data, 4) == 4) {
|
|
currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
|
|
Serial.printf("[%lu] [XTR] Loaded progress: page %lu\n", millis(), currentPage);
|
|
|
|
// Validate page number
|
|
if (currentPage >= xtc->getPageCount()) {
|
|
currentPage = 0;
|
|
}
|
|
}
|
|
f.close();
|
|
}
|
|
}
|