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
9 changed files with 376 additions and 5 deletions

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;
};