Add cover art picker setting and update file handling accordingly

- Added new `useCoverArtPicker` setting to CrossPointSettings.
- Updated saveToFile and loadFromFile methods to handle the new setting.
- Modified ReaderActivity to use CoverArtPickerActivity if enabled.
- Adjusted settings count in SettingsActivity.
This commit is contained in:
altsysrq 2025-12-31 17:43:06 -06:00
parent 2c5c5503a5
commit 490ae79ede
7 changed files with 385 additions and 5 deletions

View File

@ -26,7 +26,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec
## Features & Usage ## Features & Usage
- [x] EPUB parsing and rendering - [x] EPUB parsing and rendering
- [ ] Image support within EPUB - [x] Image support within EPUB
- [x] Saved reading position - [x] Saved reading position
- [x] File explorer with file picker - [x] File explorer with file picker
- [x] Basic EPUB picker from root directory - [x] Basic EPUB picker from root directory
@ -40,6 +40,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec
- [ ] User provided fonts - [ ] User provided fonts
- [ ] Full UTF support - [ ] Full UTF support
- [x] Screen rotation - [x] Screen rotation
- [x] Bluetooth LE Support
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint. See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.

View File

@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance;
namespace { namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields // Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 11; constexpr uint8_t SETTINGS_COUNT = 12;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace } // namespace
@ -38,6 +38,7 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, fontSize); serialization::writePod(outputFile, fontSize);
serialization::writePod(outputFile, lineSpacing); serialization::writePod(outputFile, lineSpacing);
serialization::writePod(outputFile, bluetoothEnabled); serialization::writePod(outputFile, bluetoothEnabled);
serialization::writePod(outputFile, useCoverArtPicker);
outputFile.close(); outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@ -86,6 +87,8 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, bluetoothEnabled); serialization::readPod(inputFile, bluetoothEnabled);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, useCoverArtPicker);
if (++settingsRead >= fileSettingsCount) break;
} while (false); } while (false);
inputFile.close(); inputFile.close();

View File

@ -64,6 +64,8 @@ class CrossPointSettings {
uint8_t lineSpacing = NORMAL; uint8_t lineSpacing = NORMAL;
// Bluetooth settings // Bluetooth settings
uint8_t bluetoothEnabled = 0; uint8_t bluetoothEnabled = 0;
// File browser settings
uint8_t useCoverArtPicker = 0;
~CrossPointSettings() = default; ~CrossPointSettings() = default;

View File

@ -0,0 +1,325 @@
#include "CoverArtPickerActivity.h"
#include <Epub.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <Xtc.h>
#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<std::string>& strs); // Declared in FileSelectionActivity.cpp
void CoverArtPickerActivity::taskTrampoline(void* param) {
auto* self = static_cast<CoverArtPickerActivity*>(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();
}

View File

@ -0,0 +1,39 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
class CoverArtPickerActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
std::string basepath = "/";
std::vector<std::string> files;
int selectorIndex = 0;
bool updateRequired = false;
const std::function<void(const std::string&)> onSelect;
const std::function<void()> onGoHome;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
void loadFiles();
void drawCoverThumbnail(const std::string& filePath, int gridX, int gridY, bool selected) const;
public:
explicit CoverArtPickerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(const std::string&)>& onSelect,
const std::function<void()>& onGoHome, std::string initialPath = "/")
: Activity("CoverArtPicker", renderer, mappedInput),
basepath(initialPath.empty() ? "/" : std::move(initialPath)),
onSelect(onSelect),
onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -1,5 +1,7 @@
#include "ReaderActivity.h" #include "ReaderActivity.h"
#include "CrossPointSettings.h"
#include "CoverArtPickerActivity.h"
#include "Epub.h" #include "Epub.h"
#include "EpubReaderActivity.h" #include "EpubReaderActivity.h"
#include "FileSelectionActivity.h" #include "FileSelectionActivity.h"
@ -92,8 +94,15 @@ void ReaderActivity::onGoToFileSelection(const std::string& fromBookPath) {
exitActivity(); exitActivity();
// If coming from a book, start in that book's folder; otherwise start from root // If coming from a book, start in that book's folder; otherwise start from root
const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath); const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath);
enterNewActivity(new FileSelectionActivity(
renderer, mappedInput, [this](const std::string& path) { onSelectBookFile(path); }, onGoBack, initialPath)); // Check if cover art picker is enabled
if (SETTINGS.useCoverArtPicker) {
enterNewActivity(new CoverArtPickerActivity(
renderer, mappedInput, [this](const std::string& path) { onSelectBookFile(path); }, onGoBack, initialPath));
} else {
enterNewActivity(new FileSelectionActivity(
renderer, mappedInput, [this](const std::string& path) { onSelectBookFile(path); }, onGoBack, initialPath));
}
} }
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) { void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {

View File

@ -9,7 +9,7 @@
// Define the static settings list // Define the static settings list
namespace { namespace {
constexpr int settingsCount = 12; constexpr int settingsCount = 13;
const SettingInfo settingsList[settingsCount] = { const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE // Should match with SLEEP_SCREEN_MODE
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
@ -34,6 +34,7 @@ const SettingInfo settingsList[settingsCount] = {
{"Bookerly", "Noto Sans", "Open Dyslexic"}}, {"Bookerly", "Noto Sans", "Open Dyslexic"}},
{"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}}, {"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}},
{"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}}, {"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}},
{"Cover Art Picker", SettingType::TOGGLE, &CrossPointSettings::useCoverArtPicker, {}},
{"Bluetooth", SettingType::TOGGLE, &CrossPointSettings::bluetoothEnabled, {}}, {"Bluetooth", SettingType::TOGGLE, &CrossPointSettings::bluetoothEnabled, {}},
{"Check for updates", SettingType::ACTION, nullptr, {}}, {"Check for updates", SettingType::ACTION, nullptr, {}},
}; };