mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 23:27:38 +03:00
Add TXT file reader support
- Add Txt library for loading and parsing plain text files - Create TxtReaderActivity with streaming page rendering - Uses 8KB chunks to handle large files without memory issues - Page index caching for fast re-open after sleep - Progress bar during initial indexing - Word wrapping with UTF-8 support - Support cover images for TXT files - Priority: same filename as TXT (e.g., book.jpg for book.txt) - Fallback: cover.bmp/jpg/jpeg in same folder - Converts JPG to BMP using existing converter - Update SleepActivity to show TXT cover images in Cover mode - Add .txt extension to file browser filter
This commit is contained in:
parent
14972b34cb
commit
98f14da65a
189
lib/Txt/Txt.cpp
Normal file
189
lib/Txt/Txt.cpp
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
#include "Txt.h"
|
||||||
|
|
||||||
|
#include <FsHelpers.h>
|
||||||
|
#include <JpegToBmpConverter.h>
|
||||||
|
|
||||||
|
Txt::Txt(std::string path, std::string cacheBasePath) : filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) {
|
||||||
|
// Generate cache path from file path hash
|
||||||
|
const size_t hash = std::hash<std::string>{}(filepath);
|
||||||
|
cachePath = this->cacheBasePath + "/txt_" + std::to_string(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Txt::load() {
|
||||||
|
if (loaded) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SdMan.exists(filepath.c_str())) {
|
||||||
|
Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForRead("TXT", filepath, file)) {
|
||||||
|
Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileSize = file.size();
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
loaded = true;
|
||||||
|
Serial.printf("[%lu] [TXT] Loaded TXT file: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Txt::getTitle() const {
|
||||||
|
// Extract filename without path and extension
|
||||||
|
size_t lastSlash = filepath.find_last_of('/');
|
||||||
|
std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath;
|
||||||
|
|
||||||
|
// Remove .txt extension
|
||||||
|
if (filename.length() >= 4 && filename.substr(filename.length() - 4) == ".txt") {
|
||||||
|
filename = filename.substr(0, filename.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Txt::setupCacheDir() const {
|
||||||
|
if (!SdMan.exists(cacheBasePath.c_str())) {
|
||||||
|
SdMan.mkdir(cacheBasePath.c_str());
|
||||||
|
}
|
||||||
|
if (!SdMan.exists(cachePath.c_str())) {
|
||||||
|
SdMan.mkdir(cachePath.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Txt::findCoverImage() const {
|
||||||
|
// Get the folder containing the txt file
|
||||||
|
size_t lastSlash = filepath.find_last_of('/');
|
||||||
|
std::string folder = (lastSlash != std::string::npos) ? filepath.substr(0, lastSlash) : "";
|
||||||
|
if (folder.empty()) {
|
||||||
|
folder = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the base filename without extension (e.g., "mybook" from "/books/mybook.txt")
|
||||||
|
std::string baseName = getTitle();
|
||||||
|
|
||||||
|
// Image extensions to try
|
||||||
|
const char* extensions[] = {".bmp", ".jpg", ".jpeg", ".png", ".BMP", ".JPG", ".JPEG", ".PNG"};
|
||||||
|
|
||||||
|
// First priority: look for image with same name as txt file (e.g., mybook.jpg)
|
||||||
|
for (const auto& ext : extensions) {
|
||||||
|
std::string coverPath = folder + "/" + baseName + ext;
|
||||||
|
if (SdMan.exists(coverPath.c_str())) {
|
||||||
|
Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str());
|
||||||
|
return coverPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: look for cover image files
|
||||||
|
const char* coverNames[] = {"cover", "Cover", "COVER"};
|
||||||
|
for (const auto& name : coverNames) {
|
||||||
|
for (const auto& ext : extensions) {
|
||||||
|
std::string coverPath = folder + "/" + std::string(name) + ext;
|
||||||
|
if (SdMan.exists(coverPath.c_str())) {
|
||||||
|
Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str());
|
||||||
|
return coverPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
|
||||||
|
|
||||||
|
bool Txt::generateCoverBmp() const {
|
||||||
|
// Already generated, return true
|
||||||
|
if (SdMan.exists(getCoverBmpPath().c_str())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string coverImagePath = findCoverImage();
|
||||||
|
if (coverImagePath.empty()) {
|
||||||
|
Serial.printf("[%lu] [TXT] No cover image found for TXT file\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup cache directory
|
||||||
|
setupCacheDir();
|
||||||
|
|
||||||
|
// Get file extension
|
||||||
|
const size_t len = coverImagePath.length();
|
||||||
|
const bool isJpg = (len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) ||
|
||||||
|
(len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG"));
|
||||||
|
const bool isBmp = len >= 4 && (coverImagePath.substr(len - 4) == ".bmp" || coverImagePath.substr(len - 4) == ".BMP");
|
||||||
|
|
||||||
|
if (isBmp) {
|
||||||
|
// Copy BMP file to cache
|
||||||
|
Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis());
|
||||||
|
FsFile src, dst;
|
||||||
|
if (!SdMan.openFileForRead("TXT", coverImagePath, src)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), dst)) {
|
||||||
|
src.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint8_t buffer[1024];
|
||||||
|
while (src.available()) {
|
||||||
|
size_t bytesRead = src.read(buffer, sizeof(buffer));
|
||||||
|
dst.write(buffer, bytesRead);
|
||||||
|
}
|
||||||
|
src.close();
|
||||||
|
dst.close();
|
||||||
|
Serial.printf("[%lu] [TXT] Copied BMP cover to cache\n", millis());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJpg) {
|
||||||
|
// Convert JPG/JPEG to BMP (same approach as Epub)
|
||||||
|
Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis());
|
||||||
|
FsFile coverJpg, coverBmp;
|
||||||
|
if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) {
|
||||||
|
coverJpg.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp);
|
||||||
|
coverJpg.close();
|
||||||
|
coverBmp.close();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis());
|
||||||
|
SdMan.remove(getCoverBmpPath().c_str());
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis());
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG files are not supported (would need a PNG decoder)
|
||||||
|
Serial.printf("[%lu] [TXT] Cover image format not supported (only BMP/JPG/JPEG)\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const {
|
||||||
|
if (!loaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForRead("TXT", filepath, file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.seek(offset)) {
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t bytesRead = file.read(buffer, length);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
return bytesRead > 0;
|
||||||
|
}
|
||||||
33
lib/Txt/Txt.h
Normal file
33
lib/Txt/Txt.h
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class Txt {
|
||||||
|
std::string filepath;
|
||||||
|
std::string cacheBasePath;
|
||||||
|
std::string cachePath;
|
||||||
|
bool loaded = false;
|
||||||
|
size_t fileSize = 0;
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit Txt(std::string path, std::string cacheBasePath);
|
||||||
|
|
||||||
|
bool load();
|
||||||
|
[[nodiscard]] const std::string& getPath() const { return filepath; }
|
||||||
|
[[nodiscard]] const std::string& getCachePath() const { return cachePath; }
|
||||||
|
[[nodiscard]] std::string getTitle() const;
|
||||||
|
[[nodiscard]] size_t getFileSize() const { return fileSize; }
|
||||||
|
|
||||||
|
void setupCacheDir() const;
|
||||||
|
|
||||||
|
// Cover image support - looks for cover.bmp/jpg/jpeg/png in same folder as txt file
|
||||||
|
[[nodiscard]] std::string getCoverBmpPath() const;
|
||||||
|
[[nodiscard]] bool generateCoverBmp() const;
|
||||||
|
[[nodiscard]] std::string findCoverImage() const;
|
||||||
|
|
||||||
|
// Read content from file
|
||||||
|
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
|
||||||
|
};
|
||||||
@ -3,6 +3,7 @@
|
|||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
|
#include <Txt.h>
|
||||||
#include <Xtc.h>
|
#include <Xtc.h>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
@ -22,6 +23,13 @@ bool isXtcFile(const std::string& path) {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if path has TXT extension
|
||||||
|
bool isTxtFile(const std::string& path) {
|
||||||
|
if (path.length() < 4) return false;
|
||||||
|
std::string ext4 = path.substr(path.length() - 4);
|
||||||
|
return ext4 == ".txt" || ext4 == ".TXT";
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void SleepActivity::onEnter() {
|
void SleepActivity::onEnter() {
|
||||||
@ -192,7 +200,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
|
|
||||||
std::string coverBmpPath;
|
std::string coverBmpPath;
|
||||||
|
|
||||||
// Check if the current book is XTC or EPUB
|
// Check if the current book is XTC, TXT, or EPUB
|
||||||
if (isXtcFile(APP_STATE.openEpubPath)) {
|
if (isXtcFile(APP_STATE.openEpubPath)) {
|
||||||
// Handle XTC file
|
// Handle XTC file
|
||||||
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
|
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
|
||||||
@ -207,6 +215,20 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
coverBmpPath = lastXtc.getCoverBmpPath();
|
coverBmpPath = lastXtc.getCoverBmpPath();
|
||||||
|
} else if (isTxtFile(APP_STATE.openEpubPath)) {
|
||||||
|
// Handle TXT file - looks for cover image in the same folder
|
||||||
|
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
|
||||||
|
if (!lastTxt.load()) {
|
||||||
|
Serial.println("[SLP] Failed to load last TXT");
|
||||||
|
return renderDefaultSleepScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastTxt.generateCoverBmp()) {
|
||||||
|
Serial.println("[SLP] No cover image found for TXT file");
|
||||||
|
return renderDefaultSleepScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
coverBmpPath = lastTxt.getCoverBmpPath();
|
||||||
} else {
|
} else {
|
||||||
// Handle EPUB file
|
// Handle EPUB file
|
||||||
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||||
|
|||||||
@ -53,7 +53,7 @@ void FileSelectionActivity::loadFiles() {
|
|||||||
auto filename = std::string(name);
|
auto filename = std::string(name);
|
||||||
std::string ext4 = filename.length() >= 4 ? filename.substr(filename.length() - 4) : "";
|
std::string ext4 = filename.length() >= 4 ? filename.substr(filename.length() - 4) : "";
|
||||||
std::string ext5 = filename.length() >= 5 ? filename.substr(filename.length() - 5) : "";
|
std::string ext5 = filename.length() >= 5 ? filename.substr(filename.length() - 5) : "";
|
||||||
if (ext5 == ".epub" || ext5 == ".xtch" || ext4 == ".xtc") {
|
if (ext5 == ".epub" || ext5 == ".xtch" || ext4 == ".xtc" || ext4 == ".txt") {
|
||||||
files.emplace_back(filename);
|
files.emplace_back(filename);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
#include "Epub.h"
|
#include "Epub.h"
|
||||||
#include "EpubReaderActivity.h"
|
#include "EpubReaderActivity.h"
|
||||||
#include "FileSelectionActivity.h"
|
#include "FileSelectionActivity.h"
|
||||||
|
#include "Txt.h"
|
||||||
|
#include "TxtReaderActivity.h"
|
||||||
#include "Xtc.h"
|
#include "Xtc.h"
|
||||||
#include "XtcReaderActivity.h"
|
#include "XtcReaderActivity.h"
|
||||||
#include "activities/util/FullScreenMessageActivity.h"
|
#include "activities/util/FullScreenMessageActivity.h"
|
||||||
@ -26,6 +28,12 @@ bool ReaderActivity::isXtcFile(const std::string& path) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ReaderActivity::isTxtFile(const std::string& path) {
|
||||||
|
if (path.length() < 4) return false;
|
||||||
|
std::string ext4 = path.substr(path.length() - 4);
|
||||||
|
return ext4 == ".txt" || ext4 == ".TXT";
|
||||||
|
}
|
||||||
|
|
||||||
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
||||||
if (!SdMan.exists(path.c_str())) {
|
if (!SdMan.exists(path.c_str())) {
|
||||||
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
||||||
@ -56,6 +64,21 @@ std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
|
||||||
|
if (!SdMan.exists(path.c_str())) {
|
||||||
|
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto txt = std::unique_ptr<Txt>(new Txt(path, "/.crosspoint"));
|
||||||
|
if (txt->load()) {
|
||||||
|
return txt;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [ ] Failed to load TXT\n", millis());
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
void ReaderActivity::onSelectBookFile(const std::string& path) {
|
void ReaderActivity::onSelectBookFile(const std::string& path) {
|
||||||
currentBookPath = path; // Track current book path
|
currentBookPath = path; // Track current book path
|
||||||
exitActivity();
|
exitActivity();
|
||||||
@ -73,6 +96,18 @@ void ReaderActivity::onSelectBookFile(const std::string& path) {
|
|||||||
delay(2000);
|
delay(2000);
|
||||||
onGoToFileSelection();
|
onGoToFileSelection();
|
||||||
}
|
}
|
||||||
|
} else if (isTxtFile(path)) {
|
||||||
|
// Load TXT file
|
||||||
|
auto txt = loadTxt(path);
|
||||||
|
if (txt) {
|
||||||
|
onGoToTxtReader(std::move(txt));
|
||||||
|
} else {
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load TXT",
|
||||||
|
EpdFontFamily::REGULAR, EInkDisplay::HALF_REFRESH));
|
||||||
|
delay(2000);
|
||||||
|
onGoToFileSelection();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Load EPUB file
|
// Load EPUB file
|
||||||
auto epub = loadEpub(path);
|
auto epub = loadEpub(path);
|
||||||
@ -114,6 +149,15 @@ void ReaderActivity::onGoToXtcReader(std::unique_ptr<Xtc> xtc) {
|
|||||||
[this] { onGoBack(); }));
|
[this] { onGoBack(); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {
|
||||||
|
const auto txtPath = txt->getPath();
|
||||||
|
currentBookPath = txtPath;
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new TxtReaderActivity(
|
||||||
|
renderer, mappedInput, std::move(txt), [this, txtPath] { onGoToFileSelection(txtPath); },
|
||||||
|
[this] { onGoBack(); }));
|
||||||
|
}
|
||||||
|
|
||||||
void ReaderActivity::onEnter() {
|
void ReaderActivity::onEnter() {
|
||||||
ActivityWithSubactivity::onEnter();
|
ActivityWithSubactivity::onEnter();
|
||||||
|
|
||||||
@ -131,6 +175,13 @@ void ReaderActivity::onEnter() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onGoToXtcReader(std::move(xtc));
|
onGoToXtcReader(std::move(xtc));
|
||||||
|
} else if (isTxtFile(initialBookPath)) {
|
||||||
|
auto txt = loadTxt(initialBookPath);
|
||||||
|
if (!txt) {
|
||||||
|
onGoBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onGoToTxtReader(std::move(txt));
|
||||||
} else {
|
} else {
|
||||||
auto epub = loadEpub(initialBookPath);
|
auto epub = loadEpub(initialBookPath);
|
||||||
if (!epub) {
|
if (!epub) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
class Epub;
|
class Epub;
|
||||||
class Xtc;
|
class Xtc;
|
||||||
|
class Txt;
|
||||||
|
|
||||||
class ReaderActivity final : public ActivityWithSubactivity {
|
class ReaderActivity final : public ActivityWithSubactivity {
|
||||||
std::string initialBookPath;
|
std::string initialBookPath;
|
||||||
@ -12,13 +13,16 @@ class ReaderActivity final : public ActivityWithSubactivity {
|
|||||||
const std::function<void()> onGoBack;
|
const std::function<void()> onGoBack;
|
||||||
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||||
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
||||||
|
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
||||||
static bool isXtcFile(const std::string& path);
|
static bool isXtcFile(const std::string& path);
|
||||||
|
static bool isTxtFile(const std::string& path);
|
||||||
|
|
||||||
static std::string extractFolderPath(const std::string& filePath);
|
static std::string extractFolderPath(const std::string& filePath);
|
||||||
void onSelectBookFile(const std::string& path);
|
void onSelectBookFile(const std::string& path);
|
||||||
void onGoToFileSelection(const std::string& fromBookPath = "");
|
void onGoToFileSelection(const std::string& fromBookPath = "");
|
||||||
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
||||||
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
||||||
|
void onGoToTxtReader(std::unique_ptr<Txt> txt);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
|
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
|
||||||
|
|||||||
622
src/activities/reader/TxtReaderActivity.cpp
Normal file
622
src/activities/reader/TxtReaderActivity.cpp
Normal file
@ -0,0 +1,622 @@
|
|||||||
|
#include "TxtReaderActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <Utf8.h>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
|
#include "CrossPointState.h"
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "ScreenComponents.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr unsigned long goHomeMs = 1000;
|
||||||
|
constexpr int topPadding = 10;
|
||||||
|
constexpr int horizontalPadding = 15;
|
||||||
|
constexpr int statusBarMargin = 25;
|
||||||
|
constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void TxtReaderActivity::taskTrampoline(void* param) {
|
||||||
|
auto* self = static_cast<TxtReaderActivity*>(param);
|
||||||
|
self->displayTaskLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::onEnter() {
|
||||||
|
ActivityWithSubactivity::onEnter();
|
||||||
|
|
||||||
|
if (!txt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure screen orientation based on settings
|
||||||
|
switch (SETTINGS.orientation) {
|
||||||
|
case CrossPointSettings::ORIENTATION::PORTRAIT:
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||||
|
break;
|
||||||
|
case CrossPointSettings::ORIENTATION::LANDSCAPE_CW:
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
|
||||||
|
break;
|
||||||
|
case CrossPointSettings::ORIENTATION::INVERTED:
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted);
|
||||||
|
break;
|
||||||
|
case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW:
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
|
|
||||||
|
txt->setupCacheDir();
|
||||||
|
|
||||||
|
// Save current txt as last opened file
|
||||||
|
APP_STATE.openEpubPath = txt->getPath();
|
||||||
|
APP_STATE.saveToFile();
|
||||||
|
|
||||||
|
// Trigger first update
|
||||||
|
updateRequired = true;
|
||||||
|
|
||||||
|
xTaskCreate(&TxtReaderActivity::taskTrampoline, "TxtReaderActivityTask",
|
||||||
|
6144, // Stack size
|
||||||
|
this, // Parameters
|
||||||
|
1, // Priority
|
||||||
|
&displayTaskHandle // Task handle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::onExit() {
|
||||||
|
ActivityWithSubactivity::onExit();
|
||||||
|
|
||||||
|
// Reset orientation back to portrait for the rest of the UI
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||||
|
|
||||||
|
// Wait until not rendering to delete task
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
if (displayTaskHandle) {
|
||||||
|
vTaskDelete(displayTaskHandle);
|
||||||
|
displayTaskHandle = nullptr;
|
||||||
|
}
|
||||||
|
vSemaphoreDelete(renderingMutex);
|
||||||
|
renderingMutex = nullptr;
|
||||||
|
pageOffsets.clear();
|
||||||
|
currentPageLines.clear();
|
||||||
|
txt.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::loop() {
|
||||||
|
if (subActivity) {
|
||||||
|
subActivity->loop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long press BACK (1s+) goes directly to home
|
||||||
|
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
|
||||||
|
onGoHome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short press BACK goes to file selection
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
|
||||||
|
onGoBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||||
|
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||||
|
|
||||||
|
if (!prevReleased && !nextReleased) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevReleased && currentPage > 0) {
|
||||||
|
currentPage--;
|
||||||
|
updateRequired = true;
|
||||||
|
} else if (nextReleased && currentPage < totalPages - 1) {
|
||||||
|
currentPage++;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::displayTaskLoop() {
|
||||||
|
while (true) {
|
||||||
|
if (updateRequired) {
|
||||||
|
updateRequired = false;
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
renderScreen();
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::initializeReader() {
|
||||||
|
if (initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate viewport dimensions
|
||||||
|
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||||
|
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft);
|
||||||
|
orientedMarginTop += topPadding;
|
||||||
|
orientedMarginLeft += horizontalPadding;
|
||||||
|
orientedMarginRight += horizontalPadding;
|
||||||
|
orientedMarginBottom += statusBarMargin;
|
||||||
|
|
||||||
|
viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
|
||||||
|
const int viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
|
||||||
|
const int lineHeight = renderer.getLineHeight(SETTINGS.getReaderFontId());
|
||||||
|
|
||||||
|
linesPerPage = viewportHeight / lineHeight;
|
||||||
|
if (linesPerPage < 1) linesPerPage = 1;
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [TRS] Viewport: %dx%d, lines per page: %d\n", millis(), viewportWidth, viewportHeight, linesPerPage);
|
||||||
|
|
||||||
|
// Try to load cached page index first
|
||||||
|
if (!loadPageIndexCache()) {
|
||||||
|
// Cache not found, build page index
|
||||||
|
buildPageIndex();
|
||||||
|
// Save to cache for next time
|
||||||
|
savePageIndexCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved progress
|
||||||
|
loadProgress();
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::buildPageIndex() {
|
||||||
|
pageOffsets.clear();
|
||||||
|
pageOffsets.push_back(0); // First page starts at offset 0
|
||||||
|
|
||||||
|
size_t offset = 0;
|
||||||
|
const size_t fileSize = txt->getFileSize();
|
||||||
|
int lastProgressPercent = -1;
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize);
|
||||||
|
|
||||||
|
// Progress bar dimensions (matching EpubReaderActivity style)
|
||||||
|
constexpr int barWidth = 200;
|
||||||
|
constexpr int barHeight = 10;
|
||||||
|
constexpr int boxMargin = 20;
|
||||||
|
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing...");
|
||||||
|
const int boxWidth = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
|
||||||
|
const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3;
|
||||||
|
const int boxX = (renderer.getScreenWidth() - boxWidth) / 2;
|
||||||
|
constexpr int boxY = 50;
|
||||||
|
const int barX = boxX + (boxWidth - barWidth) / 2;
|
||||||
|
const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
|
||||||
|
|
||||||
|
// Draw initial progress box
|
||||||
|
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||||
|
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Indexing...");
|
||||||
|
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||||
|
renderer.drawRect(barX, barY, barWidth, barHeight);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
|
||||||
|
while (offset < fileSize) {
|
||||||
|
std::vector<std::string> tempLines;
|
||||||
|
size_t nextOffset = offset;
|
||||||
|
|
||||||
|
if (!loadPageAtOffset(offset, tempLines, nextOffset)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextOffset <= offset) {
|
||||||
|
// No progress made, avoid infinite loop
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = nextOffset;
|
||||||
|
if (offset < fileSize) {
|
||||||
|
pageOffsets.push_back(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress bar every 2%
|
||||||
|
int progressPercent = (offset * 100) / fileSize;
|
||||||
|
if (progressPercent != lastProgressPercent && progressPercent % 2 == 0) {
|
||||||
|
lastProgressPercent = progressPercent;
|
||||||
|
|
||||||
|
// Fill progress bar
|
||||||
|
const int fillWidth = (barWidth - 2) * progressPercent / 100;
|
||||||
|
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
||||||
|
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield to other tasks periodically
|
||||||
|
if (pageOffsets.size() % 20 == 0) {
|
||||||
|
vTaskDelay(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages = pageOffsets.size();
|
||||||
|
Serial.printf("[%lu] [TRS] Built page index: %d pages\n", millis(), totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>& outLines, size_t& nextOffset) {
|
||||||
|
outLines.clear();
|
||||||
|
const size_t fileSize = txt->getFileSize();
|
||||||
|
|
||||||
|
if (offset >= fileSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a chunk from file
|
||||||
|
size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset);
|
||||||
|
auto* buffer = static_cast<uint8_t*>(malloc(chunkSize + 1));
|
||||||
|
if (!buffer) {
|
||||||
|
Serial.printf("[%lu] [TRS] Failed to allocate %zu bytes\n", millis(), chunkSize);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!txt->readContent(buffer, offset, chunkSize)) {
|
||||||
|
free(buffer);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
buffer[chunkSize] = '\0';
|
||||||
|
|
||||||
|
// Parse lines from buffer
|
||||||
|
size_t pos = 0;
|
||||||
|
size_t bytesConsumed = 0;
|
||||||
|
|
||||||
|
while (pos < chunkSize && static_cast<int>(outLines.size()) < linesPerPage) {
|
||||||
|
// Find end of line
|
||||||
|
size_t lineEnd = pos;
|
||||||
|
while (lineEnd < chunkSize && buffer[lineEnd] != '\n') {
|
||||||
|
lineEnd++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have a complete line
|
||||||
|
bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize);
|
||||||
|
|
||||||
|
if (!lineComplete && static_cast<int>(outLines.size()) > 0) {
|
||||||
|
// Incomplete line and we already have some lines, stop here
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract line (without newline)
|
||||||
|
std::string line(reinterpret_cast<char*>(buffer + pos), lineEnd - pos);
|
||||||
|
|
||||||
|
// Remove carriage return if present
|
||||||
|
if (!line.empty() && line.back() == '\r') {
|
||||||
|
line.pop_back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word wrap if needed
|
||||||
|
while (!line.empty() && static_cast<int>(outLines.size()) < linesPerPage) {
|
||||||
|
int lineWidth = renderer.getTextWidth(SETTINGS.getReaderFontId(), line.c_str());
|
||||||
|
|
||||||
|
if (lineWidth <= viewportWidth) {
|
||||||
|
outLines.push_back(line);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find break point
|
||||||
|
size_t breakPos = line.length();
|
||||||
|
while (breakPos > 0 && renderer.getTextWidth(SETTINGS.getReaderFontId(), line.substr(0, breakPos).c_str()) > viewportWidth) {
|
||||||
|
// Try to break at space
|
||||||
|
size_t spacePos = line.rfind(' ', breakPos - 1);
|
||||||
|
if (spacePos != std::string::npos && spacePos > 0) {
|
||||||
|
breakPos = spacePos;
|
||||||
|
} else {
|
||||||
|
// Break at character boundary for UTF-8
|
||||||
|
breakPos--;
|
||||||
|
// Make sure we don't break in the middle of a UTF-8 sequence
|
||||||
|
while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) {
|
||||||
|
breakPos--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breakPos == 0) {
|
||||||
|
breakPos = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
outLines.push_back(line.substr(0, breakPos));
|
||||||
|
|
||||||
|
// Skip space at break point
|
||||||
|
if (breakPos < line.length() && line[breakPos] == ' ') {
|
||||||
|
breakPos++;
|
||||||
|
}
|
||||||
|
line = line.substr(breakPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we still have remaining wrapped text but no room, don't consume this source line
|
||||||
|
if (!line.empty() && static_cast<int>(outLines.size()) >= linesPerPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move past the newline
|
||||||
|
bytesConsumed = lineEnd + 1;
|
||||||
|
pos = lineEnd + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case where we filled the page mid-line (word wrap)
|
||||||
|
if (bytesConsumed == 0 && !outLines.empty()) {
|
||||||
|
// We processed some wrapped content, estimate bytes consumed
|
||||||
|
// This is approximate - we need to track actual byte positions
|
||||||
|
bytesConsumed = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextOffset = offset + (bytesConsumed > 0 ? bytesConsumed : chunkSize);
|
||||||
|
free(buffer);
|
||||||
|
|
||||||
|
return !outLines.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::renderScreen() {
|
||||||
|
if (!txt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize reader if not done
|
||||||
|
if (!initialized) {
|
||||||
|
renderer.clearScreen();
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
initializeReader();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageOffsets.empty()) {
|
||||||
|
renderer.clearScreen();
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty file", true, EpdFontFamily::BOLD);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounds check
|
||||||
|
if (currentPage < 0) currentPage = 0;
|
||||||
|
if (currentPage >= totalPages) currentPage = totalPages - 1;
|
||||||
|
|
||||||
|
// Load current page content
|
||||||
|
size_t offset = pageOffsets[currentPage];
|
||||||
|
size_t nextOffset;
|
||||||
|
currentPageLines.clear();
|
||||||
|
loadPageAtOffset(offset, currentPageLines, nextOffset);
|
||||||
|
|
||||||
|
renderer.clearScreen();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
// Save progress
|
||||||
|
saveProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::renderPage() {
|
||||||
|
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||||
|
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft);
|
||||||
|
orientedMarginTop += topPadding;
|
||||||
|
orientedMarginLeft += horizontalPadding;
|
||||||
|
orientedMarginRight += horizontalPadding;
|
||||||
|
orientedMarginBottom += statusBarMargin;
|
||||||
|
|
||||||
|
const int lineHeight = renderer.getLineHeight(SETTINGS.getReaderFontId());
|
||||||
|
|
||||||
|
int y = orientedMarginTop;
|
||||||
|
for (const auto& line : currentPageLines) {
|
||||||
|
if (!line.empty()) {
|
||||||
|
renderer.drawText(SETTINGS.getReaderFontId(), orientedMarginLeft, y, line.c_str());
|
||||||
|
}
|
||||||
|
y += lineHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
|
|
||||||
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
|
} else {
|
||||||
|
renderer.displayBuffer();
|
||||||
|
pagesUntilFullRefresh--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,
|
||||||
|
const int orientedMarginLeft) const {
|
||||||
|
const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
|
||||||
|
const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
|
||||||
|
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
|
||||||
|
const bool showTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
|
||||||
|
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
|
||||||
|
|
||||||
|
const auto screenHeight = renderer.getScreenHeight();
|
||||||
|
const auto textY = screenHeight - orientedMarginBottom - 4;
|
||||||
|
int progressTextWidth = 0;
|
||||||
|
|
||||||
|
if (showProgress) {
|
||||||
|
const int progress = totalPages > 0 ? (currentPage + 1) * 100 / totalPages : 0;
|
||||||
|
const std::string progressStr =
|
||||||
|
std::to_string(currentPage + 1) + "/" + std::to_string(totalPages) + " " + std::to_string(progress) + "%";
|
||||||
|
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr.c_str());
|
||||||
|
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY,
|
||||||
|
progressStr.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBattery) {
|
||||||
|
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTitle) {
|
||||||
|
const int titleMarginLeft = 50 + 30 + orientedMarginLeft;
|
||||||
|
const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight;
|
||||||
|
const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight;
|
||||||
|
|
||||||
|
std::string title = txt->getTitle();
|
||||||
|
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||||
|
while (titleWidth > availableTextWidth && title.length() > 11) {
|
||||||
|
title.replace(title.length() - 8, 8, "...");
|
||||||
|
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::saveProgress() const {
|
||||||
|
FsFile f;
|
||||||
|
if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
|
||||||
|
uint8_t data[4];
|
||||||
|
data[0] = currentPage & 0xFF;
|
||||||
|
data[1] = (currentPage >> 8) & 0xFF;
|
||||||
|
data[2] = 0;
|
||||||
|
data[3] = 0;
|
||||||
|
f.write(data, 4);
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::loadProgress() {
|
||||||
|
FsFile f;
|
||||||
|
if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) {
|
||||||
|
uint8_t data[4];
|
||||||
|
if (f.read(data, 4) == 4) {
|
||||||
|
currentPage = data[0] + (data[1] << 8);
|
||||||
|
if (currentPage >= totalPages) {
|
||||||
|
currentPage = totalPages - 1;
|
||||||
|
}
|
||||||
|
if (currentPage < 0) {
|
||||||
|
currentPage = 0;
|
||||||
|
}
|
||||||
|
Serial.printf("[%lu] [TRS] Loaded progress: page %d/%d\n", millis(), currentPage, totalPages);
|
||||||
|
}
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TxtReaderActivity::loadPageIndexCache() {
|
||||||
|
// Cache file format:
|
||||||
|
// - 4 bytes: magic "TXTI"
|
||||||
|
// - 4 bytes: file size (to validate cache)
|
||||||
|
// - 4 bytes: viewport width
|
||||||
|
// - 4 bytes: lines per page
|
||||||
|
// - 4 bytes: total pages count
|
||||||
|
// - N * 4 bytes: page offsets (size_t stored as uint32_t)
|
||||||
|
|
||||||
|
std::string cachePath = txt->getCachePath() + "/index.bin";
|
||||||
|
FsFile f;
|
||||||
|
if (!SdMan.openFileForRead("TRS", cachePath, f)) {
|
||||||
|
Serial.printf("[%lu] [TRS] No page index cache found\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and validate header
|
||||||
|
uint8_t header[20];
|
||||||
|
if (f.read(header, 20) != 20) {
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check magic
|
||||||
|
if (header[0] != 'T' || header[1] != 'X' || header[2] != 'T' || header[3] != 'I') {
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size matches
|
||||||
|
uint32_t cachedFileSize = header[4] | (header[5] << 8) | (header[6] << 16) | (header[7] << 24);
|
||||||
|
if (cachedFileSize != txt->getFileSize()) {
|
||||||
|
Serial.printf("[%lu] [TRS] Cache file size mismatch, rebuilding\n", millis());
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check viewport width matches
|
||||||
|
uint32_t cachedViewportWidth = header[8] | (header[9] << 8) | (header[10] << 16) | (header[11] << 24);
|
||||||
|
if (static_cast<int>(cachedViewportWidth) != viewportWidth) {
|
||||||
|
Serial.printf("[%lu] [TRS] Cache viewport width mismatch, rebuilding\n", millis());
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check lines per page matches
|
||||||
|
uint32_t cachedLinesPerPage = header[12] | (header[13] << 8) | (header[14] << 16) | (header[15] << 24);
|
||||||
|
if (static_cast<int>(cachedLinesPerPage) != linesPerPage) {
|
||||||
|
Serial.printf("[%lu] [TRS] Cache lines per page mismatch, rebuilding\n", millis());
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read total pages
|
||||||
|
uint32_t cachedTotalPages = header[16] | (header[17] << 8) | (header[18] << 16) | (header[19] << 24);
|
||||||
|
|
||||||
|
// Read page offsets
|
||||||
|
pageOffsets.clear();
|
||||||
|
pageOffsets.reserve(cachedTotalPages);
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < cachedTotalPages; i++) {
|
||||||
|
uint8_t offsetData[4];
|
||||||
|
if (f.read(offsetData, 4) != 4) {
|
||||||
|
f.close();
|
||||||
|
pageOffsets.clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint32_t offset = offsetData[0] | (offsetData[1] << 8) | (offsetData[2] << 16) | (offsetData[3] << 24);
|
||||||
|
pageOffsets.push_back(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
totalPages = pageOffsets.size();
|
||||||
|
Serial.printf("[%lu] [TRS] Loaded page index cache: %d pages\n", millis(), totalPages);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::savePageIndexCache() const {
|
||||||
|
std::string cachePath = txt->getCachePath() + "/index.bin";
|
||||||
|
FsFile f;
|
||||||
|
if (!SdMan.openFileForWrite("TRS", cachePath, f)) {
|
||||||
|
Serial.printf("[%lu] [TRS] Failed to save page index cache\n", millis());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write header
|
||||||
|
uint8_t header[20];
|
||||||
|
header[0] = 'T';
|
||||||
|
header[1] = 'X';
|
||||||
|
header[2] = 'T';
|
||||||
|
header[3] = 'I';
|
||||||
|
|
||||||
|
// File size
|
||||||
|
uint32_t fileSize = txt->getFileSize();
|
||||||
|
header[4] = fileSize & 0xFF;
|
||||||
|
header[5] = (fileSize >> 8) & 0xFF;
|
||||||
|
header[6] = (fileSize >> 16) & 0xFF;
|
||||||
|
header[7] = (fileSize >> 24) & 0xFF;
|
||||||
|
|
||||||
|
// Viewport width
|
||||||
|
header[8] = viewportWidth & 0xFF;
|
||||||
|
header[9] = (viewportWidth >> 8) & 0xFF;
|
||||||
|
header[10] = (viewportWidth >> 16) & 0xFF;
|
||||||
|
header[11] = (viewportWidth >> 24) & 0xFF;
|
||||||
|
|
||||||
|
// Lines per page
|
||||||
|
header[12] = linesPerPage & 0xFF;
|
||||||
|
header[13] = (linesPerPage >> 8) & 0xFF;
|
||||||
|
header[14] = (linesPerPage >> 16) & 0xFF;
|
||||||
|
header[15] = (linesPerPage >> 24) & 0xFF;
|
||||||
|
|
||||||
|
// Total pages
|
||||||
|
uint32_t numPages = pageOffsets.size();
|
||||||
|
header[16] = numPages & 0xFF;
|
||||||
|
header[17] = (numPages >> 8) & 0xFF;
|
||||||
|
header[18] = (numPages >> 16) & 0xFF;
|
||||||
|
header[19] = (numPages >> 24) & 0xFF;
|
||||||
|
|
||||||
|
f.write(header, 20);
|
||||||
|
|
||||||
|
// Write page offsets
|
||||||
|
for (size_t offset : pageOffsets) {
|
||||||
|
uint8_t offsetData[4];
|
||||||
|
offsetData[0] = offset & 0xFF;
|
||||||
|
offsetData[1] = (offset >> 8) & 0xFF;
|
||||||
|
offsetData[2] = (offset >> 16) & 0xFF;
|
||||||
|
offsetData[3] = (offset >> 24) & 0xFF;
|
||||||
|
f.write(offsetData, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages);
|
||||||
|
}
|
||||||
54
src/activities/reader/TxtReaderActivity.h
Normal file
54
src/activities/reader/TxtReaderActivity.h
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Txt.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "activities/ActivityWithSubactivity.h"
|
||||||
|
|
||||||
|
class TxtReaderActivity final : public ActivityWithSubactivity {
|
||||||
|
std::unique_ptr<Txt> txt;
|
||||||
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
SemaphoreHandle_t renderingMutex = nullptr;
|
||||||
|
int currentPage = 0;
|
||||||
|
int totalPages = 1;
|
||||||
|
int pagesUntilFullRefresh = 0;
|
||||||
|
bool updateRequired = false;
|
||||||
|
const std::function<void()> onGoBack;
|
||||||
|
const std::function<void()> onGoHome;
|
||||||
|
|
||||||
|
// Streaming text reader - stores file offsets for each page
|
||||||
|
std::vector<size_t> pageOffsets; // File offset for start of each page
|
||||||
|
std::vector<std::string> currentPageLines;
|
||||||
|
int linesPerPage = 0;
|
||||||
|
int viewportWidth = 0;
|
||||||
|
bool initialized = false;
|
||||||
|
|
||||||
|
static void taskTrampoline(void* param);
|
||||||
|
[[noreturn]] void displayTaskLoop();
|
||||||
|
void renderScreen();
|
||||||
|
void renderPage();
|
||||||
|
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
|
||||||
|
|
||||||
|
void initializeReader();
|
||||||
|
bool loadPageAtOffset(size_t offset, std::vector<std::string>& outLines, size_t& nextOffset);
|
||||||
|
void buildPageIndex();
|
||||||
|
bool loadPageIndexCache();
|
||||||
|
void savePageIndexCache() const;
|
||||||
|
void saveProgress() const;
|
||||||
|
void loadProgress();
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Txt> txt,
|
||||||
|
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
|
||||||
|
: ActivityWithSubactivity("TxtReader", renderer, mappedInput),
|
||||||
|
txt(std::move(txt)),
|
||||||
|
onGoBack(onGoBack),
|
||||||
|
onGoHome(onGoHome) {}
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user