Merge branch 'master' of github.com:daveallie/crosspoint-reader into feat/recent-books
This commit is contained in:
@@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance;
|
||||
namespace {
|
||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||
// Increment this when adding new persisted settings fields
|
||||
constexpr uint8_t SETTINGS_COUNT = 17;
|
||||
constexpr uint8_t SETTINGS_COUNT = 18;
|
||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||
} // namespace
|
||||
|
||||
@@ -47,6 +47,7 @@ bool CrossPointSettings::saveToFile() const {
|
||||
serialization::writeString(outputFile, std::string(opdsServerUrl));
|
||||
serialization::writePod(outputFile, textAntiAliasing);
|
||||
serialization::writePod(outputFile, hideBatteryPercentage);
|
||||
serialization::writePod(outputFile, longPressChapterSkip);
|
||||
outputFile.close();
|
||||
|
||||
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
|
||||
@@ -113,6 +114,8 @@ bool CrossPointSettings::loadFromFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, hideBatteryPercentage);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, longPressChapterSkip);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
} while (false);
|
||||
|
||||
inputFile.close();
|
||||
|
||||
@@ -90,6 +90,8 @@ class CrossPointSettings {
|
||||
char opdsServerUrl[128] = "";
|
||||
// Hide battery percentage
|
||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||
// Long-press chapter skip on side buttons
|
||||
uint8_t longPressChapterSkip = 1;
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#include <Serialization.h>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t STATE_FILE_VERSION = 1;
|
||||
constexpr uint8_t STATE_FILE_VERSION = 2;
|
||||
constexpr char STATE_FILE[] = "/.crosspoint/state.bin";
|
||||
} // namespace
|
||||
|
||||
@@ -19,6 +19,7 @@ bool CrossPointState::saveToFile() const {
|
||||
|
||||
serialization::writePod(outputFile, STATE_FILE_VERSION);
|
||||
serialization::writeString(outputFile, openEpubPath);
|
||||
serialization::writePod(outputFile, lastSleepImage);
|
||||
outputFile.close();
|
||||
return true;
|
||||
}
|
||||
@@ -31,13 +32,18 @@ bool CrossPointState::loadFromFile() {
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
if (version != STATE_FILE_VERSION) {
|
||||
if (version > STATE_FILE_VERSION) {
|
||||
Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version);
|
||||
inputFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
serialization::readString(inputFile, openEpubPath);
|
||||
if (version >= 2) {
|
||||
serialization::readPod(inputFile, lastSleepImage);
|
||||
} else {
|
||||
lastSleepImage = 0;
|
||||
}
|
||||
|
||||
inputFile.close();
|
||||
return true;
|
||||
|
||||
@@ -8,6 +8,7 @@ class CrossPointState {
|
||||
|
||||
public:
|
||||
std::string openEpubPath;
|
||||
uint8_t lastSleepImage;
|
||||
~CrossPointState() = default;
|
||||
|
||||
// Get singleton instance
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <Epub.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <Txt.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
@@ -80,7 +81,13 @@ void SleepActivity::renderCustomSleepScreen() const {
|
||||
const auto numFiles = files.size();
|
||||
if (numFiles > 0) {
|
||||
// Generate a random number between 1 and numFiles
|
||||
const auto randomFileIndex = random(numFiles);
|
||||
auto randomFileIndex = random(numFiles);
|
||||
// If we picked the same image as last time, reroll
|
||||
while (numFiles > 1 && randomFileIndex == APP_STATE.lastSleepImage) {
|
||||
randomFileIndex = random(numFiles);
|
||||
}
|
||||
APP_STATE.lastSleepImage = randomFileIndex;
|
||||
APP_STATE.saveToFile();
|
||||
const auto filename = "/sleep/" + files[randomFileIndex];
|
||||
FsFile file;
|
||||
if (SdMan.openFileForRead("SLP", filename, file)) {
|
||||
@@ -201,6 +208,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
||||
std::string coverBmpPath;
|
||||
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
|
||||
|
||||
// Check if the current book is XTC, TXT, or EPUB
|
||||
if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") ||
|
||||
StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) {
|
||||
// Handle XTC file
|
||||
@@ -216,6 +224,20 @@ void SleepActivity::renderCoverSleepScreen() const {
|
||||
}
|
||||
|
||||
coverBmpPath = lastXtc.getCoverBmpPath();
|
||||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
|
||||
// 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 if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
|
||||
// Handle EPUB file
|
||||
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#include "HomeActivity.h"
|
||||
|
||||
#include <Bitmap.h>
|
||||
#include <Epub.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
@@ -46,7 +48,7 @@ void HomeActivity::onEnter() {
|
||||
lastBookTitle = lastBookTitle.substr(lastSlash + 1);
|
||||
}
|
||||
|
||||
// If epub, try to load the metadata for title/author
|
||||
// If epub, try to load the metadata for title/author and cover
|
||||
if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) {
|
||||
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||
epub.load(false);
|
||||
@@ -56,10 +58,31 @@ void HomeActivity::onEnter() {
|
||||
if (!epub.getAuthor().empty()) {
|
||||
lastBookAuthor = std::string(epub.getAuthor());
|
||||
}
|
||||
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) {
|
||||
lastBookTitle.resize(lastBookTitle.length() - 5);
|
||||
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
|
||||
lastBookTitle.resize(lastBookTitle.length() - 4);
|
||||
// Try to generate thumbnail image for Continue Reading card
|
||||
if (epub.generateThumbBmp()) {
|
||||
coverBmpPath = epub.getThumbBmpPath();
|
||||
hasCoverImage = true;
|
||||
}
|
||||
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") ||
|
||||
StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
|
||||
// Handle XTC file
|
||||
Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint");
|
||||
if (xtc.load()) {
|
||||
if (!xtc.getTitle().empty()) {
|
||||
lastBookTitle = std::string(xtc.getTitle());
|
||||
}
|
||||
// Try to generate thumbnail image for Continue Reading card
|
||||
if (xtc.generateThumbBmp()) {
|
||||
coverBmpPath = xtc.getThumbBmpPath();
|
||||
hasCoverImage = true;
|
||||
}
|
||||
}
|
||||
// Remove extension from title if we don't have metadata
|
||||
if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) {
|
||||
lastBookTitle.resize(lastBookTitle.length() - 5);
|
||||
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
|
||||
lastBookTitle.resize(lastBookTitle.length() - 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +92,7 @@ void HomeActivity::onEnter() {
|
||||
updateRequired = true;
|
||||
|
||||
xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask",
|
||||
4096, // Stack size
|
||||
4096, // Stack size (increased for cover image rendering)
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&displayTaskHandle // Task handle
|
||||
@@ -87,6 +110,51 @@ void HomeActivity::onExit() {
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
|
||||
// Free the stored cover buffer if any
|
||||
freeCoverBuffer();
|
||||
}
|
||||
|
||||
bool HomeActivity::storeCoverBuffer() {
|
||||
uint8_t* frameBuffer = renderer.getFrameBuffer();
|
||||
if (!frameBuffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Free any existing buffer first
|
||||
freeCoverBuffer();
|
||||
|
||||
const size_t bufferSize = GfxRenderer::getBufferSize();
|
||||
coverBuffer = static_cast<uint8_t*>(malloc(bufferSize));
|
||||
if (!coverBuffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memcpy(coverBuffer, frameBuffer, bufferSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HomeActivity::restoreCoverBuffer() {
|
||||
if (!coverBuffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t* frameBuffer = renderer.getFrameBuffer();
|
||||
if (!frameBuffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const size_t bufferSize = GfxRenderer::getBufferSize();
|
||||
memcpy(frameBuffer, coverBuffer, bufferSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
void HomeActivity::freeCoverBuffer() {
|
||||
if (coverBuffer) {
|
||||
free(coverBuffer);
|
||||
coverBuffer = nullptr;
|
||||
}
|
||||
coverBufferStored = false;
|
||||
}
|
||||
|
||||
void HomeActivity::loop() {
|
||||
@@ -138,8 +206,12 @@ void HomeActivity::displayTaskLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
void HomeActivity::render() const {
|
||||
renderer.clearScreen();
|
||||
void HomeActivity::render() {
|
||||
// If we have a stored cover buffer, restore it instead of clearing
|
||||
const bool bufferRestored = coverBufferStored && restoreCoverBuffer();
|
||||
if (!bufferRestored) {
|
||||
renderer.clearScreen();
|
||||
}
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
@@ -154,34 +226,101 @@ void HomeActivity::render() const {
|
||||
constexpr int bookY = 30;
|
||||
const bool bookSelected = hasContinueReading && selectorIndex == 0;
|
||||
|
||||
// Bookmark dimensions (used in multiple places)
|
||||
const int bookmarkWidth = bookWidth / 8;
|
||||
const int bookmarkHeight = bookHeight / 5;
|
||||
const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10;
|
||||
const int bookmarkY = bookY + 5;
|
||||
|
||||
// Draw book card regardless, fill with message based on `hasContinueReading`
|
||||
{
|
||||
if (bookSelected) {
|
||||
renderer.fillRect(bookX, bookY, bookWidth, bookHeight);
|
||||
} else {
|
||||
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
|
||||
// Draw cover image as background if available (inside the box)
|
||||
// Only load from SD on first render, then use stored buffer
|
||||
if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) {
|
||||
// First time: load cover from SD and render
|
||||
FsFile file;
|
||||
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
// Calculate position to center image within the book card
|
||||
int coverX, coverY;
|
||||
|
||||
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
|
||||
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
|
||||
|
||||
if (imgRatio > boxRatio) {
|
||||
coverX = bookX;
|
||||
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
|
||||
} else {
|
||||
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
|
||||
coverY = bookY;
|
||||
}
|
||||
} else {
|
||||
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
|
||||
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
|
||||
}
|
||||
|
||||
// Draw the cover image centered within the book card
|
||||
renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight);
|
||||
|
||||
// Draw border around the card
|
||||
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
|
||||
|
||||
// No bookmark ribbon when cover is shown - it would just cover the art
|
||||
|
||||
// Store the buffer with cover image for fast navigation
|
||||
coverBufferStored = storeCoverBuffer();
|
||||
coverRendered = true;
|
||||
|
||||
// First render: if selected, draw selection indicators now
|
||||
if (bookSelected) {
|
||||
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
|
||||
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
} else if (!bufferRestored && !coverRendered) {
|
||||
// No cover image: draw border or fill, plus bookmark as visual flair
|
||||
if (bookSelected) {
|
||||
renderer.fillRect(bookX, bookY, bookWidth, bookHeight);
|
||||
} else {
|
||||
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
|
||||
}
|
||||
|
||||
// Draw bookmark ribbon when no cover image (visual decoration)
|
||||
if (hasContinueReading) {
|
||||
const int notchDepth = bookmarkHeight / 3;
|
||||
const int centerX = bookmarkX + bookmarkWidth / 2;
|
||||
|
||||
const int xPoints[5] = {
|
||||
bookmarkX, // top-left
|
||||
bookmarkX + bookmarkWidth, // top-right
|
||||
bookmarkX + bookmarkWidth, // bottom-right
|
||||
centerX, // center notch point
|
||||
bookmarkX // bottom-left
|
||||
};
|
||||
const int yPoints[5] = {
|
||||
bookmarkY, // top-left
|
||||
bookmarkY, // top-right
|
||||
bookmarkY + bookmarkHeight, // bottom-right
|
||||
bookmarkY + bookmarkHeight - notchDepth, // center notch point
|
||||
bookmarkY + bookmarkHeight // bottom-left
|
||||
};
|
||||
|
||||
// Draw bookmark ribbon (inverted if selected)
|
||||
renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected);
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmark icon in the top-right corner of the card
|
||||
const int bookmarkWidth = bookWidth / 8;
|
||||
const int bookmarkHeight = bookHeight / 5;
|
||||
const int bookmarkX = bookX + bookWidth - bookmarkWidth - 8;
|
||||
constexpr int bookmarkY = bookY + 1;
|
||||
|
||||
// Main bookmark body (solid)
|
||||
renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, !bookSelected);
|
||||
|
||||
// Carve out an inverted triangle notch at the bottom center to create angled points
|
||||
const int notchHeight = bookmarkHeight / 2; // depth of the notch
|
||||
for (int i = 0; i < notchHeight; ++i) {
|
||||
const int y = bookmarkY + bookmarkHeight - 1 - i;
|
||||
const int xStart = bookmarkX + i;
|
||||
const int width = bookmarkWidth - 2 * i;
|
||||
if (width <= 0) {
|
||||
break;
|
||||
}
|
||||
// Draw a horizontal strip in the opposite color to "cut" the notch
|
||||
renderer.fillRect(xStart, y, width, 1, bookSelected);
|
||||
// If buffer was restored, draw selection indicators if needed
|
||||
if (bufferRestored && bookSelected && coverRendered) {
|
||||
// Draw selection border (no bookmark inversion needed since cover has no bookmark)
|
||||
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
|
||||
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
|
||||
} else if (!coverRendered && !bufferRestored) {
|
||||
// Selection border already handled above in the no-cover case
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,18 +357,25 @@ void HomeActivity::render() const {
|
||||
lines.back().append("...");
|
||||
|
||||
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
|
||||
lines.back().resize(lines.back().size() - 5);
|
||||
// Remove "..." first, then remove one UTF-8 char, then add "..." back
|
||||
lines.back().resize(lines.back().size() - 3); // Remove "..."
|
||||
StringUtils::utf8RemoveLastChar(lines.back());
|
||||
lines.back().append("...");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
|
||||
while (wordWidth > maxLineWidth && i.size() > 5) {
|
||||
// Word itself is too long, trim it
|
||||
i.resize(i.size() - 5);
|
||||
i.append("...");
|
||||
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
|
||||
while (wordWidth > maxLineWidth && !i.empty()) {
|
||||
// Word itself is too long, trim it (UTF-8 safe)
|
||||
StringUtils::utf8RemoveLastChar(i);
|
||||
// Check if we have room for ellipsis
|
||||
std::string withEllipsis = i + "...";
|
||||
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
|
||||
if (wordWidth <= maxLineWidth) {
|
||||
i = withEllipsis;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str());
|
||||
@@ -261,24 +407,85 @@ void HomeActivity::render() const {
|
||||
// Vertically center the title block within the card
|
||||
int titleYStart = bookY + (bookHeight - totalTextHeight) / 2;
|
||||
|
||||
// If cover image was rendered, draw white box behind title and author
|
||||
if (coverRendered) {
|
||||
constexpr int boxPadding = 8;
|
||||
// Calculate the max text width for the box
|
||||
int maxTextWidth = 0;
|
||||
for (const auto& line : lines) {
|
||||
const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str());
|
||||
if (lineWidth > maxTextWidth) {
|
||||
maxTextWidth = lineWidth;
|
||||
}
|
||||
}
|
||||
if (!lastBookAuthor.empty()) {
|
||||
std::string trimmedAuthor = lastBookAuthor;
|
||||
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
||||
StringUtils::utf8RemoveLastChar(trimmedAuthor);
|
||||
}
|
||||
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
|
||||
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
|
||||
trimmedAuthor.append("...");
|
||||
}
|
||||
const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str());
|
||||
if (authorWidth > maxTextWidth) {
|
||||
maxTextWidth = authorWidth;
|
||||
}
|
||||
}
|
||||
|
||||
const int boxWidth = maxTextWidth + boxPadding * 2;
|
||||
const int boxHeight = totalTextHeight + boxPadding * 2;
|
||||
const int boxX = (pageWidth - boxWidth) / 2;
|
||||
const int boxY = titleYStart - boxPadding;
|
||||
|
||||
// Draw white filled box
|
||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||
// Draw black border around the box
|
||||
renderer.drawRect(boxX, boxY, boxWidth, boxHeight, true);
|
||||
}
|
||||
|
||||
for (const auto& line : lines) {
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected);
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected || coverRendered);
|
||||
titleYStart += renderer.getLineHeight(UI_12_FONT_ID);
|
||||
}
|
||||
|
||||
if (!lastBookAuthor.empty()) {
|
||||
titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2;
|
||||
std::string trimmedAuthor = lastBookAuthor;
|
||||
// Trim author if too long
|
||||
// Trim author if too long (UTF-8 safe)
|
||||
bool wasTrimmed = false;
|
||||
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
||||
trimmedAuthor.resize(trimmedAuthor.size() - 5);
|
||||
StringUtils::utf8RemoveLastChar(trimmedAuthor);
|
||||
wasTrimmed = true;
|
||||
}
|
||||
if (wasTrimmed && !trimmedAuthor.empty()) {
|
||||
// Make room for ellipsis
|
||||
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
|
||||
!trimmedAuthor.empty()) {
|
||||
StringUtils::utf8RemoveLastChar(trimmedAuthor);
|
||||
}
|
||||
trimmedAuthor.append("...");
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected || coverRendered);
|
||||
}
|
||||
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2,
|
||||
"Continue Reading", !bookSelected);
|
||||
// "Continue Reading" label at the bottom
|
||||
const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
|
||||
if (coverRendered) {
|
||||
// Draw white box behind "Continue Reading" text
|
||||
const char* continueText = "Continue Reading";
|
||||
const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText);
|
||||
constexpr int continuePadding = 6;
|
||||
const int continueBoxWidth = continueTextWidth + continuePadding * 2;
|
||||
const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding;
|
||||
const int continueBoxX = (pageWidth - continueBoxWidth) / 2;
|
||||
const int continueBoxY = continueY - continuePadding / 2;
|
||||
renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, false);
|
||||
renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, true);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, true);
|
||||
} else {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected);
|
||||
}
|
||||
} else {
|
||||
// No book to continue reading
|
||||
const int y =
|
||||
|
||||
@@ -14,8 +14,13 @@ class HomeActivity final : public Activity {
|
||||
bool updateRequired = false;
|
||||
bool hasContinueReading = false;
|
||||
bool hasOpdsUrl = false;
|
||||
bool hasCoverImage = false;
|
||||
bool coverRendered = false; // Track if cover has been rendered once
|
||||
bool coverBufferStored = false; // Track if cover buffer is stored
|
||||
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
|
||||
std::string lastBookTitle;
|
||||
std::string lastBookAuthor;
|
||||
std::string coverBmpPath;
|
||||
const std::function<void()> onContinueReading;
|
||||
const std::function<void()> onMyLibraryOpen;
|
||||
const std::function<void()> onSettingsOpen;
|
||||
@@ -24,8 +29,11 @@ class HomeActivity final : public Activity {
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void render() const;
|
||||
void render();
|
||||
int getMenuItemCount() const;
|
||||
bool storeCoverBuffer(); // Store frame buffer for cover image
|
||||
bool restoreCoverBuffer(); // Restore frame buffer from stored cover
|
||||
void freeCoverBuffer(); // Free the stored cover buffer
|
||||
|
||||
public:
|
||||
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <ESPmDNS.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
#include <qrcode.h>
|
||||
|
||||
#include <cstddef>
|
||||
@@ -83,9 +84,8 @@ void CrossPointWebServerActivity::onExit() {
|
||||
dnsServer = nullptr;
|
||||
}
|
||||
|
||||
// CRITICAL: Wait for LWIP stack to flush any pending packets
|
||||
Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis());
|
||||
delay(500);
|
||||
// Brief wait for LWIP stack to flush pending packets
|
||||
delay(50);
|
||||
|
||||
// Disconnect WiFi gracefully
|
||||
if (isApMode) {
|
||||
@@ -95,11 +95,11 @@ void CrossPointWebServerActivity::onExit() {
|
||||
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis());
|
||||
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
|
||||
}
|
||||
delay(100); // Allow disconnect frame to be sent
|
||||
delay(30); // Allow disconnect frame to be sent
|
||||
|
||||
Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis());
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(100); // Allow WiFi hardware to fully power down
|
||||
delay(30); // Allow WiFi hardware to power down
|
||||
|
||||
Serial.printf("[%lu] [WEBACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
@@ -283,8 +283,28 @@ void CrossPointWebServerActivity::loop() {
|
||||
dnsServer->processNextRequest();
|
||||
}
|
||||
|
||||
// Handle web server requests - call handleClient multiple times per loop
|
||||
// to improve responsiveness and upload throughput
|
||||
// STA mode: Monitor WiFi connection health
|
||||
if (!isApMode && webServer && webServer->isRunning()) {
|
||||
static unsigned long lastWifiCheck = 0;
|
||||
if (millis() - lastWifiCheck > 2000) { // Check every 2 seconds
|
||||
lastWifiCheck = millis();
|
||||
const wl_status_t wifiStatus = WiFi.status();
|
||||
if (wifiStatus != WL_CONNECTED) {
|
||||
Serial.printf("[%lu] [WEBACT] WiFi disconnected! Status: %d\n", millis(), wifiStatus);
|
||||
// Show error and exit gracefully
|
||||
state = WebServerActivityState::SHUTTING_DOWN;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
// Log weak signal warnings
|
||||
const int rssi = WiFi.RSSI();
|
||||
if (rssi < -75) {
|
||||
Serial.printf("[%lu] [WEBACT] Warning: Weak WiFi signal: %d dBm\n", millis(), rssi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle web server requests - maximize throughput with watchdog safety
|
||||
if (webServer && webServer->isRunning()) {
|
||||
const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
|
||||
|
||||
@@ -294,17 +314,32 @@ void CrossPointWebServerActivity::loop() {
|
||||
timeSinceLastHandleClient);
|
||||
}
|
||||
|
||||
// Call handleClient multiple times to process pending requests faster
|
||||
// This is critical for upload performance - HTTP file uploads send data
|
||||
// in chunks and each handleClient() call processes incoming data
|
||||
constexpr int HANDLE_CLIENT_ITERATIONS = 10;
|
||||
for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) {
|
||||
// Reset watchdog BEFORE processing - HTTP header parsing can be slow
|
||||
esp_task_wdt_reset();
|
||||
|
||||
// Process HTTP requests in tight loop for maximum throughput
|
||||
// More iterations = more data processed per main loop cycle
|
||||
constexpr int MAX_ITERATIONS = 500;
|
||||
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
|
||||
webServer->handleClient();
|
||||
// Reset watchdog every 32 iterations
|
||||
if ((i & 0x1F) == 0x1F) {
|
||||
esp_task_wdt_reset();
|
||||
}
|
||||
// Yield and check for exit button every 64 iterations
|
||||
if ((i & 0x3F) == 0x3F) {
|
||||
yield();
|
||||
// Check for exit button inside loop for responsiveness
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastHandleClientTime = millis();
|
||||
}
|
||||
|
||||
// Handle exit on Back button
|
||||
// Handle exit on Back button (also check outside loop)
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onGoBack();
|
||||
return;
|
||||
|
||||
@@ -170,7 +170,7 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool skipChapter = mappedInput.getHeldTime() > skipChapterMs;
|
||||
const bool skipChapter = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipChapterMs;
|
||||
|
||||
if (skipChapter) {
|
||||
// We don't want to delete the section mid-render, so grab the semaphore
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "Epub.h"
|
||||
#include "EpubReaderActivity.h"
|
||||
#include "Txt.h"
|
||||
#include "TxtReaderActivity.h"
|
||||
#include "Xtc.h"
|
||||
#include "XtcReaderActivity.h"
|
||||
#include "activities/util/FullScreenMessageActivity.h"
|
||||
@@ -19,6 +21,12 @@ bool ReaderActivity::isXtcFile(const std::string& path) {
|
||||
return StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtch");
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!SdMan.exists(path.c_str())) {
|
||||
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
||||
@@ -49,6 +57,21 @@ std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
|
||||
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) {
|
||||
currentBookPath = path; // Track current book path
|
||||
exitActivity();
|
||||
@@ -66,6 +89,18 @@ void ReaderActivity::onSelectBookFile(const std::string& path) {
|
||||
delay(2000);
|
||||
goToLibrary();
|
||||
}
|
||||
} 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 {
|
||||
// Load EPUB file
|
||||
auto epub = loadEpub(path);
|
||||
@@ -103,6 +138,15 @@ void ReaderActivity::onGoToXtcReader(std::unique_ptr<Xtc> xtc) {
|
||||
renderer, mappedInput, std::move(xtc), [this, xtcPath] { goToLibrary(xtcPath); }, [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() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
@@ -120,6 +164,13 @@ void ReaderActivity::onEnter() {
|
||||
return;
|
||||
}
|
||||
onGoToXtcReader(std::move(xtc));
|
||||
} else if (isTxtFile(initialBookPath)) {
|
||||
auto txt = loadTxt(initialBookPath);
|
||||
if (!txt) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
onGoToTxtReader(std::move(txt));
|
||||
} else {
|
||||
auto epub = loadEpub(initialBookPath);
|
||||
if (!epub) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
class Epub;
|
||||
class Xtc;
|
||||
class Txt;
|
||||
|
||||
class ReaderActivity final : public ActivityWithSubactivity {
|
||||
std::string initialBookPath;
|
||||
@@ -13,13 +14,16 @@ class ReaderActivity final : public ActivityWithSubactivity {
|
||||
const std::function<void(const std::string&)> onGoToLibrary;
|
||||
static std::unique_ptr<Epub> loadEpub(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 isTxtFile(const std::string& path);
|
||||
|
||||
static std::string extractFolderPath(const std::string& filePath);
|
||||
void onSelectBookFile(const std::string& path);
|
||||
void goToLibrary(const std::string& fromBookPath = "");
|
||||
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
||||
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
||||
void onGoToTxtReader(std::unique_ptr<Txt> txt);
|
||||
|
||||
public:
|
||||
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
|
||||
|
||||
700
src/activities/reader/TxtReaderActivity.cpp
Normal file
700
src/activities/reader/TxtReaderActivity.cpp
Normal file
@@ -0,0 +1,700 @@
|
||||
#include "TxtReaderActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <Serialization.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 statusBarMargin = 25;
|
||||
constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading
|
||||
|
||||
// Cache file magic and version
|
||||
constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI"
|
||||
constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes
|
||||
} // 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) ||
|
||||
(SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Power)) ||
|
||||
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;
|
||||
}
|
||||
|
||||
// Store current settings for cache validation
|
||||
cachedFontId = SETTINGS.getReaderFontId();
|
||||
cachedScreenMargin = SETTINGS.screenMargin;
|
||||
cachedParagraphAlignment = SETTINGS.paragraphAlignment;
|
||||
|
||||
// Calculate viewport dimensions
|
||||
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||
&orientedMarginLeft);
|
||||
orientedMarginTop += cachedScreenMargin;
|
||||
orientedMarginLeft += cachedScreenMargin;
|
||||
orientedMarginRight += cachedScreenMargin;
|
||||
orientedMarginBottom += statusBarMargin;
|
||||
|
||||
viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
|
||||
const int viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
|
||||
const int lineHeight = renderer.getLineHeight(cachedFontId);
|
||||
|
||||
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 10% (matching EpubReaderActivity logic)
|
||||
int progressPercent = (offset * 100) / fileSize;
|
||||
if (lastProgressPercent / 10 != progressPercent / 10) {
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Calculate the actual length of line content in the buffer (excluding newline)
|
||||
size_t lineContentLen = lineEnd - pos;
|
||||
|
||||
// Check for carriage return
|
||||
bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r');
|
||||
size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen;
|
||||
|
||||
// Extract line content for display (without CR/LF)
|
||||
std::string line(reinterpret_cast<char*>(buffer + pos), displayLen);
|
||||
|
||||
// Track position within this source line (in bytes from pos)
|
||||
size_t lineBytePos = 0;
|
||||
|
||||
// Word wrap if needed
|
||||
while (!line.empty() && static_cast<int>(outLines.size()) < linesPerPage) {
|
||||
int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str());
|
||||
|
||||
if (lineWidth <= viewportWidth) {
|
||||
outLines.push_back(line);
|
||||
lineBytePos = displayLen; // Consumed entire display content
|
||||
line.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
// Find break point
|
||||
size_t breakPos = line.length();
|
||||
while (breakPos > 0 && renderer.getTextWidth(cachedFontId, 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
|
||||
size_t skipChars = breakPos;
|
||||
if (breakPos < line.length() && line[breakPos] == ' ') {
|
||||
skipChars++;
|
||||
}
|
||||
lineBytePos += skipChars;
|
||||
line = line.substr(skipChars);
|
||||
}
|
||||
|
||||
// Determine how much of the source buffer we consumed
|
||||
if (line.empty()) {
|
||||
// Fully consumed this source line, move past the newline
|
||||
pos = lineEnd + 1;
|
||||
} else {
|
||||
// Partially consumed - page is full mid-line
|
||||
// Move pos to where we stopped in the line (NOT past the line)
|
||||
pos = pos + lineBytePos;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we make progress even if calculations go wrong
|
||||
if (pos == 0 && !outLines.empty()) {
|
||||
// Fallback: at minimum, consume something to avoid infinite loop
|
||||
pos = 1;
|
||||
}
|
||||
|
||||
nextOffset = offset + pos;
|
||||
|
||||
// Make sure we don't go past the file
|
||||
if (nextOffset > fileSize) {
|
||||
nextOffset = fileSize;
|
||||
}
|
||||
|
||||
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 += cachedScreenMargin;
|
||||
orientedMarginLeft += cachedScreenMargin;
|
||||
orientedMarginRight += cachedScreenMargin;
|
||||
orientedMarginBottom += statusBarMargin;
|
||||
|
||||
const int lineHeight = renderer.getLineHeight(cachedFontId);
|
||||
const int contentWidth = viewportWidth;
|
||||
|
||||
// Render text lines with alignment
|
||||
auto renderLines = [&]() {
|
||||
int y = orientedMarginTop;
|
||||
for (const auto& line : currentPageLines) {
|
||||
if (!line.empty()) {
|
||||
int x = orientedMarginLeft;
|
||||
|
||||
// Apply text alignment
|
||||
switch (cachedParagraphAlignment) {
|
||||
case CrossPointSettings::LEFT_ALIGN:
|
||||
default:
|
||||
// x already set to left margin
|
||||
break;
|
||||
case CrossPointSettings::CENTER_ALIGN: {
|
||||
int textWidth = renderer.getTextWidth(cachedFontId, line.c_str());
|
||||
x = orientedMarginLeft + (contentWidth - textWidth) / 2;
|
||||
break;
|
||||
}
|
||||
case CrossPointSettings::RIGHT_ALIGN: {
|
||||
int textWidth = renderer.getTextWidth(cachedFontId, line.c_str());
|
||||
x = orientedMarginLeft + contentWidth - textWidth;
|
||||
break;
|
||||
}
|
||||
case CrossPointSettings::JUSTIFIED:
|
||||
// For plain text, justified is treated as left-aligned
|
||||
// (true justification would require word spacing adjustments)
|
||||
break;
|
||||
}
|
||||
|
||||
renderer.drawText(cachedFontId, x, y, line.c_str());
|
||||
}
|
||||
y += lineHeight;
|
||||
}
|
||||
};
|
||||
|
||||
// First pass: BW rendering
|
||||
renderLines();
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
|
||||
if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
} else {
|
||||
renderer.displayBuffer();
|
||||
pagesUntilFullRefresh--;
|
||||
}
|
||||
|
||||
// Grayscale rendering pass (for anti-aliased fonts)
|
||||
if (SETTINGS.textAntiAliasing) {
|
||||
// Save BW buffer for restoration after grayscale pass
|
||||
renderer.storeBwBuffer();
|
||||
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
renderLines();
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
renderLines();
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
|
||||
renderer.displayGrayBuffer();
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
|
||||
// Restore BW buffer
|
||||
renderer.restoreBwBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
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 (using serialization module):
|
||||
// - uint32_t: magic "TXTI"
|
||||
// - uint8_t: cache version
|
||||
// - uint32_t: file size (to validate cache)
|
||||
// - int32_t: viewport width
|
||||
// - int32_t: lines per page
|
||||
// - int32_t: font ID (to invalidate cache on font change)
|
||||
// - int32_t: screen margin (to invalidate cache on margin change)
|
||||
// - uint8_t: paragraph alignment (to invalidate cache on alignment change)
|
||||
// - uint32_t: total pages count
|
||||
// - N * uint32_t: page offsets
|
||||
|
||||
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 using serialization module
|
||||
uint32_t magic;
|
||||
serialization::readPod(f, magic);
|
||||
if (magic != CACHE_MAGIC) {
|
||||
Serial.printf("[%lu] [TRS] Cache magic mismatch, rebuilding\n", millis());
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(f, version);
|
||||
if (version != CACHE_VERSION) {
|
||||
Serial.printf("[%lu] [TRS] Cache version mismatch (%d != %d), rebuilding\n", millis(), version, CACHE_VERSION);
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t fileSize;
|
||||
serialization::readPod(f, fileSize);
|
||||
if (fileSize != txt->getFileSize()) {
|
||||
Serial.printf("[%lu] [TRS] Cache file size mismatch, rebuilding\n", millis());
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t cachedWidth;
|
||||
serialization::readPod(f, cachedWidth);
|
||||
if (cachedWidth != viewportWidth) {
|
||||
Serial.printf("[%lu] [TRS] Cache viewport width mismatch, rebuilding\n", millis());
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t cachedLines;
|
||||
serialization::readPod(f, cachedLines);
|
||||
if (cachedLines != linesPerPage) {
|
||||
Serial.printf("[%lu] [TRS] Cache lines per page mismatch, rebuilding\n", millis());
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t fontId;
|
||||
serialization::readPod(f, fontId);
|
||||
if (fontId != cachedFontId) {
|
||||
Serial.printf("[%lu] [TRS] Cache font ID mismatch (%d != %d), rebuilding\n", millis(), fontId, cachedFontId);
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t margin;
|
||||
serialization::readPod(f, margin);
|
||||
if (margin != cachedScreenMargin) {
|
||||
Serial.printf("[%lu] [TRS] Cache screen margin mismatch, rebuilding\n", millis());
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t alignment;
|
||||
serialization::readPod(f, alignment);
|
||||
if (alignment != cachedParagraphAlignment) {
|
||||
Serial.printf("[%lu] [TRS] Cache paragraph alignment mismatch, rebuilding\n", millis());
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t numPages;
|
||||
serialization::readPod(f, numPages);
|
||||
|
||||
// Read page offsets
|
||||
pageOffsets.clear();
|
||||
pageOffsets.reserve(numPages);
|
||||
|
||||
for (uint32_t i = 0; i < numPages; i++) {
|
||||
uint32_t offset;
|
||||
serialization::readPod(f, offset);
|
||||
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 using serialization module
|
||||
serialization::writePod(f, CACHE_MAGIC);
|
||||
serialization::writePod(f, CACHE_VERSION);
|
||||
serialization::writePod(f, static_cast<uint32_t>(txt->getFileSize()));
|
||||
serialization::writePod(f, static_cast<int32_t>(viewportWidth));
|
||||
serialization::writePod(f, static_cast<int32_t>(linesPerPage));
|
||||
serialization::writePod(f, static_cast<int32_t>(cachedFontId));
|
||||
serialization::writePod(f, static_cast<int32_t>(cachedScreenMargin));
|
||||
serialization::writePod(f, cachedParagraphAlignment);
|
||||
serialization::writePod(f, static_cast<uint32_t>(pageOffsets.size()));
|
||||
|
||||
// Write page offsets
|
||||
for (size_t offset : pageOffsets) {
|
||||
serialization::writePod(f, static_cast<uint32_t>(offset));
|
||||
}
|
||||
|
||||
f.close();
|
||||
Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages);
|
||||
}
|
||||
60
src/activities/reader/TxtReaderActivity.h
Normal file
60
src/activities/reader/TxtReaderActivity.h
Normal file
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <Txt.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#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;
|
||||
|
||||
// Cached settings for cache validation (different fonts/margins require re-indexing)
|
||||
int cachedFontId = 0;
|
||||
int cachedScreenMargin = 0;
|
||||
uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN;
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
// Define the static settings list
|
||||
namespace {
|
||||
constexpr int settingsCount = 19;
|
||||
constexpr int settingsCount = 20;
|
||||
const SettingInfo settingsList[settingsCount] = {
|
||||
// Should match with SLEEP_SCREEN_MODE
|
||||
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
|
||||
@@ -29,6 +29,7 @@ const SettingInfo settingsList[settingsCount] = {
|
||||
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}),
|
||||
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
|
||||
{"Prev, Next", "Next, Prev"}),
|
||||
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
|
||||
SettingInfo::Enum("Reader Font Family", &CrossPointSettings::fontFamily,
|
||||
{"Bookerly", "Noto Sans", "Open Dyslexic"}),
|
||||
SettingInfo::Enum("Reader Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
|
||||
|
||||
20
src/main.cpp
20
src/main.cpp
@@ -45,18 +45,19 @@ GfxRenderer renderer(einkDisplay);
|
||||
Activity* currentActivity;
|
||||
|
||||
// Fonts
|
||||
EpdFont bookerly12RegularFont(&bookerly_12_regular);
|
||||
EpdFont bookerly12BoldFont(&bookerly_12_bold);
|
||||
EpdFont bookerly12ItalicFont(&bookerly_12_italic);
|
||||
EpdFont bookerly12BoldItalicFont(&bookerly_12_bolditalic);
|
||||
EpdFontFamily bookerly12FontFamily(&bookerly12RegularFont, &bookerly12BoldFont, &bookerly12ItalicFont,
|
||||
&bookerly12BoldItalicFont);
|
||||
EpdFont bookerly14RegularFont(&bookerly_14_regular);
|
||||
EpdFont bookerly14BoldFont(&bookerly_14_bold);
|
||||
EpdFont bookerly14ItalicFont(&bookerly_14_italic);
|
||||
EpdFont bookerly14BoldItalicFont(&bookerly_14_bolditalic);
|
||||
EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont, &bookerly14ItalicFont,
|
||||
&bookerly14BoldItalicFont);
|
||||
#ifndef OMIT_FONTS
|
||||
EpdFont bookerly12RegularFont(&bookerly_12_regular);
|
||||
EpdFont bookerly12BoldFont(&bookerly_12_bold);
|
||||
EpdFont bookerly12ItalicFont(&bookerly_12_italic);
|
||||
EpdFont bookerly12BoldItalicFont(&bookerly_12_bolditalic);
|
||||
EpdFontFamily bookerly12FontFamily(&bookerly12RegularFont, &bookerly12BoldFont, &bookerly12ItalicFont,
|
||||
&bookerly12BoldItalicFont);
|
||||
EpdFont bookerly16RegularFont(&bookerly_16_regular);
|
||||
EpdFont bookerly16BoldFont(&bookerly_16_bold);
|
||||
EpdFont bookerly16ItalicFont(&bookerly_16_italic);
|
||||
@@ -119,6 +120,7 @@ EpdFont opendyslexic14ItalicFont(&opendyslexic_14_italic);
|
||||
EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic);
|
||||
EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont,
|
||||
&opendyslexic14BoldItalicFont);
|
||||
#endif // OMIT_FONTS
|
||||
|
||||
EpdFont smallFont(¬osans_8_regular);
|
||||
EpdFontFamily smallFontFamily(&smallFont);
|
||||
@@ -252,10 +254,12 @@ void onGoHome() {
|
||||
void setupDisplayAndFonts() {
|
||||
einkDisplay.begin();
|
||||
Serial.printf("[%lu] [ ] Display initialized\n", millis());
|
||||
renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily);
|
||||
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
|
||||
#ifndef OMIT_FONTS
|
||||
renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily);
|
||||
renderer.insertFont(BOOKERLY_16_FONT_ID, bookerly16FontFamily);
|
||||
renderer.insertFont(BOOKERLY_18_FONT_ID, bookerly18FontFamily);
|
||||
|
||||
renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily);
|
||||
renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily);
|
||||
renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily);
|
||||
@@ -264,6 +268,7 @@ void setupDisplayAndFonts() {
|
||||
renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, opendyslexic10FontFamily);
|
||||
renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, opendyslexic12FontFamily);
|
||||
renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, opendyslexic14FontFamily);
|
||||
#endif // OMIT_FONTS
|
||||
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
|
||||
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
|
||||
renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
|
||||
@@ -318,6 +323,7 @@ void setup() {
|
||||
// Clear app state to avoid getting into a boot loop if the epub doesn't load
|
||||
const auto path = APP_STATE.openEpubPath;
|
||||
APP_STATE.openEpubPath = "";
|
||||
APP_STATE.lastSleepImage = 0;
|
||||
APP_STATE.saveToFile();
|
||||
onGoToReader(path);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <FsHelpers.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -15,6 +16,18 @@ namespace {
|
||||
// Note: Items starting with "." are automatically hidden
|
||||
const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
|
||||
constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
|
||||
|
||||
// Static pointer for WebSocket callback (WebSocketsServer requires C-style callback)
|
||||
CrossPointWebServer* wsInstance = nullptr;
|
||||
|
||||
// WebSocket upload state
|
||||
FsFile wsUploadFile;
|
||||
String wsUploadFileName;
|
||||
String wsUploadPath;
|
||||
size_t wsUploadSize = 0;
|
||||
size_t wsUploadReceived = 0;
|
||||
unsigned long wsUploadStartTime = 0;
|
||||
bool wsUploadInProgress = false;
|
||||
} // namespace
|
||||
|
||||
// File listing page template - now using generated headers:
|
||||
@@ -86,12 +99,22 @@ void CrossPointWebServer::begin() {
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
server->begin();
|
||||
|
||||
// Start WebSocket server for fast binary uploads
|
||||
Serial.printf("[%lu] [WEB] Starting WebSocket server on port %d...\n", millis(), wsPort);
|
||||
wsServer.reset(new WebSocketsServer(wsPort));
|
||||
wsInstance = const_cast<CrossPointWebServer*>(this);
|
||||
wsServer->begin();
|
||||
wsServer->onEvent(wsEventCallback);
|
||||
Serial.printf("[%lu] [WEB] WebSocket server started\n", millis());
|
||||
|
||||
running = true;
|
||||
|
||||
Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port);
|
||||
// Show the correct IP based on network mode
|
||||
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
|
||||
Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str());
|
||||
Serial.printf("[%lu] [WEB] WebSocket at ws://%s:%d/\n", millis(), ipAddr.c_str(), wsPort);
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
}
|
||||
|
||||
@@ -107,16 +130,29 @@ void CrossPointWebServer::stop() {
|
||||
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
// Add delay to allow any in-flight handleClient() calls to complete
|
||||
delay(100);
|
||||
Serial.printf("[%lu] [WEB] Waited 100ms for handleClient to finish\n", millis());
|
||||
// Close any in-progress WebSocket upload
|
||||
if (wsUploadInProgress && wsUploadFile) {
|
||||
wsUploadFile.close();
|
||||
wsUploadInProgress = false;
|
||||
}
|
||||
|
||||
// Stop WebSocket server
|
||||
if (wsServer) {
|
||||
Serial.printf("[%lu] [WEB] Stopping WebSocket server...\n", millis());
|
||||
wsServer->close();
|
||||
wsServer.reset();
|
||||
wsInstance = nullptr;
|
||||
Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis());
|
||||
}
|
||||
|
||||
// Brief delay to allow any in-flight handleClient() calls to complete
|
||||
delay(20);
|
||||
|
||||
server->stop();
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
// Add another delay before deletion to ensure server->stop() completes
|
||||
delay(50);
|
||||
Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis());
|
||||
// Brief delay before deletion
|
||||
delay(10);
|
||||
|
||||
server.reset();
|
||||
Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis());
|
||||
@@ -148,6 +184,11 @@ void CrossPointWebServer::handleClient() const {
|
||||
}
|
||||
|
||||
server->handleClient();
|
||||
|
||||
// Handle WebSocket events
|
||||
if (wsServer) {
|
||||
wsServer->loop();
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleRoot() const {
|
||||
@@ -229,7 +270,8 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
|
||||
}
|
||||
|
||||
file.close();
|
||||
yield(); // Yield to allow WiFi and other tasks to process during long scans
|
||||
yield(); // Yield to allow WiFi and other tasks to process during long scans
|
||||
esp_task_wdt_reset(); // Reset watchdog to prevent timeout on large directories
|
||||
file = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
@@ -301,11 +343,44 @@ static size_t uploadSize = 0;
|
||||
static bool uploadSuccess = false;
|
||||
static String uploadError = "";
|
||||
|
||||
// Upload write buffer - batches small writes into larger SD card operations
|
||||
// 4KB is a good balance: large enough to reduce syscall overhead, small enough
|
||||
// to keep individual write times short and avoid watchdog issues
|
||||
constexpr size_t UPLOAD_BUFFER_SIZE = 4096; // 4KB buffer
|
||||
static uint8_t uploadBuffer[UPLOAD_BUFFER_SIZE];
|
||||
static size_t uploadBufferPos = 0;
|
||||
|
||||
// Diagnostic counters for upload performance analysis
|
||||
static unsigned long uploadStartTime = 0;
|
||||
static unsigned long totalWriteTime = 0;
|
||||
static size_t writeCount = 0;
|
||||
|
||||
static bool flushUploadBuffer() {
|
||||
if (uploadBufferPos > 0 && uploadFile) {
|
||||
esp_task_wdt_reset(); // Reset watchdog before potentially slow SD write
|
||||
const unsigned long writeStart = millis();
|
||||
const size_t written = uploadFile.write(uploadBuffer, uploadBufferPos);
|
||||
totalWriteTime += millis() - writeStart;
|
||||
writeCount++;
|
||||
esp_task_wdt_reset(); // Reset watchdog after SD write
|
||||
|
||||
if (written != uploadBufferPos) {
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] Buffer flush failed: expected %d, wrote %d\n", millis(), uploadBufferPos,
|
||||
written);
|
||||
uploadBufferPos = 0;
|
||||
return false;
|
||||
}
|
||||
uploadBufferPos = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleUpload() const {
|
||||
static unsigned long lastWriteTime = 0;
|
||||
static unsigned long uploadStartTime = 0;
|
||||
static size_t lastLoggedSize = 0;
|
||||
|
||||
// Reset watchdog at start of every upload callback - HTTP parsing can be slow
|
||||
esp_task_wdt_reset();
|
||||
|
||||
// Safety check: ensure server is still valid
|
||||
if (!running || !server) {
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] ERROR: handleUpload called but server not running!\n", millis());
|
||||
@@ -315,13 +390,18 @@ void CrossPointWebServer::handleUpload() const {
|
||||
const HTTPUpload& upload = server->upload();
|
||||
|
||||
if (upload.status == UPLOAD_FILE_START) {
|
||||
// Reset watchdog - this is the critical 1% crash point
|
||||
esp_task_wdt_reset();
|
||||
|
||||
uploadFileName = upload.filename;
|
||||
uploadSize = 0;
|
||||
uploadSuccess = false;
|
||||
uploadError = "";
|
||||
uploadStartTime = millis();
|
||||
lastWriteTime = millis();
|
||||
lastLoggedSize = 0;
|
||||
uploadBufferPos = 0;
|
||||
totalWriteTime = 0;
|
||||
writeCount = 0;
|
||||
|
||||
// Get upload path from query parameter (defaults to root if not specified)
|
||||
// Note: We use query parameter instead of form data because multipart form
|
||||
@@ -348,60 +428,82 @@ void CrossPointWebServer::handleUpload() const {
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += uploadFileName;
|
||||
|
||||
// Check if file already exists
|
||||
// Check if file already exists - SD operations can be slow
|
||||
esp_task_wdt_reset();
|
||||
if (SdMan.exists(filePath.c_str())) {
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str());
|
||||
esp_task_wdt_reset();
|
||||
SdMan.remove(filePath.c_str());
|
||||
}
|
||||
|
||||
// Open file for writing
|
||||
// Open file for writing - this can be slow due to FAT cluster allocation
|
||||
esp_task_wdt_reset();
|
||||
if (!SdMan.openFileForWrite("WEB", filePath, uploadFile)) {
|
||||
uploadError = "Failed to create file on SD card";
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str());
|
||||
return;
|
||||
}
|
||||
esp_task_wdt_reset();
|
||||
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str());
|
||||
} else if (upload.status == UPLOAD_FILE_WRITE) {
|
||||
if (uploadFile && uploadError.isEmpty()) {
|
||||
const unsigned long writeStartTime = millis();
|
||||
const size_t written = uploadFile.write(upload.buf, upload.currentSize);
|
||||
const unsigned long writeEndTime = millis();
|
||||
const unsigned long writeDuration = writeEndTime - writeStartTime;
|
||||
// Buffer incoming data and flush when buffer is full
|
||||
// This reduces SD card write operations and improves throughput
|
||||
const uint8_t* data = upload.buf;
|
||||
size_t remaining = upload.currentSize;
|
||||
|
||||
if (written != upload.currentSize) {
|
||||
uploadError = "Failed to write to SD card - disk may be full";
|
||||
uploadFile.close();
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] WRITE ERROR - expected %d, wrote %d\n", millis(), upload.currentSize,
|
||||
written);
|
||||
} else {
|
||||
uploadSize += written;
|
||||
while (remaining > 0) {
|
||||
const size_t space = UPLOAD_BUFFER_SIZE - uploadBufferPos;
|
||||
const size_t toCopy = (remaining < space) ? remaining : space;
|
||||
|
||||
// Log progress every 50KB or if write took >100ms
|
||||
if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) {
|
||||
const unsigned long timeSinceStart = millis() - uploadStartTime;
|
||||
const unsigned long timeSinceLastWrite = millis() - lastWriteTime;
|
||||
const float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0);
|
||||
memcpy(uploadBuffer + uploadBufferPos, data, toCopy);
|
||||
uploadBufferPos += toCopy;
|
||||
data += toCopy;
|
||||
remaining -= toCopy;
|
||||
|
||||
Serial.printf(
|
||||
"[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu "
|
||||
"ms\n",
|
||||
millis(), uploadSize, uploadSize / 1024.0, kbps, writeDuration, timeSinceLastWrite);
|
||||
lastLoggedSize = uploadSize;
|
||||
// Flush buffer when full
|
||||
if (uploadBufferPos >= UPLOAD_BUFFER_SIZE) {
|
||||
if (!flushUploadBuffer()) {
|
||||
uploadError = "Failed to write to SD card - disk may be full";
|
||||
uploadFile.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
lastWriteTime = millis();
|
||||
}
|
||||
|
||||
uploadSize += upload.currentSize;
|
||||
|
||||
// Log progress every 100KB
|
||||
if (uploadSize - lastLoggedSize >= 102400) {
|
||||
const unsigned long elapsed = millis() - uploadStartTime;
|
||||
const float kbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0;
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes\n", millis(), uploadSize,
|
||||
uploadSize / 1024.0, kbps, writeCount);
|
||||
lastLoggedSize = uploadSize;
|
||||
}
|
||||
}
|
||||
} else if (upload.status == UPLOAD_FILE_END) {
|
||||
if (uploadFile) {
|
||||
// Flush any remaining buffered data
|
||||
if (!flushUploadBuffer()) {
|
||||
uploadError = "Failed to write final data to SD card";
|
||||
}
|
||||
uploadFile.close();
|
||||
|
||||
if (uploadError.isEmpty()) {
|
||||
uploadSuccess = true;
|
||||
Serial.printf("[%lu] [WEB] Upload complete: %s (%d bytes)\n", millis(), uploadFileName.c_str(), uploadSize);
|
||||
const unsigned long elapsed = millis() - uploadStartTime;
|
||||
const float avgKbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0;
|
||||
const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0;
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)\n", millis(),
|
||||
uploadFileName.c_str(), uploadSize, elapsed, avgKbps);
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(),
|
||||
writeCount, totalWriteTime, writePercent);
|
||||
}
|
||||
}
|
||||
} else if (upload.status == UPLOAD_FILE_ABORTED) {
|
||||
uploadBufferPos = 0; // Discard buffered data
|
||||
if (uploadFile) {
|
||||
uploadFile.close();
|
||||
// Try to delete the incomplete file
|
||||
@@ -555,3 +657,143 @@ void CrossPointWebServer::handleDelete() const {
|
||||
server->send(500, "text/plain", "Failed to delete item");
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket callback trampoline
|
||||
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
if (wsInstance) {
|
||||
wsInstance->onWebSocketEvent(num, type, payload, length);
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket event handler for fast binary uploads
|
||||
// Protocol:
|
||||
// 1. Client sends TEXT message: "START:<filename>:<size>:<path>"
|
||||
// 2. Client sends BINARY messages with file data chunks
|
||||
// 3. Server sends TEXT "PROGRESS:<received>:<total>" after each chunk
|
||||
// 4. Server sends TEXT "DONE" or "ERROR:<message>" when complete
|
||||
void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
switch (type) {
|
||||
case WStype_DISCONNECTED:
|
||||
Serial.printf("[%lu] [WS] Client %u disconnected\n", millis(), num);
|
||||
// Clean up any in-progress upload
|
||||
if (wsUploadInProgress && wsUploadFile) {
|
||||
wsUploadFile.close();
|
||||
// Delete incomplete file
|
||||
String filePath = wsUploadPath;
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += wsUploadFileName;
|
||||
SdMan.remove(filePath.c_str());
|
||||
Serial.printf("[%lu] [WS] Deleted incomplete upload: %s\n", millis(), filePath.c_str());
|
||||
}
|
||||
wsUploadInProgress = false;
|
||||
break;
|
||||
|
||||
case WStype_CONNECTED: {
|
||||
Serial.printf("[%lu] [WS] Client %u connected\n", millis(), num);
|
||||
break;
|
||||
}
|
||||
|
||||
case WStype_TEXT: {
|
||||
// Parse control messages
|
||||
String msg = String((char*)payload);
|
||||
Serial.printf("[%lu] [WS] Text from client %u: %s\n", millis(), num, msg.c_str());
|
||||
|
||||
if (msg.startsWith("START:")) {
|
||||
// Parse: START:<filename>:<size>:<path>
|
||||
int firstColon = msg.indexOf(':', 6);
|
||||
int secondColon = msg.indexOf(':', firstColon + 1);
|
||||
|
||||
if (firstColon > 0 && secondColon > 0) {
|
||||
wsUploadFileName = msg.substring(6, firstColon);
|
||||
wsUploadSize = msg.substring(firstColon + 1, secondColon).toInt();
|
||||
wsUploadPath = msg.substring(secondColon + 1);
|
||||
wsUploadReceived = 0;
|
||||
wsUploadStartTime = millis();
|
||||
|
||||
// Ensure path is valid
|
||||
if (!wsUploadPath.startsWith("/")) wsUploadPath = "/" + wsUploadPath;
|
||||
if (wsUploadPath.length() > 1 && wsUploadPath.endsWith("/")) {
|
||||
wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1);
|
||||
}
|
||||
|
||||
// Build file path
|
||||
String filePath = wsUploadPath;
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += wsUploadFileName;
|
||||
|
||||
Serial.printf("[%lu] [WS] Starting upload: %s (%d bytes) to %s\n", millis(), wsUploadFileName.c_str(),
|
||||
wsUploadSize, filePath.c_str());
|
||||
|
||||
// Check if file exists and remove it
|
||||
esp_task_wdt_reset();
|
||||
if (SdMan.exists(filePath.c_str())) {
|
||||
SdMan.remove(filePath.c_str());
|
||||
}
|
||||
|
||||
// Open file for writing
|
||||
esp_task_wdt_reset();
|
||||
if (!SdMan.openFileForWrite("WS", filePath, wsUploadFile)) {
|
||||
wsServer->sendTXT(num, "ERROR:Failed to create file");
|
||||
wsUploadInProgress = false;
|
||||
return;
|
||||
}
|
||||
esp_task_wdt_reset();
|
||||
|
||||
wsUploadInProgress = true;
|
||||
wsServer->sendTXT(num, "READY");
|
||||
} else {
|
||||
wsServer->sendTXT(num, "ERROR:Invalid START format");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case WStype_BIN: {
|
||||
if (!wsUploadInProgress || !wsUploadFile) {
|
||||
wsServer->sendTXT(num, "ERROR:No upload in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
// Write binary data directly to file
|
||||
esp_task_wdt_reset();
|
||||
size_t written = wsUploadFile.write(payload, length);
|
||||
esp_task_wdt_reset();
|
||||
|
||||
if (written != length) {
|
||||
wsUploadFile.close();
|
||||
wsUploadInProgress = false;
|
||||
wsServer->sendTXT(num, "ERROR:Write failed - disk full?");
|
||||
return;
|
||||
}
|
||||
|
||||
wsUploadReceived += written;
|
||||
|
||||
// Send progress update (every 64KB or at end)
|
||||
static size_t lastProgressSent = 0;
|
||||
if (wsUploadReceived - lastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) {
|
||||
String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize);
|
||||
wsServer->sendTXT(num, progress);
|
||||
lastProgressSent = wsUploadReceived;
|
||||
}
|
||||
|
||||
// Check if upload complete
|
||||
if (wsUploadReceived >= wsUploadSize) {
|
||||
wsUploadFile.close();
|
||||
wsUploadInProgress = false;
|
||||
|
||||
unsigned long elapsed = millis() - wsUploadStartTime;
|
||||
float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0;
|
||||
|
||||
Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(),
|
||||
wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps);
|
||||
|
||||
wsServer->sendTXT(num, "DONE");
|
||||
lastProgressSent = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <WebServer.h>
|
||||
#include <WebSocketsServer.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
@@ -34,9 +35,15 @@ class CrossPointWebServer {
|
||||
|
||||
private:
|
||||
std::unique_ptr<WebServer> server = nullptr;
|
||||
std::unique_ptr<WebSocketsServer> wsServer = nullptr;
|
||||
bool running = false;
|
||||
bool apMode = false; // true when running in AP mode, false for STA mode
|
||||
uint16_t port = 80;
|
||||
uint16_t wsPort = 81; // WebSocket port
|
||||
|
||||
// WebSocket upload state
|
||||
void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
||||
static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
||||
|
||||
// File scanning
|
||||
void scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const;
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
|
||||
#include <HTTPClient.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
const std::unique_ptr<WiFiClientSecure> client(new WiFiClientSecure());
|
||||
client->setInsecure();
|
||||
// Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP
|
||||
std::unique_ptr<WiFiClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new WiFiClientSecure();
|
||||
secureClient->setInsecure();
|
||||
client.reset(secureClient);
|
||||
} else {
|
||||
client.reset(new WiFiClient());
|
||||
}
|
||||
HTTPClient http;
|
||||
|
||||
Serial.printf("[%lu] [HTTP] Fetching: %s\n", millis(), url.c_str());
|
||||
@@ -33,8 +43,15 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
|
||||
HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress) {
|
||||
const std::unique_ptr<WiFiClientSecure> client(new WiFiClientSecure());
|
||||
client->setInsecure();
|
||||
// Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP
|
||||
std::unique_ptr<WiFiClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new WiFiClientSecure();
|
||||
secureClient->setInsecure();
|
||||
client.reset(secureClient);
|
||||
} else {
|
||||
client.reset(new WiFiClient());
|
||||
}
|
||||
HTTPClient http;
|
||||
|
||||
Serial.printf("[%lu] [HTTP] Downloading: %s\n", millis(), url.c_str());
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#include <Update.h>
|
||||
|
||||
namespace {
|
||||
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest";
|
||||
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/latest";
|
||||
}
|
||||
|
||||
OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
|
||||
|
||||
@@ -816,6 +816,151 @@
|
||||
}
|
||||
|
||||
let failedUploadsGlobal = [];
|
||||
let wsConnection = null;
|
||||
const WS_PORT = 81;
|
||||
const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability
|
||||
|
||||
// Get WebSocket URL based on current page location
|
||||
function getWsUrl() {
|
||||
const host = window.location.hostname;
|
||||
return `ws://${host}:${WS_PORT}/`;
|
||||
}
|
||||
|
||||
// Upload file via WebSocket (faster, binary protocol)
|
||||
function uploadFileWebSocket(file, onProgress, onComplete, onError) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(getWsUrl());
|
||||
let uploadStarted = false;
|
||||
let sendingChunks = false;
|
||||
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = function() {
|
||||
console.log('[WS] Connected, starting upload:', file.name);
|
||||
// Send start message: START:<filename>:<size>:<path>
|
||||
ws.send(`START:${file.name}:${file.size}:${currentPath}`);
|
||||
};
|
||||
|
||||
ws.onmessage = async function(event) {
|
||||
const msg = event.data;
|
||||
console.log('[WS] Message:', msg);
|
||||
|
||||
if (msg === 'READY') {
|
||||
uploadStarted = true;
|
||||
sendingChunks = true;
|
||||
|
||||
// Small delay to let connection stabilize
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
try {
|
||||
// Send file in chunks
|
||||
const totalSize = file.size;
|
||||
let offset = 0;
|
||||
|
||||
while (offset < totalSize && ws.readyState === WebSocket.OPEN) {
|
||||
const chunkSize = Math.min(WS_CHUNK_SIZE, totalSize - offset);
|
||||
const chunk = file.slice(offset, offset + chunkSize);
|
||||
const buffer = await chunk.arrayBuffer();
|
||||
|
||||
// Wait for buffer to clear - more aggressive backpressure
|
||||
while (ws.bufferedAmount > WS_CHUNK_SIZE * 2 && ws.readyState === WebSocket.OPEN) {
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
}
|
||||
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('WebSocket closed during upload');
|
||||
}
|
||||
|
||||
ws.send(buffer);
|
||||
offset += chunkSize;
|
||||
|
||||
// Update local progress - cap at 95% since server still needs to write
|
||||
// Final 100% shown when server confirms DONE
|
||||
if (onProgress) {
|
||||
const cappedOffset = Math.min(offset, Math.floor(totalSize * 0.95));
|
||||
onProgress(cappedOffset, totalSize);
|
||||
}
|
||||
}
|
||||
|
||||
sendingChunks = false;
|
||||
console.log('[WS] All chunks sent, waiting for DONE');
|
||||
} catch (err) {
|
||||
console.error('[WS] Error sending chunks:', err);
|
||||
sendingChunks = false;
|
||||
ws.close();
|
||||
reject(err);
|
||||
}
|
||||
} else if (msg.startsWith('PROGRESS:')) {
|
||||
// Server confirmed progress - log for debugging but don't update UI
|
||||
// (local progress is smoother, server progress causes jumping)
|
||||
console.log('[WS] Server progress:', msg);
|
||||
} else if (msg === 'DONE') {
|
||||
// Show 100% when server confirms completion
|
||||
if (onProgress) onProgress(file.size, file.size);
|
||||
ws.close();
|
||||
if (onComplete) onComplete();
|
||||
resolve();
|
||||
} else if (msg.startsWith('ERROR:')) {
|
||||
const error = msg.substring(6);
|
||||
ws.close();
|
||||
if (onError) onError(error);
|
||||
reject(new Error(error));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = function(event) {
|
||||
console.error('[WS] Error:', event);
|
||||
if (!uploadStarted) {
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
} else if (!sendingChunks) {
|
||||
reject(new Error('WebSocket error during upload'));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function(event) {
|
||||
console.log('[WS] Connection closed, code:', event.code, 'reason:', event.reason);
|
||||
if (sendingChunks) {
|
||||
reject(new Error('WebSocket closed unexpectedly'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Upload file via HTTP (fallback method)
|
||||
function uploadFileHTTP(file, onProgress, onComplete, onError) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
||||
|
||||
xhr.upload.onprogress = function(e) {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress(e.loaded, e.total);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
if (onComplete) onComplete();
|
||||
resolve();
|
||||
} else {
|
||||
const error = xhr.responseText || 'Upload failed';
|
||||
if (onError) onError(error);
|
||||
reject(new Error(error));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
const error = 'Network error';
|
||||
if (onError) onError(error);
|
||||
reject(new Error(error));
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function uploadFile() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
@@ -836,8 +981,9 @@ function uploadFile() {
|
||||
|
||||
let currentIndex = 0;
|
||||
const failedFiles = [];
|
||||
let useWebSocket = true; // Try WebSocket first
|
||||
|
||||
function uploadNextFile() {
|
||||
async function uploadNextFile() {
|
||||
if (currentIndex >= files.length) {
|
||||
// All files processed - show summary
|
||||
if (failedFiles.length === 0) {
|
||||
@@ -845,67 +991,71 @@ function uploadFile() {
|
||||
progressText.textContent = 'All uploads complete!';
|
||||
setTimeout(() => {
|
||||
closeUploadModal();
|
||||
hydrate(); // Refresh file list instead of reloading
|
||||
hydrate();
|
||||
}, 1000);
|
||||
} else {
|
||||
progressFill.style.backgroundColor = '#e74c3c';
|
||||
const failedList = failedFiles.map(f => f.name).join(', ');
|
||||
progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`;
|
||||
|
||||
// Store failed files globally and show banner
|
||||
failedUploadsGlobal = failedFiles;
|
||||
|
||||
setTimeout(() => {
|
||||
closeUploadModal();
|
||||
showFailedUploadsBanner();
|
||||
hydrate(); // Refresh file list to show successfully uploaded files
|
||||
hydrate();
|
||||
}, 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[currentIndex];
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
// Include path as query parameter since multipart form data doesn't make
|
||||
// form fields available until after file upload completes
|
||||
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
||||
|
||||
progressFill.style.width = '0%';
|
||||
progressFill.style.backgroundColor = '#4caf50';
|
||||
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`;
|
||||
progressFill.style.backgroundColor = '#27ae60';
|
||||
const methodText = useWebSocket ? ' [WS]' : ' [HTTP]';
|
||||
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText}`;
|
||||
|
||||
xhr.upload.onprogress = function (e) {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
progressFill.style.width = percent + '%';
|
||||
progressText.textContent =
|
||||
`Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`;
|
||||
}
|
||||
const onProgress = (loaded, total) => {
|
||||
const percent = Math.round((loaded / total) * 100);
|
||||
progressFill.style.width = percent + '%';
|
||||
const speed = ''; // Could calculate speed here
|
||||
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${percent}%`;
|
||||
};
|
||||
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
currentIndex++;
|
||||
uploadNextFile(); // upload next file
|
||||
} else {
|
||||
// Track failure and continue with next file
|
||||
failedFiles.push({ name: file.name, error: xhr.responseText, file: file });
|
||||
currentIndex++;
|
||||
uploadNextFile();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
// Track network error and continue with next file
|
||||
failedFiles.push({ name: file.name, error: 'network error', file: file });
|
||||
const onComplete = () => {
|
||||
currentIndex++;
|
||||
uploadNextFile();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
const onError = (error) => {
|
||||
failedFiles.push({ name: file.name, error: error, file: file });
|
||||
currentIndex++;
|
||||
uploadNextFile();
|
||||
};
|
||||
|
||||
try {
|
||||
if (useWebSocket) {
|
||||
await uploadFileWebSocket(file, onProgress, null, null);
|
||||
onComplete();
|
||||
} else {
|
||||
await uploadFileHTTP(file, onProgress, null, null);
|
||||
onComplete();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
if (useWebSocket && error.message === 'WebSocket connection failed') {
|
||||
// Fall back to HTTP for all subsequent uploads
|
||||
console.log('WebSocket failed, falling back to HTTP');
|
||||
useWebSocket = false;
|
||||
// Retry this file with HTTP
|
||||
try {
|
||||
await uploadFileHTTP(file, onProgress, null, null);
|
||||
onComplete();
|
||||
} catch (httpError) {
|
||||
onError(httpError.message);
|
||||
}
|
||||
} else {
|
||||
onError(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uploadNextFile();
|
||||
|
||||
@@ -49,4 +49,23 @@ bool checkFileExtension(const std::string& fileName, const char* extension) {
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t utf8RemoveLastChar(std::string& str) {
|
||||
if (str.empty()) return 0;
|
||||
size_t pos = str.size() - 1;
|
||||
// Walk back to find the start of the last UTF-8 character
|
||||
// UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF)
|
||||
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
|
||||
--pos;
|
||||
}
|
||||
str.resize(pos);
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Truncate string by removing N UTF-8 characters from the end
|
||||
void utf8TruncateChars(std::string& str, const size_t numChars) {
|
||||
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
|
||||
utf8RemoveLastChar(str);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace StringUtils
|
||||
|
||||
@@ -16,4 +16,10 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
|
||||
*/
|
||||
bool checkFileExtension(const std::string& fileName, const char* extension);
|
||||
|
||||
// UTF-8 safe string truncation - removes one character from the end
|
||||
// Returns the new size after removing one UTF-8 character
|
||||
size_t utf8RemoveLastChar(std::string& str);
|
||||
|
||||
// Truncate string by removing N UTF-8 characters from the end
|
||||
void utf8TruncateChars(std::string& str, size_t numChars);
|
||||
} // namespace StringUtils
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace UrlUtils {
|
||||
|
||||
bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0; }
|
||||
|
||||
std::string ensureProtocol(const std::string& url) {
|
||||
if (url.find("://") == std::string::npos) {
|
||||
return "http://" + url;
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
|
||||
namespace UrlUtils {
|
||||
|
||||
/**
|
||||
* Check if URL uses HTTPS protocol
|
||||
*/
|
||||
bool isHttpsUrl(const std::string& url);
|
||||
|
||||
/**
|
||||
* Prepend http:// if no protocol specified (server will redirect to https if needed)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user