/** * XtcReaderActivity.cpp * * XTC ebook reader activity implementation * Displays pre-rendered XTC pages on e-ink display */ #include "XtcReaderActivity.h" #include #include #include #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(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(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(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(); } }