#include "HomeActivity.h" #include #include #include #include #include #include #include #include "Battery.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "ScreenComponents.h" #include "fontIds.h" #include "util/StringUtils.h" #include void HomeActivity::taskTrampoline(void *param) { auto *self = static_cast(param); self->displayTaskLoop(); } int HomeActivity::getMenuItemCount() const { int count = 3; // My Library, File transfer, Settings if (hasContinueReading) count++; if (hasOpdsUrl) count++; return count; } void HomeActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); // Check if we have a book to continue reading hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str()); // Check if OPDS browser URL is configured hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; if (hasContinueReading) { // Extract filename from path for display lastBookTitle = APP_STATE.openEpubPath; const size_t lastSlash = lastBookTitle.find_last_of('/'); if (lastSlash != std::string::npos) { lastBookTitle = lastBookTitle.substr(lastSlash + 1); } // If epub, try to load the metadata for title/author and cover if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) { Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); epub.load(false); if (!epub.getTitle().empty()) { lastBookTitle = std::string(epub.getTitle()); } if (!epub.getAuthor().empty()) { lastBookAuthor = std::string(epub.getAuthor()); } // Try to generate thumbnail image for Continue Reading card if (epub.generateThumbBmp()) { coverBmpPath = epub.getThumbBmpPath(); hasCoverImage = true; } } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") || StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { // Handle XTC file Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint"); if (xtc.load()) { if (!xtc.getTitle().empty()) { lastBookTitle = std::string(xtc.getTitle()); } // Try to generate thumbnail image for Continue Reading card if (xtc.generateThumbBmp()) { coverBmpPath = xtc.getThumbBmpPath(); hasCoverImage = true; } } // Remove extension from title if we don't have metadata if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { lastBookTitle.resize(lastBookTitle.length() - 5); } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { lastBookTitle.resize(lastBookTitle.length() - 4); } } } selectorIndex = 0; lastBatteryCheck = 0; // Force update on first render coverRendered = false; coverBufferStored = false; // Load and cache recent books data (slow operation, do once) loadRecentBooksData(); // Trigger first update updateRequired = true; xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", 4096, // Stack size (increased for cover image rendering) this, // Parameters 1, // Priority &displayTaskHandle // Task handle ); } void HomeActivity::loadRecentBooksData() { cachedRecentBooks.clear(); const auto &recentBooks = RECENT_BOOKS.getBooks(); const int maxRecentBooks = 3; int recentCount = std::min(static_cast(recentBooks.size()), maxRecentBooks); for (int i = 0; i < recentCount; i++) { const std::string &bookPath = recentBooks[i]; CachedBookInfo info; // Extract title from path info.title = bookPath; size_t lastSlash = info.title.find_last_of('/'); if (lastSlash != std::string::npos) { info.title = info.title.substr(lastSlash + 1); } size_t lastDot = info.title.find_last_of('.'); if (lastDot != std::string::npos) { info.title = info.title.substr(0, lastDot); } if (StringUtils::checkFileExtension(bookPath, ".epub")) { Epub epub(bookPath, "/.crosspoint"); epub.load(false); if (!epub.getTitle().empty()) { info.title = epub.getTitle(); } if (epub.generateThumbBmp()) { info.coverPath = epub.getThumbBmpPath(); } // Read progress FsFile f; if (SdMan.openFileForRead("HOME", epub.getCachePath() + "/progress.bin", f)) { uint8_t data[4]; if (f.read(data, 4) == 4) { int spineIndex = data[0] + (data[1] << 8); int spineCount = epub.getSpineItemsCount(); if (spineCount > 0) { info.progressPercent = (spineIndex * 100) / spineCount; } } f.close(); } } else if (StringUtils::checkFileExtension(bookPath, ".xtc") || StringUtils::checkFileExtension(bookPath, ".xtch")) { Xtc xtc(bookPath, "/.crosspoint"); if (xtc.load()) { if (!xtc.getTitle().empty()) { info.title = xtc.getTitle(); } if (xtc.generateThumbBmp()) { info.coverPath = xtc.getThumbBmpPath(); } // Read progress FsFile f; if (SdMan.openFileForRead("HOME", xtc.getCachePath() + "/progress.bin", f)) { uint8_t data[4]; if (f.read(data, 4) == 4) { uint32_t currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); uint32_t totalPages = xtc.getPageCount(); if (totalPages > 0) { info.progressPercent = (currentPage * 100) / totalPages; } } f.close(); } } } Serial.printf("[HOME] Book %d: title='%s', cover='%s', progress=%d%%\n", i, info.title.c_str(), info.coverPath.c_str(), info.progressPercent); cachedRecentBooks.push_back(info); } Serial.printf("[HOME] Loaded %d recent books\n", (int)cachedRecentBooks.size()); } void HomeActivity::onExit() { Activity::onExit(); // Wait until not rendering to delete task to avoid killing mid-instruction to // EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; // Free the stored cover buffer if any freeCoverBuffer(); } bool HomeActivity::storeCoverBuffer() { uint8_t *frameBuffer = renderer.getFrameBuffer(); if (!frameBuffer) { return false; } // Free any existing buffer first freeCoverBuffer(); const size_t bufferSize = GfxRenderer::getBufferSize(); coverBuffer = static_cast(malloc(bufferSize)); if (!coverBuffer) { return false; } memcpy(coverBuffer, frameBuffer, bufferSize); return true; } bool HomeActivity::restoreCoverBuffer() { if (!coverBuffer) { return false; } uint8_t *frameBuffer = renderer.getFrameBuffer(); if (!frameBuffer) { return false; } const size_t bufferSize = GfxRenderer::getBufferSize(); memcpy(frameBuffer, coverBuffer, bufferSize); return true; } void HomeActivity::freeCoverBuffer() { if (coverBuffer) { free(coverBuffer); coverBuffer = nullptr; } coverBufferStored = false; } void HomeActivity::loop() { const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) || mappedInput.wasPressed(MappedInputManager::Button::Left); const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) || mappedInput.wasPressed(MappedInputManager::Button::Right); const int menuCount = getMenuItemCount(); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Calculate dynamic indices based on which options are available int idx = 0; const int continueIdx = hasContinueReading ? idx++ : -1; const int myLibraryIdx = idx++; const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int fileTransferIdx = idx++; const int settingsIdx = idx; if (selectorIndex == continueIdx) { onContinueReading(); } else if (selectorIndex == myLibraryIdx) { onMyLibraryOpen(); } else if (selectorIndex == opdsLibraryIdx) { onOpdsBrowserOpen(); } else if (selectorIndex == fileTransferIdx) { onFileTransferOpen(); } else if (selectorIndex == settingsIdx) { onSettingsOpen(); } } else if (prevPressed) { selectorIndex = (selectorIndex + menuCount - 1) % menuCount; updateRequired = true; } else if (nextPressed) { selectorIndex = (selectorIndex + 1) % menuCount; updateRequired = true; } } void HomeActivity::displayTaskLoop() { while (true) { if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); render(); xSemaphoreGive(renderingMutex); } vTaskDelay(10 / portTICK_PERIOD_MS); } } void HomeActivity::render() { // Battery check logic (only update every 60 seconds) const uint32_t now = millis(); const bool needBatteryUpdate = (now - lastBatteryCheck > 60000) || (lastBatteryCheck == 0); if (needBatteryUpdate) { cachedBatteryLevel = battery.readPercentage(); lastBatteryCheck = now; } // Optimization: If we have a cached framebuffer from a previous full render, // and only the selection changed (no battery update), restore it first. const bool canRestoreBuffer = coverBufferStored && coverRendered; const bool isSelectionOnlyChange = !needBatteryUpdate && canRestoreBuffer; if (isSelectionOnlyChange) { restoreCoverBuffer(); } else { renderer.clearScreen(); } ThemeEngine::ThemeContext context; // --- Bind Global Data --- context.setString("BatteryPercent", std::to_string(cachedBatteryLevel)); context.setBool("ShowBatteryPercent", SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS); // --- Recent Books Data (use cached data for performance) --- int recentCount = static_cast(cachedRecentBooks.size()); context.setBool("HasRecentBooks", recentCount > 0); context.setInt("RecentBooks.Count", recentCount); for (int i = 0; i < recentCount; i++) { const auto &book = cachedRecentBooks[i]; std::string prefix = "RecentBooks." + std::to_string(i) + "."; context.setString(prefix + "Title", book.title); context.setString(prefix + "Image", book.coverPath); context.setString(prefix + "Progress", std::to_string(book.progressPercent)); context.setBool(prefix + "Selected", false); } // --- Book Card Data (for legacy theme) --- context.setBool("HasBook", hasContinueReading); context.setString("BookTitle", lastBookTitle); context.setString("BookAuthor", lastBookAuthor); context.setString("BookCoverPath", coverBmpPath); context.setBool("HasCover", hasContinueReading && hasCoverImage && !coverBmpPath.empty()); context.setBool("ShowInfoBox", true); // --- Selection Logic --- int idx = 0; const int continueIdx = hasContinueReading ? idx++ : -1; const int myLibraryIdx = idx++; const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int fileTransferIdx = idx++; const int settingsIdx = idx; context.setBool("IsBookSelected", selectorIndex == continueIdx); // --- Main Menu Data --- std::vector menuLabels; std::vector menuIcons; std::vector menuSelected; menuLabels.push_back("Books"); menuIcons.push_back("book"); menuSelected.push_back(selectorIndex == myLibraryIdx); if (hasOpdsUrl) { menuLabels.push_back("Calibre Library"); menuIcons.push_back("library"); menuSelected.push_back(selectorIndex == opdsLibraryIdx); } menuLabels.push_back("Files"); menuIcons.push_back("folder"); menuSelected.push_back(selectorIndex == fileTransferIdx); menuLabels.push_back("Transfer"); menuIcons.push_back("transfer"); menuSelected.push_back(false); // Separate from file transfer menuLabels.push_back("Settings"); menuIcons.push_back("settings"); menuSelected.push_back(selectorIndex == settingsIdx); context.setInt("MainMenu.Count", menuLabels.size()); for (size_t i = 0; i < menuLabels.size(); ++i) { std::string prefix = "MainMenu." + std::to_string(i) + "."; context.setString(prefix + "Title", menuLabels[i]); context.setString(prefix + "Icon", menuIcons[i]); context.setBool(prefix + "Selected", menuSelected[i]); } // --- Render via ThemeEngine --- const uint32_t renderStart = millis(); ThemeEngine::ThemeManager::get().renderScreen("Home", renderer, context); const uint32_t renderTime = millis() - renderStart; Serial.printf("[HOME] ThemeEngine render took %lums\n", renderTime); // After first full render, store the framebuffer for fast subsequent updates if (!coverRendered) { coverBufferStored = storeCoverBuffer(); coverRendered = true; } const uint32_t displayStart = millis(); renderer.displayBuffer(); Serial.printf("[HOME] Display buffer took %lums\n", millis() - displayStart); }