#include "CoverArtPickerActivity.h" #include #include #include #include #include "Bitmap.h" #include "MappedInputManager.h" #include "fontIds.h" namespace { constexpr int GRID_COLS = 3; constexpr int GRID_ROWS = 4; constexpr int PAGE_ITEMS = GRID_COLS * GRID_ROWS; // 12 items per page constexpr int CELL_WIDTH = 160; constexpr int CELL_HEIGHT = 180; constexpr int COVER_WIDTH = 120; // Leave some spacing constexpr int COVER_HEIGHT = 160; // Leave some spacing constexpr int GRID_START_Y = 50; constexpr int SKIP_PAGE_MS = 700; constexpr unsigned long GO_HOME_MS = 1000; } // namespace void sortFileList(std::vector& strs); // Declared in FileSelectionActivity.cpp void CoverArtPickerActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } void CoverArtPickerActivity::loadFiles() { files.clear(); selectorIndex = 0; auto root = SdMan.open(basepath.c_str()); if (!root || !root.isDirectory()) { if (root) root.close(); return; } root.rewindDirectory(); char name[128]; for (auto file = root.openNextFile(); file; file = root.openNextFile()) { file.getName(name, sizeof(name)); if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) { file.close(); continue; } if (file.isDirectory()) { files.emplace_back(std::string(name) + "/"); } else { auto filename = std::string(name); std::string ext4 = filename.length() >= 4 ? filename.substr(filename.length() - 4) : ""; std::string ext5 = filename.length() >= 5 ? filename.substr(filename.length() - 5) : ""; if (ext5 == ".epub" || ext5 == ".xtch" || ext4 == ".xtc") { files.emplace_back(filename); } } file.close(); } root.close(); sortFileList(files); } void CoverArtPickerActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); loadFiles(); selectorIndex = 0; // Trigger first update updateRequired = true; xTaskCreate(&CoverArtPickerActivity::taskTrampoline, "CoverArtPickerActivityTask", 4096, // Stack size (need more for EPUB loading) this, // Parameters 1, // Priority &displayTaskHandle // Task handle ); } void CoverArtPickerActivity::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; files.clear(); } void CoverArtPickerActivity::loop() { // Long press BACK (1s+) goes to root folder if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS) { if (basepath != "/") { basepath = "/"; loadFiles(); updateRequired = true; } return; } const bool leftPressed = mappedInput.wasReleased(MappedInputManager::Button::Left); const bool rightPressed = mappedInput.wasReleased(MappedInputManager::Button::Right); const bool upPressed = mappedInput.wasReleased(MappedInputManager::Button::Up); const bool downPressed = mappedInput.wasReleased(MappedInputManager::Button::Down); const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (files.empty()) { return; } if (basepath.back() != '/') basepath += "/"; if (files[selectorIndex].back() == '/') { basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1); loadFiles(); updateRequired = true; } else { onSelect(basepath + files[selectorIndex]); } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { // Short press: go up one directory, or go home if at root if (mappedInput.getHeldTime() < GO_HOME_MS) { if (basepath != "/") { basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); if (basepath.empty()) basepath = "/"; loadFiles(); updateRequired = true; } else { onGoHome(); } } } else if (leftPressed) { if (skipPage) { selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + files.size()) % files.size(); } else { selectorIndex = (selectorIndex + files.size() - 1) % files.size(); } updateRequired = true; } else if (rightPressed) { if (skipPage) { selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % files.size(); } else { selectorIndex = (selectorIndex + 1) % files.size(); } updateRequired = true; } else if (upPressed) { // Move up one row selectorIndex = (selectorIndex - GRID_COLS + files.size()) % files.size(); updateRequired = true; } else if (downPressed) { // Move down one row selectorIndex = (selectorIndex + GRID_COLS) % files.size(); updateRequired = true; } } void CoverArtPickerActivity::displayTaskLoop() { while (true) { if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); render(); xSemaphoreGive(renderingMutex); } vTaskDelay(10 / portTICK_PERIOD_MS); } } void CoverArtPickerActivity::drawCoverThumbnail(const std::string& filePath, int gridX, int gridY, bool selected) const { const int x = gridX * CELL_WIDTH + (CELL_WIDTH - COVER_WIDTH) / 2; const int y = GRID_START_Y + gridY * CELL_HEIGHT + (CELL_HEIGHT - COVER_HEIGHT) / 2; // Draw selection box if (selected) { renderer.drawRect(x - 2, y - 2, COVER_WIDTH + 4, COVER_HEIGHT + 4); } // If it's a directory, draw a folder icon if (filePath.back() == '/') { std::string dirName = filePath.substr(0, filePath.length() - 1); if (dirName.length() > 12) { dirName = dirName.substr(0, 12) + "..."; } // Draw a folder outline (like a book but with a tab) const int folderX = x + 30; const int folderY = y + 30; const int folderW = COVER_WIDTH - 60; const int folderH = COVER_HEIGHT - 80; // Main folder body renderer.drawRect(folderX, folderY + 10, folderW, folderH - 10); // Folder tab renderer.drawRect(folderX, folderY, folderW / 2, 10); // Draw folder label with background if selected const int labelY = y + COVER_HEIGHT - 25; const int labelWidth = renderer.getTextWidth(SMALL_FONT_ID, dirName.c_str()); const int labelX = x + (COVER_WIDTH - labelWidth) / 2; if (selected) { // Fill background for selected text renderer.fillRect(labelX - 2, labelY - 2, labelWidth + 4, renderer.getLineHeight(SMALL_FONT_ID) + 4); } // Always draw black text on white background, or white text on black background renderer.drawText(SMALL_FONT_ID, labelX, labelY, dirName.c_str(), !selected); return; } // Prepare display name std::string displayName = filePath; if (displayName.length() > 5 && displayName.substr(displayName.length() - 5) == ".epub") { displayName = displayName.substr(0, displayName.length() - 5); } else if (displayName.length() > 5 && displayName.substr(displayName.length() - 5) == ".xtch") { displayName = displayName.substr(0, displayName.length() - 5); } else if (displayName.length() > 4 && displayName.substr(displayName.length() - 4) == ".xtc") { displayName = displayName.substr(0, displayName.length() - 4); } if (displayName.length() > 15) { displayName = displayName.substr(0, 15) + "..."; } // Build full path std::string fullPath = basepath; if (fullPath.back() != '/') fullPath += "/"; fullPath += filePath; // Determine if XTC or EPUB bool isXtc = false; if (filePath.length() >= 4) { std::string ext4 = filePath.substr(filePath.length() - 4); if (ext4 == ".xtc") isXtc = true; } if (!isXtc && filePath.length() >= 5) { std::string ext5 = filePath.substr(filePath.length() - 5); if (ext5 == ".xtch") isXtc = true; } // Try to get cover using the same pattern as sleep screen std::string coverBmpPath; bool coverGenerated = false; if (isXtc) { Xtc book(fullPath, "/.crosspoint"); if (book.load()) { if (book.generateCoverBmp()) { // This is fast if cover already exists coverBmpPath = book.getCoverBmpPath(); coverGenerated = true; } } } else { Epub book(fullPath, "/.crosspoint"); if (book.load(false)) { // Load without building metadata if missing if (book.generateCoverBmp()) { // This is fast if cover already exists coverBmpPath = book.getCoverBmpPath(); coverGenerated = true; } } } // Render the cover if we got one if (coverGenerated) { FsFile coverFile; if (SdMan.openFileForRead("COVER", coverBmpPath.c_str(), coverFile)) { Bitmap coverBitmap(coverFile); if (coverBitmap.parseHeaders() == BmpReaderError::Ok) { renderer.drawBitmap(coverBitmap, x, y, COVER_WIDTH, COVER_HEIGHT); // Don't close file here - Bitmap holds a reference to it and needs it open // File will close when coverFile goes out of scope return; // Success - cover rendered } } } // Fallback: show filename with border renderer.drawRect(x + 20, y + 20, COVER_WIDTH - 40, COVER_HEIGHT - 60); renderer.drawCenteredText(SMALL_FONT_ID, y + COVER_HEIGHT - 30, displayName.c_str(), true); } void CoverArtPickerActivity::render() const { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); renderer.drawCenteredText(UI_12_FONT_ID, 15, "Books", true, BOLD); // Help text const auto labels = mappedInput.mapLabels("« Home", "Open", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (files.empty()) { renderer.drawText(UI_10_FONT_ID, 20, 60, "No books found"); renderer.displayBuffer(); return; } // Calculate page const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; // Draw covers in grid for (int i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) { const int gridIndex = i - pageStartIndex; const int gridX = gridIndex % GRID_COLS; const int gridY = gridIndex / GRID_COLS; const bool selected = (i == selectorIndex); drawCoverThumbnail(files[i], gridX, gridY, selected); } renderer.displayBuffer(); }