diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 6ffbabb8..8f79c9dd 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -87,6 +87,21 @@ std::string Xtc::getTitle() const { return filepath.substr(lastSlash, lastDot - lastSlash); } +bool Xtc::hasChapters() const { + if (!loaded || !parser) { + return false; + } + return parser->hasChapters(); +} + +const std::vector& Xtc::getChapters() const { + static const std::vector kEmpty; + if (!loaded || !parser) { + return kEmpty; + } + return parser->getChapters(); +} + std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } bool Xtc::generateCoverBmp() const { diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index 42e05ef3..e5bce102 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -9,6 +9,7 @@ #include #include +#include #include "Xtc/XtcParser.h" #include "Xtc/XtcTypes.h" @@ -55,6 +56,8 @@ class Xtc { // Metadata std::string getTitle() const; + bool hasChapters() const; + const std::vector& getChapters() const; // Cover image support (for sleep screen) std::string getCoverBmpPath() const; diff --git a/lib/Xtc/Xtc/XtcParser.cpp b/lib/Xtc/Xtc/XtcParser.cpp index fbdbc554..dfce1747 100644 --- a/lib/Xtc/Xtc/XtcParser.cpp +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -20,6 +20,7 @@ XtcParser::XtcParser() m_defaultWidth(DISPLAY_WIDTH), m_defaultHeight(DISPLAY_HEIGHT), m_bitDepth(1), + m_hasChapters(false), m_lastError(XtcError::OK) { memset(&m_header, 0, sizeof(m_header)); } @@ -57,6 +58,14 @@ XtcError XtcParser::open(const char* filepath) { return m_lastError; } + // Read chapters if present + m_lastError = readChapters(); + if (m_lastError != XtcError::OK) { + Serial.printf("[%lu] [XTC] Failed to read chapters: %s\n", millis(), errorToString(m_lastError)); + m_file.close(); + return m_lastError; + } + m_isOpen = true; Serial.printf("[%lu] [XTC] Opened file: %s (%u pages, %dx%d)\n", millis(), filepath, m_header.pageCount, m_defaultWidth, m_defaultHeight); @@ -69,7 +78,9 @@ void XtcParser::close() { m_isOpen = false; } m_pageTable.clear(); + m_chapters.clear(); m_title.clear(); + m_hasChapters = false; memset(&m_header, 0, sizeof(m_header)); } @@ -91,7 +102,9 @@ XtcError XtcParser::readHeader() { m_bitDepth = (m_header.magic == XTCH_MAGIC) ? 2 : 1; // Check version - if (m_header.version > 1) { + // Currently, version 1 is the only valid version, however some generators are using big endian for the version code + // so we also accept version 256 (0x0100) + if (m_header.version != 1 && m_header.version != 256) { Serial.printf("[%lu] [XTC] Unsupported version: %d\n", millis(), m_header.version); return XtcError::INVALID_VERSION; } @@ -166,6 +179,112 @@ XtcError XtcParser::readPageTable() { return XtcError::OK; } +XtcError XtcParser::readChapters() { + m_hasChapters = false; + m_chapters.clear(); + + uint8_t hasChaptersFlag = 0; + if (!m_file.seek(0x0B)) { + return XtcError::READ_ERROR; + } + if (m_file.read(&hasChaptersFlag, sizeof(hasChaptersFlag)) != sizeof(hasChaptersFlag)) { + return XtcError::READ_ERROR; + } + + if (hasChaptersFlag != 1) { + return XtcError::OK; + } + + uint64_t chapterOffset = 0; + if (!m_file.seek(0x30)) { + return XtcError::READ_ERROR; + } + if (m_file.read(reinterpret_cast(&chapterOffset), sizeof(chapterOffset)) != sizeof(chapterOffset)) { + return XtcError::READ_ERROR; + } + + if (chapterOffset == 0) { + return XtcError::OK; + } + + const uint64_t fileSize = m_file.size(); + if (chapterOffset < sizeof(XtcHeader) || chapterOffset >= fileSize || chapterOffset + 96 > fileSize) { + return XtcError::OK; + } + + uint64_t maxOffset = 0; + if (m_header.pageTableOffset > chapterOffset) { + maxOffset = m_header.pageTableOffset; + } else if (m_header.dataOffset > chapterOffset) { + maxOffset = m_header.dataOffset; + } else { + maxOffset = fileSize; + } + + if (maxOffset <= chapterOffset) { + return XtcError::OK; + } + + constexpr size_t chapterSize = 96; + const uint64_t available = maxOffset - chapterOffset; + const size_t chapterCount = static_cast(available / chapterSize); + if (chapterCount == 0) { + return XtcError::OK; + } + + if (!m_file.seek(chapterOffset)) { + return XtcError::READ_ERROR; + } + + std::vector chapterBuf(chapterSize); + for (size_t i = 0; i < chapterCount; i++) { + if (m_file.read(chapterBuf.data(), chapterSize) != chapterSize) { + return XtcError::READ_ERROR; + } + + char nameBuf[81]; + memcpy(nameBuf, chapterBuf.data(), 80); + nameBuf[80] = '\0'; + const size_t nameLen = strnlen(nameBuf, 80); + std::string name(nameBuf, nameLen); + + uint16_t startPage = 0; + uint16_t endPage = 0; + memcpy(&startPage, chapterBuf.data() + 0x50, sizeof(startPage)); + memcpy(&endPage, chapterBuf.data() + 0x52, sizeof(endPage)); + + if (name.empty() && startPage == 0 && endPage == 0) { + break; + } + + if (startPage > 0) { + startPage--; + } + if (endPage > 0) { + endPage--; + } + + if (startPage >= m_header.pageCount) { + continue; + } + + if (endPage >= m_header.pageCount) { + endPage = m_header.pageCount - 1; + } + + if (startPage > endPage) { + continue; + } + + ChapterInfo chapter{std::move(name), startPage, endPage}; + m_chapters.push_back(std::move(chapter)); + } + + m_hasChapters = !m_chapters.empty(); + Serial.printf("[%lu] [XTC] Chapters: %u\n", millis(), static_cast(m_chapters.size())); + return XtcError::OK; +} + bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) const { if (pageIndex >= m_pageTable.size()) { return false; diff --git a/lib/Xtc/Xtc/XtcParser.h b/lib/Xtc/Xtc/XtcParser.h index 5d2340ac..2d2b780e 100644 --- a/lib/Xtc/Xtc/XtcParser.h +++ b/lib/Xtc/Xtc/XtcParser.h @@ -70,6 +70,9 @@ class XtcParser { // Get title from metadata std::string getTitle() const { return m_title; } + bool hasChapters() const { return m_hasChapters; } + const std::vector& getChapters() const { return m_chapters; } + // Validation static bool isValidXtcFile(const char* filepath); @@ -81,16 +84,19 @@ class XtcParser { bool m_isOpen; XtcHeader m_header; std::vector m_pageTable; + std::vector m_chapters; std::string m_title; uint16_t m_defaultWidth; uint16_t m_defaultHeight; uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit) + bool m_hasChapters; XtcError m_lastError; // Internal helper functions XtcError readHeader(); XtcError readPageTable(); XtcError readTitle(); + XtcError readChapters(); }; } // namespace xtc diff --git a/lib/Xtc/Xtc/XtcTypes.h b/lib/Xtc/Xtc/XtcTypes.h index 30761d97..eba8b32b 100644 --- a/lib/Xtc/Xtc/XtcTypes.h +++ b/lib/Xtc/Xtc/XtcTypes.h @@ -13,6 +13,7 @@ #pragma once #include +#include namespace xtc { @@ -92,6 +93,12 @@ struct PageInfo { uint8_t padding; // Alignment padding }; // 16 bytes total +struct ChapterInfo { + std::string name; + uint16_t startPage; + uint16_t endPage; +}; + // Error codes enum class XtcError { OK = 0, diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 5ad879bf..c096cdbc 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -59,7 +59,7 @@ class CrossPointSettings { // Get singleton instance static CrossPointSettings& getInstance() { return instance; } - uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 500; } + uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 400; } bool saveToFile() const; bool loadFromFile(); diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index 7ee22958..17be4cf8 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -1,5 +1,7 @@ #include "MappedInputManager.h" +#include "CrossPointSettings.h" + decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button button) const { const auto frontLayout = static_cast(SETTINGS.frontButtonLayout); const auto sideLayout = static_cast(SETTINGS.sideButtonLayout); diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index 138d793d..62065fe9 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -2,8 +2,6 @@ #include -#include "CrossPointSettings.h" - class MappedInputManager { public: enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward }; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index fa4772b4..8291826b 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -122,12 +122,16 @@ void HomeActivity::render() const { if (bookName.length() > 5 && bookName.substr(bookName.length() - 5) == ".epub") { bookName.resize(bookName.length() - 5); } + // Truncate if too long - if (bookName.length() > 25) { - bookName.resize(22); - bookName += "..."; - } std::string continueLabel = "Continue: " + bookName; + int itemWidth = renderer.getTextWidth(UI_FONT_ID, continueLabel.c_str()); + while (itemWidth > renderer.getScreenWidth() - 40 && continueLabel.length() > 8) { + continueLabel.replace(continueLabel.length() - 5, 5, "..."); + itemWidth = renderer.getTextWidth(UI_FONT_ID, continueLabel.c_str()); + Serial.printf("[%lu] [HOM] width: %lu, pageWidth: %lu\n", millis(), itemWidth, pageWidth); + } + renderer.drawText(UI_FONT_ID, 20, menuY, continueLabel.c_str(), selectorIndex != menuIndex); menuY += 30; menuIndex++; diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 3f5645c4..421b789d 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -13,6 +13,7 @@ #include "CrossPointState.h" #include "MappedInputManager.h" +#include "XtcReaderChapterSelectionActivity.h" #include "config.h" namespace { @@ -27,7 +28,7 @@ void XtcReaderActivity::taskTrampoline(void* param) { } void XtcReaderActivity::onEnter() { - Activity::onEnter(); + ActivityWithSubactivity::onEnter(); if (!xtc) { return; @@ -56,7 +57,7 @@ void XtcReaderActivity::onEnter() { } void XtcReaderActivity::onExit() { - Activity::onExit(); + ActivityWithSubactivity::onExit(); // Wait until not rendering to delete task xSemaphoreTake(renderingMutex, portMAX_DELAY); @@ -70,6 +71,32 @@ void XtcReaderActivity::onExit() { } void XtcReaderActivity::loop() { + // Pass input responsibility to sub activity if exists + if (subActivity) { + subActivity->loop(); + return; + } + + // Enter chapter selection activity + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new XtcReaderChapterSelectionActivity( + this->renderer, this->mappedInput, xtc, currentPage, + [this] { + exitActivity(); + updateRequired = true; + }, + [this](const uint32_t newPage) { + currentPage = newPage; + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } + } + // Long press BACK (1s+) goes directly to home if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { onGoHome(); diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index bf72064a..579e1777 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -12,9 +12,9 @@ #include #include -#include "activities/Activity.h" +#include "activities/ActivityWithSubactivity.h" -class XtcReaderActivity final : public Activity { +class XtcReaderActivity final : public ActivityWithSubactivity { std::shared_ptr xtc; TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; @@ -34,7 +34,10 @@ class XtcReaderActivity final : public Activity { public: explicit XtcReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr xtc, const std::function& onGoBack, const std::function& onGoHome) - : Activity("XtcReader", renderer, mappedInput), xtc(std::move(xtc)), onGoBack(onGoBack), onGoHome(onGoHome) {} + : ActivityWithSubactivity("XtcReader", renderer, mappedInput), + xtc(std::move(xtc)), + onGoBack(onGoBack), + onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp new file mode 100644 index 00000000..d00fc590 --- /dev/null +++ b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp @@ -0,0 +1,151 @@ +#include "XtcReaderChapterSelectionActivity.h" + +#include + +#include "MappedInputManager.h" +#include "config.h" + +namespace { +constexpr int SKIP_PAGE_MS = 700; +} // namespace + +int XtcReaderChapterSelectionActivity::getPageItems() const { + constexpr int startY = 60; + constexpr int lineHeight = 30; + + const int screenHeight = renderer.getScreenHeight(); + const int availableHeight = screenHeight - startY; + int items = availableHeight / lineHeight; + if (items < 1) { + items = 1; + } + return items; +} + +int XtcReaderChapterSelectionActivity::findChapterIndexForPage(uint32_t page) const { + if (!xtc) { + return 0; + } + + const auto& chapters = xtc->getChapters(); + for (size_t i = 0; i < chapters.size(); i++) { + if (page >= chapters[i].startPage && page <= chapters[i].endPage) { + return static_cast(i); + } + } + return 0; +} + +void XtcReaderChapterSelectionActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void XtcReaderChapterSelectionActivity::onEnter() { + Activity::onEnter(); + + if (!xtc) { + return; + } + + renderingMutex = xSemaphoreCreateMutex(); + selectorIndex = findChapterIndexForPage(currentPage); + + updateRequired = true; + xTaskCreate(&XtcReaderChapterSelectionActivity::taskTrampoline, "XtcReaderChapterSelectionActivityTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void XtcReaderChapterSelectionActivity::onExit() { + Activity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void XtcReaderChapterSelectionActivity::loop() { + const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) || + mappedInput.wasReleased(MappedInputManager::Button::Left); + const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) || + mappedInput.wasReleased(MappedInputManager::Button::Right); + + const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; + const int pageItems = getPageItems(); + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + const auto& chapters = xtc->getChapters(); + if (!chapters.empty() && selectorIndex >= 0 && selectorIndex < static_cast(chapters.size())) { + onSelectPage(chapters[selectorIndex].startPage); + } + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoBack(); + } else if (prevReleased) { + const int total = static_cast(xtc->getChapters().size()); + if (total == 0) { + return; + } + if (skipPage) { + selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + total) % total; + } else { + selectorIndex = (selectorIndex + total - 1) % total; + } + updateRequired = true; + } else if (nextReleased) { + const int total = static_cast(xtc->getChapters().size()); + if (total == 0) { + return; + } + if (skipPage) { + selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % total; + } else { + selectorIndex = (selectorIndex + 1) % total; + } + updateRequired = true; + } +} + +void XtcReaderChapterSelectionActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + renderScreen(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void XtcReaderChapterSelectionActivity::renderScreen() { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(); + renderer.drawCenteredText(READER_FONT_ID, 10, "Select Chapter", true, BOLD); + + const auto& chapters = xtc->getChapters(); + if (chapters.empty()) { + renderer.drawCenteredText(UI_FONT_ID, 120, "No chapters", true, REGULAR); + renderer.displayBuffer(); + return; + } + + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); + for (int i = pageStartIndex; i < static_cast(chapters.size()) && i < pageStartIndex + pageItems; i++) { + const auto& chapter = chapters[i]; + const char* title = chapter.name.empty() ? "Unnamed" : chapter.name.c_str(); + renderer.drawText(UI_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex); + } + + renderer.displayBuffer(); +} diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.h b/src/activities/reader/XtcReaderChapterSelectionActivity.h new file mode 100644 index 00000000..f0fe06bb --- /dev/null +++ b/src/activities/reader/XtcReaderChapterSelectionActivity.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include +#include + +#include + +#include "../Activity.h" + +class XtcReaderChapterSelectionActivity final : public Activity { + std::shared_ptr xtc; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + uint32_t currentPage = 0; + int selectorIndex = 0; + bool updateRequired = false; + const std::function onGoBack; + const std::function onSelectPage; + + int getPageItems() const; + int findChapterIndexForPage(uint32_t page) const; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void renderScreen(); + + public: + explicit XtcReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::shared_ptr& xtc, uint32_t currentPage, + const std::function& onGoBack, + const std::function& onSelectPage) + : Activity("XtcReaderChapterSelection", renderer, mappedInput), + xtc(xtc), + currentPage(currentPage), + onGoBack(onGoBack), + onSelectPage(onSelectPage) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index a03b88a8..eea7a47d 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -159,6 +159,9 @@ void SettingsActivity::render() const { // Draw header renderer.drawCenteredText(READER_FONT_ID, 10, "Settings", true, BOLD); + // Draw selection + renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30); + // Draw all settings for (int i = 0; i < settingsCount; i++) { const int settingY = 60 + i * 30; // 30 pixels between settings @@ -169,18 +172,19 @@ void SettingsActivity::render() const { } // Draw setting name - renderer.drawText(UI_FONT_ID, 20, settingY, settingsList[i].name); + renderer.drawText(UI_FONT_ID, 20, settingY, settingsList[i].name, i != selectedSettingIndex); // Draw value based on setting type + std::string valueText = ""; if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { const bool value = SETTINGS.*(settingsList[i].valuePtr); - renderer.drawText(UI_FONT_ID, pageWidth - 80, settingY, value ? "ON" : "OFF"); + valueText = value ? "ON" : "OFF"; } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); - auto valueText = settingsList[i].enumValues[value]; - const auto width = renderer.getTextWidth(UI_FONT_ID, valueText.c_str()); - renderer.drawText(UI_FONT_ID, pageWidth - 50 - width, settingY, valueText.c_str()); + valueText = settingsList[i].enumValues[value]; } + const auto width = renderer.getTextWidth(UI_FONT_ID, valueText.c_str()); + renderer.drawText(UI_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), i != selectedSettingIndex); } // Draw version text above button hints diff --git a/src/main.cpp b/src/main.cpp index 63928e68..e1bba598 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -84,7 +84,7 @@ void verifyWakeupLongPress() { const auto start = millis(); bool abort = false; // It takes us some time to wake up from deep sleep, so we need to subtract that from the duration - uint16_t calibration = 25; + uint16_t calibration = 29; uint16_t calibratedPressDuration = (calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1; @@ -179,8 +179,6 @@ void setup() { Serial.begin(115200); } - Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); - inputManager.begin(); // Initialize pins pinMode(BAT_GPIO0, INPUT); @@ -203,6 +201,9 @@ void setup() { // verify power button press duration after we've read settings. verifyWakeupLongPress(); + // First serial output only here to avoid timing inconsistencies for power button press duration verification + Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); + setupDisplayAndFonts(); exitActivity();