Add chapter select support to XTC files (#145)

## Summary

- **What is the goal of this PR?** Add chapter selection support to the
XTC reader activity, including parsing chapter metadata from XTC files.
- **What changes are included?** Implemented XTC chapter parsing and
exposure in the XTC library, added a chapter selection activity for XTC,
integrated it into XtcReaderActivity, and normalized chapter page
indices by shifting them to 0-based.

  ## Additional Context

- The reader uses 0-based page indexing (first page = 0), but the XTC
chapter table appears to be 1-based (first page = 1), so chapter
start/end pages are shifted down by 1 during parsing.
This commit is contained in:
Sam Davis 2025-12-30 12:49:18 +11:00 committed by GitHub
parent b01eb50325
commit 278b056bd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 376 additions and 5 deletions

View File

@ -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::ChapterInfo>& Xtc::getChapters() const {
static const std::vector<xtc::ChapterInfo> kEmpty;
if (!loaded || !parser) {
return kEmpty;
}
return parser->getChapters();
}
std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
bool Xtc::generateCoverBmp() const {

View File

@ -9,6 +9,7 @@
#include <memory>
#include <string>
#include <vector>
#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<xtc::ChapterInfo>& getChapters() const;
// Cover image support (for sleep screen)
std::string getCoverBmpPath() const;

View File

@ -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<uint8_t*>(&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<size_t>(available / chapterSize);
if (chapterCount == 0) {
return XtcError::OK;
}
if (!m_file.seek(chapterOffset)) {
return XtcError::READ_ERROR;
}
std::vector<uint8_t> 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<unsigned int>(m_chapters.size()));
return XtcError::OK;
}
bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) const {
if (pageIndex >= m_pageTable.size()) {
return false;

View File

@ -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<ChapterInfo>& 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<PageInfo> m_pageTable;
std::vector<ChapterInfo> 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

View File

@ -13,6 +13,7 @@
#pragma once
#include <cstdint>
#include <string>
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,

View File

@ -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();

View File

@ -12,9 +12,9 @@
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "activities/Activity.h"
#include "activities/ActivityWithSubactivity.h"
class XtcReaderActivity final : public Activity {
class XtcReaderActivity final : public ActivityWithSubactivity {
std::shared_ptr<Xtc> 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> xtc,
const std::function<void()>& onGoBack, const std::function<void()>& 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;

View File

@ -0,0 +1,152 @@
#include "XtcReaderChapterSelectionActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include <SD.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<int>(i);
}
}
return 0;
}
void XtcReaderChapterSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<XtcReaderChapterSelectionActivity*>(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<int>(chapters.size())) {
onSelectPage(chapters[selectorIndex].startPage);
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
} else if (prevReleased) {
const int total = static_cast<int>(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<int>(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<int>(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();
}

View File

@ -0,0 +1,41 @@
#pragma once
#include <Xtc.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <memory>
#include "../Activity.h"
class XtcReaderChapterSelectionActivity final : public Activity {
std::shared_ptr<Xtc> xtc;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
uint32_t currentPage = 0;
int selectorIndex = 0;
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void(uint32_t newPage)> 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>& xtc, uint32_t currentPage,
const std::function<void()>& onGoBack,
const std::function<void(uint32_t newPage)>& onSelectPage)
: Activity("XtcReaderChapterSelection", renderer, mappedInput),
xtc(xtc),
currentPage(currentPage),
onGoBack(onGoBack),
onSelectPage(onSelectPage) {}
void onEnter() override;
void onExit() override;
void loop() override;
};