diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index fe0b107e..9e948ff4 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 a443f57b..f4bf62f8 100644 --- a/lib/Xtc/Xtc/XtcParser.cpp +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -19,6 +19,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)); } @@ -56,6 +57,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); @@ -68,7 +77,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)); } @@ -165,6 +176,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 b0a402aa..25f1c336 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/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 89336062..317594c3 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -13,6 +13,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.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..6c747f01 --- /dev/null +++ b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp @@ -0,0 +1,152 @@ +#include "XtcReaderChapterSelectionActivity.h" + +#include +#include +#include + +#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; +};