mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-06 07:37:37 +03:00
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:
parent
2c5c5503a5
commit
490ae79ede
@ -26,7 +26,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec
|
||||
## Features & Usage
|
||||
|
||||
- [x] EPUB parsing and rendering
|
||||
- [ ] Image support within EPUB
|
||||
- [x] Image support within EPUB
|
||||
- [x] Saved reading position
|
||||
- [x] File explorer with file picker
|
||||
- [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
|
||||
- [ ] Full UTF support
|
||||
- [x] Screen rotation
|
||||
- [x] Bluetooth LE Support
|
||||
|
||||
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance;
|
||||
namespace {
|
||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||
// 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";
|
||||
} // namespace
|
||||
|
||||
@ -38,6 +38,7 @@ bool CrossPointSettings::saveToFile() const {
|
||||
serialization::writePod(outputFile, fontSize);
|
||||
serialization::writePod(outputFile, lineSpacing);
|
||||
serialization::writePod(outputFile, bluetoothEnabled);
|
||||
serialization::writePod(outputFile, useCoverArtPicker);
|
||||
outputFile.close();
|
||||
|
||||
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
|
||||
@ -86,6 +87,8 @@ bool CrossPointSettings::loadFromFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, bluetoothEnabled);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, useCoverArtPicker);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
} while (false);
|
||||
|
||||
inputFile.close();
|
||||
|
||||
@ -64,6 +64,8 @@ class CrossPointSettings {
|
||||
uint8_t lineSpacing = NORMAL;
|
||||
// Bluetooth settings
|
||||
uint8_t bluetoothEnabled = 0;
|
||||
// File browser settings
|
||||
uint8_t useCoverArtPicker = 0;
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
|
||||
325
src/activities/reader/CoverArtPickerActivity.cpp
Normal file
325
src/activities/reader/CoverArtPickerActivity.cpp
Normal 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();
|
||||
}
|
||||
39
src/activities/reader/CoverArtPickerActivity.h
Normal file
39
src/activities/reader/CoverArtPickerActivity.h
Normal 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;
|
||||
};
|
||||
@ -1,5 +1,7 @@
|
||||
#include "ReaderActivity.h"
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CoverArtPickerActivity.h"
|
||||
#include "Epub.h"
|
||||
#include "EpubReaderActivity.h"
|
||||
#include "FileSelectionActivity.h"
|
||||
@ -92,8 +94,15 @@ void ReaderActivity::onGoToFileSelection(const std::string& fromBookPath) {
|
||||
exitActivity();
|
||||
// If coming from a book, start in that book's folder; otherwise start from root
|
||||
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) {
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
|
||||
// Define the static settings list
|
||||
namespace {
|
||||
constexpr int settingsCount = 12;
|
||||
constexpr int settingsCount = 13;
|
||||
const SettingInfo settingsList[settingsCount] = {
|
||||
// Should match with SLEEP_SCREEN_MODE
|
||||
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
|
||||
@ -34,6 +34,7 @@ const SettingInfo settingsList[settingsCount] = {
|
||||
{"Bookerly", "Noto Sans", "Open Dyslexic"}},
|
||||
{"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}},
|
||||
{"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}},
|
||||
{"Cover Art Picker", SettingType::TOGGLE, &CrossPointSettings::useCoverArtPicker, {}},
|
||||
{"Bluetooth", SettingType::TOGGLE, &CrossPointSettings::bluetoothEnabled, {}},
|
||||
{"Check for updates", SettingType::ACTION, nullptr, {}},
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user