Compare commits

...

10 Commits

Author SHA1 Message Date
Dave Allie
2fa73de61e
Update open-x4-sdk 2025-12-30 16:02:51 +11:00
Dave Allie
1ec66d57bb
Defensive handling for readItemContentsToBytes and readItemContentsToStream 2025-12-30 15:59:00 +11:00
Dave Allie
d608fb9848
Make sections directory if it does not exist 2025-12-30 15:58:34 +11:00
Dave Allie
4fa5772572
Use explicit uint32_t in BookMetadataCache and Section file handling 2025-12-30 15:58:16 +11:00
Dave Allie
fd6373c128
Merge branch 'master' into feature/exfat-support 2025-12-30 13:46:51 +11:00
Dave Allie
e4ac90f5c1
Accept big endian version in XTC files (#159)
## Summary

* Accept big endian version in XTC files
* Recently, two issues
(https://github.com/daveallie/crosspoint-reader/issues/157 and
https://github.com/daveallie/crosspoint-reader/issues/146) have popped
up with XTC files with a big endian encoded version. This is read out as
256.
  * We should be more lax and accept these values.
2025-12-30 13:36:25 +11:00
Sam Davis
278b056bd0
Add chapter select support to XTC files (#145)
Some checks are pending
CI / build (push) Waiting to run
## 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.
2025-12-30 12:49:18 +11:00
Jonas Diemer
b01eb50325
Shorten continueLabel to actual screen width. (#151)
Some checks are pending
CI / build (push) Waiting to run
2025-12-29 23:18:23 +11:00
Jonas Diemer
1bfe694807
Improvement/settings selection by inversion (#152)
Style in settings like everywhere else. Also improved the alignment of
the settings value.
2025-12-29 23:18:16 +11:00
Jonas Diemer
7b32a87596
Recalibrated power button duration, decreased long setting slightly. (#149)
Slight tuning, as I noticed sometimes inconsistent behavior (reported
>200ms of calibration value, I assume related to the Serial output).
2025-12-29 23:18:12 +11:00
21 changed files with 455 additions and 58 deletions

View File

@ -312,6 +312,11 @@ bool Epub::generateCoverBmp() const {
}
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
if (itemHref.empty()) {
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis());
return nullptr;
}
const std::string path = FsHelpers::normalisePath(itemHref);
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
@ -324,6 +329,11 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
}
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
if (itemHref.empty()) {
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis());
return false;
}
const std::string path = FsHelpers::normalisePath(itemHref);
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize);
}

View File

@ -85,12 +85,12 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
return false;
}
constexpr size_t headerASize =
sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(size_t) + sizeof(spineCount) + sizeof(tocCount);
const size_t metadataSize =
constexpr uint32_t headerASize =
sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(uint32_t) + sizeof(spineCount) + sizeof(tocCount);
const uint32_t metadataSize =
metadata.title.size() + metadata.author.size() + metadata.coverItemHref.size() + sizeof(uint32_t) * 3;
const size_t lutSize = sizeof(size_t) * spineCount + sizeof(size_t) * tocCount;
const size_t lutOffset = headerASize + metadataSize;
const uint32_t lutSize = sizeof(uint32_t) * spineCount + sizeof(uint32_t) * tocCount;
const uint32_t lutOffset = headerASize + metadataSize;
// Header A
serialization::writePod(bookFile, BOOK_CACHE_VERSION);
@ -105,7 +105,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
// Loop through spine entries, writing LUT positions
spineFile.seek(0);
for (int i = 0; i < spineCount; i++) {
auto pos = spineFile.position();
uint32_t pos = spineFile.position();
auto spineEntry = readSpineEntry(spineFile);
serialization::writePod(bookFile, pos + lutOffset + lutSize);
}
@ -113,9 +113,9 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
// Loop through toc entries, writing LUT positions
tocFile.seek(0);
for (int i = 0; i < tocCount; i++) {
auto pos = tocFile.position();
uint32_t pos = tocFile.position();
auto tocEntry = readTocEntry(tocFile);
serialization::writePod(bookFile, pos + lutOffset + lutSize + spineFile.position());
serialization::writePod(bookFile, pos + lutOffset + lutSize + static_cast<uint32_t>(spineFile.position()));
}
// LUTs complete
@ -141,7 +141,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
zip.close();
return false;
}
size_t cumSize = 0;
uint32_t cumSize = 0;
spineFile.seek(0);
for (int i = 0; i < spineCount; i++) {
auto spineEntry = readSpineEntry(spineFile);
@ -203,16 +203,16 @@ bool BookMetadataCache::cleanupTmpFiles() const {
return true;
}
size_t BookMetadataCache::writeSpineEntry(FsFile& file, const SpineEntry& entry) const {
const auto pos = file.position();
uint32_t BookMetadataCache::writeSpineEntry(FsFile& file, const SpineEntry& entry) const {
const uint32_t pos = file.position();
serialization::writeString(file, entry.href);
serialization::writePod(file, entry.cumulativeSize);
serialization::writePod(file, entry.tocIndex);
return pos;
}
size_t BookMetadataCache::writeTocEntry(FsFile& file, const TocEntry& entry) const {
const auto pos = file.position();
uint32_t BookMetadataCache::writeTocEntry(FsFile& file, const TocEntry& entry) const {
const uint32_t pos = file.position();
serialization::writeString(file, entry.title);
serialization::writeString(file, entry.href);
serialization::writeString(file, entry.anchor);
@ -303,8 +303,8 @@ BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index)
}
// Seek to spine LUT item, read from LUT and get out data
bookFile.seek(lutOffset + sizeof(size_t) * index);
size_t spineEntryPos;
bookFile.seek(lutOffset + sizeof(uint32_t) * index);
uint32_t spineEntryPos;
serialization::readPod(bookFile, spineEntryPos);
bookFile.seek(spineEntryPos);
return readSpineEntry(bookFile);
@ -322,8 +322,8 @@ BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
}
// Seek to TOC LUT item, read from LUT and get out data
bookFile.seek(lutOffset + sizeof(size_t) * spineCount + sizeof(size_t) * index);
size_t tocEntryPos;
bookFile.seek(lutOffset + sizeof(uint32_t) * spineCount + sizeof(uint32_t) * index);
uint32_t tocEntryPos;
serialization::readPod(bookFile, tocEntryPos);
bookFile.seek(tocEntryPos);
return readTocEntry(bookFile);

View File

@ -51,8 +51,8 @@ class BookMetadataCache {
FsFile spineFile;
FsFile tocFile;
size_t writeSpineEntry(FsFile& file, const SpineEntry& entry) const;
size_t writeTocEntry(FsFile& file, const TocEntry& entry) const;
uint32_t writeSpineEntry(FsFile& file, const SpineEntry& entry) const;
uint32_t writeTocEntry(FsFile& file, const TocEntry& entry) const;
SpineEntry readSpineEntry(FsFile& file) const;
TocEntry readTocEntry(FsFile& file) const;

View File

@ -8,17 +8,17 @@
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 7;
constexpr size_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(int) +
sizeof(int) + sizeof(int) + sizeof(size_t);
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(int) +
sizeof(int) + sizeof(int) + sizeof(uint32_t);
} // namespace
size_t Section::onPageComplete(std::unique_ptr<Page> page) {
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
if (!file) {
Serial.printf("[%lu] [SCT] File not open for writing page %d\n", millis(), pageCount);
return 0;
}
const auto position = file.position();
const uint32_t position = file.position();
if (!page->serialize(file)) {
Serial.printf("[%lu] [SCT] Failed to serialize page %d\n", millis(), pageCount);
return 0;
@ -37,7 +37,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
}
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
sizeof(extraParagraphSpacing) + sizeof(viewportWidth) + sizeof(viewportHeight) +
sizeof(pageCount) + sizeof(size_t),
sizeof(pageCount) + sizeof(uint32_t),
"Header size mismatch");
serialization::writePod(file, SECTION_FILE_VERSION);
serialization::writePod(file, fontId);
@ -46,7 +46,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
serialization::writePod(file, viewportWidth);
serialization::writePod(file, viewportHeight);
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written)
serialization::writePod(file, static_cast<size_t>(0)); // Placeholder for LUT offset
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset
}
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
@ -111,13 +111,19 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
const int viewportWidth, const int viewportHeight,
const std::function<void()>& progressSetupFn,
const std::function<void(int)>& progressFn) {
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
// Create cache directory if it doesn't exist
{
const auto sectionsDir = epub->getCachePath() + "/sections";
SdMan.mkdir(sectionsDir.c_str());
}
// Retry logic for SD card timing issues
bool success = false;
size_t fileSize = 0;
uint32_t fileSize = 0;
for (int attempt = 0; attempt < 3 && !success; attempt++) {
if (attempt > 0) {
Serial.printf("[%lu] [SCT] Retrying stream (attempt %d)...\n", millis(), attempt + 1);
@ -160,7 +166,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
return false;
}
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight);
std::vector<size_t> lut = {};
std::vector<uint32_t> lut = {};
ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight,
@ -176,10 +182,10 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
return false;
}
const auto lutOffset = file.position();
const uint32_t lutOffset = file.position();
bool hasFailedLutRecords = false;
// Write LUT
for (const auto& pos : lut) {
for (const uint32_t& pos : lut) {
if (pos == 0) {
hasFailedLutRecords = true;
break;
@ -195,7 +201,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
}
// Go back and write LUT offset
file.seek(HEADER_SIZE - sizeof(size_t) - sizeof(pageCount));
file.seek(HEADER_SIZE - sizeof(uint32_t) - sizeof(pageCount));
serialization::writePod(file, pageCount);
serialization::writePod(file, lutOffset);
file.close();
@ -207,11 +213,11 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
return nullptr;
}
file.seek(HEADER_SIZE - sizeof(size_t));
size_t lutOffset;
file.seek(HEADER_SIZE - sizeof(uint32_t));
uint32_t lutOffset;
serialization::readPod(file, lutOffset);
file.seek(lutOffset + sizeof(size_t) * currentPage);
size_t pagePos;
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
uint32_t pagePos;
serialization::readPod(file, pagePos);
file.seek(pagePos);

View File

@ -16,7 +16,7 @@ class Section {
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int viewportHeight);
size_t onPageComplete(std::unique_ptr<Page> page);
uint32_t onPageComplete(std::unique_ptr<Page> page);
public:
int pageCount = 0;

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

@ -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<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,

@ -1 +1 @@
Subproject commit 0d269feed2d6450a8a9149fd333c5336ca25daf2
Subproject commit bd4e6707503ab9c97d13ee0d8f8c69e9ff03cd12

View File

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

View File

@ -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<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
const auto sideLayout = static_cast<CrossPointSettings::SIDE_BUTTON_LAYOUT>(SETTINGS.sideButtonLayout);

View File

@ -2,8 +2,6 @@
#include <InputManager.h>
#include "CrossPointSettings.h"
class MappedInputManager {
public:
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward };

View File

@ -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++;

View File

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

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,151 @@
#include "XtcReaderChapterSelectionActivity.h"
#include <GfxRenderer.h>
#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<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;
};

View File

@ -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

View File

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