Compare commits

..

16 Commits

Author SHA1 Message Date
Jonas Diemer
cd1e4e8c8e
Merge 800a26b88d into 56ec3dfb6d 2026-01-14 07:54:33 -07:00
Dave Allie
56ec3dfb6d
chore: Cut release 0.14.0
Some checks are pending
CI / build (push) Waiting to run
2026-01-15 01:01:47 +11:00
Maeve Andrews
e517945aaa
Add a bullet to the beginning of any <li> (#368)
Currently there is no visual indication whatsoever if something is in a
list. An `<li>` is essentially just another paragraph.

As a partial remedy for this, add a bullet character to the beginning of
`<li>` text blocks so that the user can see that they're list items.
This is incomplete in that an `<ol>` should also have a counter so that
its list items can get numbers instead of bullets (right now I don't
think we track if we're in a `<ul>` or an `<ol>` at all), but it's
strictly better than the current situation.

Co-authored-by: Maeve Andrews <maeve@git.mail.maeveandrews.com>
2026-01-14 23:23:03 +11:00
Maeve Andrews
489220832f
Only indent paragraphs for justify/left-align (#367)
Currently, when Extra Paragraph Spacing is off, an em-space is added to
the beginning of each ParsedText even for blocks like headers that are
centered. This whitespace makes the centering slightly off. Change the
calculation here to only add the em-space for left/justified text.

Co-authored-by: Maeve Andrews <maeve@git.mail.maeveandrews.com>
2026-01-14 23:21:48 +11:00
Dave Allie
3ee10b31ab
Update OTA updater URL 2026-01-14 23:14:00 +11:00
swwilshub
a946c83a07
Turbocharge WiFi uploads with WebSocket + watchdog stability (#364)
## Summary

* **What is the goal of this PR?** Fix WiFi file transfer stability
issues (especially crashes during uploads) and improve upload speed via
WebSocket binary protocol. File transfers now don't really crash as
much, if they do it recovers and speed has gone form 50KB/s to 300+KB/s.

* **What changes are included?**
- **WebSocket upload support** - Adds WebSocket binary protocol for file
uploads, achieving faster speeds 335 KB/s vs HTTP multipart)
- **Watchdog stability fixes** - Adds `esp_task_wdt_reset()` calls
throughout upload path to prevent watchdog timeouts during:
    - File creation (FAT allocation can be slow)
    - SD card write operations
    - HTTP header parsing
    - WebSocket chunk processing
- **4KB write buffering** - Batches SD card writes to reduce I/O
overhead
- **WiFi health monitoring** - Detects WiFi disconnection in STA mode
and exits gracefully
- **Improved handleClient loop** - 500 iterations with periodic watchdog
resets and button checks for responsiveness
- **Progress bar improvements** - Fixed jumping/inaccurate progress by
capping local progress at 95% until server confirms completion
- **Exit button responsiveness** - Button now checked inside the
handleClient loop every 64 iterations
- **Reduced exit delays** - Decreased shutdown delays from ~850ms to
~140ms

**Files changed:**
- `platformio.ini` - Added WebSockets library dependency
- `CrossPointWebServer.cpp/h` - WebSocket server, upload buffering,
watchdog resets
- `CrossPointWebServerActivity.cpp` - WiFi monitoring, improved loop,
button handling
- `FilesPage.html` - WebSocket upload JavaScript with HTTP fallback

## Additional Context

- WebSocket uses 4KB chunks with backpressure management to prevent
ESP32 buffer overflow
- Falls back to HTTP automatically if WebSocket connection fails
- The main bottleneck now is SD card write speed (~44% of transfer
time), not WiFi
- STA mode was more prone to crashes than AP mode due to external
network factors; WiFi health monitoring helps detect and handle
disconnections gracefully

---

### AI Usage

Did you use AI tools to help write this code? _**YES**_ Claude did it
ALL, I have no idea what I am doing, but my books transfer fast now.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-14 23:11:28 +11:00
Justin Mitchell
847786e342
Fixes issue with Calibre web expecting SSL (#347)
http urls now work with Calibre web

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-14 11:54:14 +00:00
Luke Stein
c2fb8ce55d
Update User Guide to reflect release 0.13.1 (#337)
Please note I have not tested the Calibre features and am not yet in a
position to offer detailed documentation of how they work.
2026-01-14 22:48:43 +11:00
Armando Cerna
ed05554d74
feat: Add setting to toggle long-press chapter skip (#341)
## Summary

Adds a new "Long-press Chapter Skip" toggle in Settings to control
whether holding the side buttons skips chapters.

I kept accidentally triggering chapter skips while reading, which caused
me to lose my place in the middle of long chapters.

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY **_
2026-01-14 22:47:24 +11:00
Jonas Diemer
9a9dc044ce
ifdef around optional fonts to reduce flash size/time. (#339)
## Summary

Adds define to omit optional fonts from the build. This reduces time to
flash from >31s to <13s. Useful for development that doesn't require
fonts. Addresses #193

Invoke it like this during development:
`PLATFORMIO_BUILD_FLAGS="-D OMIT_FONTS" pio run --target upload && pio
device monitor`

Changing the define causes `pio` to do a full rebuild (but it will be
quick if you keep the define).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? NO
2026-01-14 22:40:40 +11:00
Jonas Diemer
1c027ce2cd
Skip BOM character (sometimes used in front of em-dashes) (#340)
## Summary

Skip BOM character (sometimes used in front of em-dashes) - they are not
part of the glyph set and would render `?` otherwise.

---

### AI Usage

Did you use AI tools to help write this code? _**YES**_
2026-01-14 22:38:30 +11:00
Eunchurn Park
49f97b69ca
Add TXT file reader support (#240)
## Summary

* **What is the goal of this PR?** 

Add support for reading plain text (.txt) files, enabling users to
browse, read, and track progress in TXT documents alongside existing
EPUB and XTC formats.

* **What changes are included?**

- New Txt library for loading and parsing plain text files
- New TxtReaderActivity with streaming page rendering using 8KB chunks
to handle large files without memory issues on ESP32-C3
- Page index caching system (index.bin) for instant re-open after sleep
or app restart
- Progress bar UI during initial file indexing (matching EPUB style)
- Word wrapping with proper UTF-8 support
- Cover image support for TXT files:
- Primary: image with same filename as TXT (e.g., book.jpg for book.txt)
  - Fallback: cover.bmp/jpg/jpeg in the same folder
  - JPG to BMP conversion using existing converter
  - Sleep screen cover mode now works with TXT files
- File browser now shows .txt files

## Additional Context

* Add any other information that might be helpful for the reviewer

* Memory constraints: The streaming approach was necessary because
ESP32-C3 only has 320KB RAM. A 700KB TXT file cannot be loaded entirely
into memory, so we read 8KB chunks and build a page offset index
instead.

* Cache invalidation: The page index cache automatically invalidates
when file size, viewport width, or lines per page changes (e.g., font
size or orientation change).

* Performance: First open requires indexing (with progress bar),
subsequent opens load from cache instantly.

* Cover image format: PNG is detected but not supported for conversion
(no PNG decoder available). Only BMP and JPG/JPEG work.
2026-01-14 21:36:40 +11:00
Dave Allie
14643d0225
Move string helpers out of HomeActivity into StringUtils 2026-01-14 21:24:45 +11:00
Eunchurn Park
fecd1849b9
Add cover image display in *Continue Reading* card with framebuffer caching (#200)
## Summary

* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module,

Display the book cover image in the **"Continue Reading"** card on the
home screen, with fast navigation using framebuffer caching.

* **What changes are included?**

- Display book cover image in the "Continue Reading" card on home screen
- Load cover from cached BMP (same as sleep screen cover)
- Add framebuffer store/restore functions (`copyStoredBwBuffer`,
`freeStoredBwBuffer`) for fast navigation after initial render
- Fix `drawBitmap` scaling bug: apply scale to offset only, not to base
coordinates
- Add white text boxes behind title/author/continue reading label for
readability on cover
- Support both EPUB and XTC file cover images
- Increase HomeActivity task stack size from 2048 to 4096 for cover
image rendering

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).

- Performance: First render loads cover from SD card (~800ms),
subsequent navigation uses cached framebuffer (~instant)
- Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home
screen, freed on exit
- Fallback: If cover image is not available, falls back to standard
text-only display
- The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale
was incorrectly scaling the base coordinates. Now correctly uses screenY
= y + (offset scale)
2026-01-14 21:24:02 +11:00
Will Morrow
2040e088e7
Ensure new custom sleep image every time (#300)
When picking a random sleep image from a set of custom images, compare
the randomly chosen index against a cached value in settings. If the
value matches, use the next image (rolling over if it's the last image).
Cache the chosen image index to settings in either case.

## Summary

Implements a tweak on the custom sleep image feature that ensures that
the user gets a new image every time the device goes to sleep.
This change adds a new setting (perhaps there's a better place to cache
this?) that stores the most recently used file index. During picking the
random image index, we compare this against the random index and choose
the next one (modulo the number of image files) if it matches, ensuring
we get a new image.

## Additional Context

As mentioned, I used settings to cache this value since it is a
persisted store, perhaps that's overkill. Open to suggestions on if
there's a better way.
2026-01-14 21:05:08 +11:00
Andrew Brandt
65d23910a3
ci: add PR format check workflow (#328)
**Description**:

Add a new workflow to check the PR formatting to ensure consistency on
PR titles. We can also use this for semantic release versioning later,
if we so desire.

**Related Issue(s)**:

Implements first portion of #327

---------

Signed-off-by: Andrew Brandt <brandt.andrew89@gmail.com>
2026-01-14 21:04:02 +11:00
44 changed files with 2700 additions and 180 deletions

View File

@ -0,0 +1,26 @@
name: "PR Formatting"
on:
pull_request_target:
types:
- opened
- reopened
- edited
permissions:
statuses: write
jobs:
title-check:
name: Title Check
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Check PR Title
uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -20,9 +20,10 @@ Button layout can be customized in **[Settings](#35-settings)**.
### Power On / Off
To turn the device on or off, **press and hold the Power button for half a second**. In **[Settings](#35-settings)** you can configure the power button to trigger on a short press instead of a long one.
To turn the device on or off, **press and hold the Power button for approximately half a second**.
In **[Settings](#35-settings)** you can configure the power button to turn the device off with a short press instead of a long one.
To reboot the device (for example if it's frozen, or after a firmware update), press and release the Reset button, and then hold the Power button for a few seconds.
To reboot the device (for example if it's frozen, or after a firmware update), press and release the Reset button, and then quickly press and hold the Power button for a few seconds.
### First Launch
@ -63,18 +64,29 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con
The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust:
- **Sleep Screen**: Which sleep screen to display when the device sleeps:
- "Dark" (default) - The default dark sleep screen
- "Dark" (default) - The default dark Crosspoint logo sleep screen
- "Light" - The same default sleep screen, on a white background
- "Custom" - Custom images from the SD card, see [Sleep Screen](#36-sleep-screen) below for more information
- "Custom" - Custom images from the SD card; see [Sleep Screen](#36-sleep-screen) below for more information
- "Cover" - The book cover image (Note: this is experimental and may not work as expected)
- "Blank" - A blank screen
- "None" - A blank screen
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
- "Fit" (default) - Scale the image down to fit centered on the screen, padding with white borders as necessary
- "Crop" - Scale the image down and crop as necessary to try to to fill the screen (Note: this is experimental and may not work as expected)
- **Status Bar**: Configure the status bar displayed while reading:
- "None" - No status bar
- "No Progress" - Show status bar without reading progress
- "Full" - Show status bar with reading progress
- **Hide Battery %**: Configure where to suppress the battery pecentage display in the status bar; the battery icon will still be shown:
- "Never" - Always show battery percentage (default)
- "In Reader" - Show battery percentage everywhere except in reading mode
- "Always" - Always hide battery percentage
- **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book. If disabled, paragraphs will not have vertical space between them, but will have first-line indentation.
- **Short Power Button Click**: Whether to trigger the power button on a short press or a long press.
- **Reading Orientation**: Set the screen orientation for reading:
- **Text Anti-Aliasing**: Whether to show smooth grey edges (anti-aliasing) on text in reading mode. Note this slows down page turns slightly.
- **Short Power Button Click**: Controls the effect of a short click of the power button:
- "Ignore" - Require a long press to turn off the device
- "Sleep" - A short press powers the device off
- "Page Turn" - A short press in reading mode turns to the next page; a long press turns the device off
- **Reading Orientation**: Set the screen orientation for reading EPUB files:
- "Portrait" (default) - Standard portrait orientation
- "Landscape CW" - Landscape, rotated clockwise
- "Inverted" - Portrait, upside down
@ -83,16 +95,18 @@ The Settings screen allows you to configure the device's behavior. There are a f
- Back, Confirm, Left, Right (default)
- Left, Right, Back, Confirm
- Left, Back, Confirm, Right
- **Side Button Layout**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading.
- **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading.
- **Reader Font Family**: Choose the font used for reading:
- "Bookerly" (default) - Amazon's reading font
- "Noto Sans" - Google's sans-serif font
- "Open Dyslexic" - Font designed for readers with dyslexia
- **Reader Font Size**: Adjust the text size for reading; options are "Small", "Medium", "Large", or "X Large".
- **Reader Line Spacing**: Adjust the spacing between lines; options are "Tight", "Normal", or "Wide".
- **Reader Screen Margin**: Controls the screen margins in reader mode between 5 and 40 pixels in 5 pixel increments.
- **Reader Paragraph Alignment**: Set the alignment of paragraphs; options are "Justified" (default), "Left", "Center", or "Right".
- **Time to Sleep**: Set the duration of inactivity before the device automatically goes to sleep.
- **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting.
- **Calibre Settings**: Set up integration for accessing a Calibre web library or connecting to Calibre as a wireless device.
- **Check for updates**: Check for firmware updates over WiFi.
### 3.6 Sleep Screen
@ -124,6 +138,8 @@ Once you have opened a book, the button layout changes to facilitate reading.
The role of the volume (side) buttons can be swapped in **[Settings](#35-settings)**.
If the **Short Power Button Click** setting is set to "Page Turn", you can also turn to the next page by briefly pressing the Power button.
### Chapter Navigation
* **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release.
* **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release.

View File

@ -409,6 +409,70 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false;
}
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
bool Epub::generateThumbBmp() const {
// Already generated, return true
if (SdMan.exists(getThumbBmpPath().c_str())) {
return true;
}
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis());
return false;
}
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) {
Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis());
return false;
}
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis());
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg;
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
coverJpg.close();
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
FsFile thumbBmp;
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) {
coverJpg.close();
return false;
}
// Use smaller target size for Continue Reading card (half of screen: 240x400)
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
constexpr int THUMB_TARGET_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
THUMB_TARGET_HEIGHT);
coverJpg.close();
thumbBmp.close();
SdMan.remove(coverJpgTempPath.c_str());
if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis());
SdMan.remove(getThumbBmpPath().c_str());
}
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(),
success ? "yes" : "no");
return success;
} else {
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis());
}
return false;
}
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
if (itemHref.empty()) {
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis());

View File

@ -46,6 +46,8 @@ class Epub {
const std::string& getAuthor() const;
std::string getCoverBmpPath(bool cropped = false) const;
bool generateCoverBmp(bool cropped = false) const;
std::string getThumbBmpPath() const;
bool generateThumbBmp() const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;

View File

@ -43,7 +43,7 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
wordWidths.reserve(totalWordCount);
// add em-space at the beginning of first word in paragraph to indent
if (!extraParagraphSpacing) {
if ((style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN) && !extraParagraphSpacing) {
std::string& first_word = words.front();
first_word.insert(0, "\xe2\x80\x83");
}

View File

@ -114,6 +114,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->startNewTextBlock(self->currentTextBlock->getStyle());
} else {
self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment);
if (strcmp(name, "li") == 0) {
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
}
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
@ -168,6 +171,20 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
}
}
// Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF
const XML_Char FEFF_BYTE_1 = static_cast<XML_Char>(0xEF);
const XML_Char FEFF_BYTE_2 = static_cast<XML_Char>(0xBB);
const XML_Char FEFF_BYTE_3 = static_cast<XML_Char>(0xBF);
if (s[i] == FEFF_BYTE_1) {
// Check if the next two bytes complete the 3-byte sequence
if ((i + 2 < len) && (s[i + 1] == FEFF_BYTE_2) && (s[i + 2] == FEFF_BYTE_3)) {
// Sequence 0xEF 0xBB 0xBF found!
i += 2; // Skip the next two bytes
continue; // Move to the next iteration
}
}
// If we're about to run out of space, then cut the word off and start a new one
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
self->partWordBuffer[self->partWordBufferIndex] = '\0';

View File

@ -228,7 +228,10 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
}
case 1: {
for (int x = 0; x < width; x++) {
lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00;
// Get palette index (0 or 1) from bit at position x
const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0;
// Use palette lookup for proper black/white mapping
lum = paletteLum[palIndex];
packPixel(lum);
}
break;

View File

@ -42,6 +42,8 @@ class Bitmap {
bool isTopDown() const { return topDown; }
bool hasGreyscale() const { return bpp > 1; }
int getRowBytes() const { return rowBytes; }
bool is1Bit() const { return bpp == 1; }
uint16_t getBpp() const { return bpp; }
private:
static uint16_t readLE16(FsFile& f);

View File

@ -88,3 +88,19 @@ uint8_t quantize(int gray, int x, int y) {
return quantizeSimple(gray);
}
}
// 1-bit noise dithering for fast home screen rendering
// Uses hash-based noise for consistent dithering that works well at small sizes
uint8_t quantize1bit(int gray, int x, int y) {
gray = adjustPixel(gray);
// Generate noise threshold using integer hash (no regular pattern to alias)
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24); // 0-255
// Simple threshold with noise: gray >= (128 + noise offset) -> white
// The noise adds variation around the 128 midpoint
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
return (gray >= adjustedThreshold) ? 1 : 0;
}

View File

@ -5,8 +5,89 @@
// Helper functions
uint8_t quantize(int gray, int x, int y);
uint8_t quantizeSimple(int gray);
uint8_t quantize1bit(int gray, int x, int y);
int adjustPixel(int gray);
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):
// X 1/8 1/8
// 1/8 1/8 1/8
// 1/8
class Atkinson1BitDitherer {
public:
explicit Atkinson1BitDitherer(int width) : width(width) {
errorRow0 = new int16_t[width + 4](); // Current row
errorRow1 = new int16_t[width + 4](); // Next row
errorRow2 = new int16_t[width + 4](); // Row after next
}
~Atkinson1BitDitherer() {
delete[] errorRow0;
delete[] errorRow1;
delete[] errorRow2;
}
// EXPLICITLY DELETE THE COPY CONSTRUCTOR
Atkinson1BitDitherer(const Atkinson1BitDitherer& other) = delete;
// EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR
Atkinson1BitDitherer& operator=(const Atkinson1BitDitherer& other) = delete;
uint8_t processPixel(int gray, int x) {
// Apply brightness/contrast/gamma adjustments
gray = adjustPixel(gray);
// Add accumulated error
int adjusted = gray + errorRow0[x + 2];
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 2 levels (1-bit): 0 = black, 1 = white
uint8_t quantized;
int quantizedValue;
if (adjusted < 128) {
quantized = 0;
quantizedValue = 0;
} else {
quantized = 1;
quantizedValue = 255;
}
// Calculate error (only distribute 6/8 = 75%)
int error = (adjusted - quantizedValue) >> 3; // error/8
// Distribute 1/8 to each of 6 neighbors
errorRow0[x + 3] += error; // Right
errorRow0[x + 4] += error; // Right+1
errorRow1[x + 1] += error; // Bottom-left
errorRow1[x + 2] += error; // Bottom
errorRow1[x + 3] += error; // Bottom-right
errorRow2[x + 2] += error; // Two rows down
return quantized;
}
void nextRow() {
int16_t* temp = errorRow0;
errorRow0 = errorRow1;
errorRow1 = errorRow2;
errorRow2 = temp;
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
void reset() {
memset(errorRow0, 0, (width + 4) * sizeof(int16_t));
memset(errorRow1, 0, (width + 4) * sizeof(int16_t));
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
private:
int width;
int16_t* errorRow0;
int16_t* errorRow1;
int16_t* errorRow2;
};
// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results
// Error distribution pattern:
// X 1/8 1/8

View File

@ -154,6 +154,12 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
const float cropX, const float cropY) const {
// For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit)
if (bitmap.is1Bit() && cropX == 0.0f && cropY == 0.0f) {
drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight);
return;
}
float scale = 1.0f;
bool isScaled = false;
int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f);
@ -195,6 +201,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
if (screenY >= getScreenHeight()) {
break;
}
if (screenY < 0) {
continue;
}
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY);
@ -217,6 +226,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
if (screenX >= getScreenWidth()) {
break;
}
if (screenX < 0) {
continue;
}
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
@ -234,6 +246,143 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
free(rowBytes);
}
void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
const int maxHeight) const {
float scale = 1.0f;
bool isScaled = false;
if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
scale = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
isScaled = true;
}
if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight()));
isScaled = true;
}
// For 1-bit BMP, output is still 2-bit packed (for consistency with readNextRow)
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) {
Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", millis());
free(outputRow);
free(rowBytes);
return;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
// Read rows sequentially using readNextRow
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", millis(), bmpY);
free(outputRow);
free(rowBytes);
return;
}
// Calculate screen Y based on whether BMP is top-down or bottom-up
const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
int screenY = y + (isScaled ? static_cast<int>(std::floor(bmpYOffset * scale)) : bmpYOffset);
if (screenY >= getScreenHeight()) {
continue; // Continue reading to keep row counter in sync
}
if (screenY < 0) {
continue;
}
for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) {
int screenX = x + (isScaled ? static_cast<int>(std::floor(bmpX * scale)) : bmpX);
if (screenX >= getScreenWidth()) {
break;
}
if (screenX < 0) {
continue;
}
// Get 2-bit value (result of readNextRow quantization)
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
// For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3)
// val < 3 means black pixel (draw it)
if (val < 3) {
drawPixel(screenX, screenY, true);
}
// White pixels (val == 3) are not drawn (leave background)
}
}
free(outputRow);
free(rowBytes);
}
void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const {
if (numPoints < 3) return;
// Find bounding box
int minY = yPoints[0], maxY = yPoints[0];
for (int i = 1; i < numPoints; i++) {
if (yPoints[i] < minY) minY = yPoints[i];
if (yPoints[i] > maxY) maxY = yPoints[i];
}
// Clip to screen
if (minY < 0) minY = 0;
if (maxY >= getScreenHeight()) maxY = getScreenHeight() - 1;
// Allocate node buffer for scanline algorithm
auto* nodeX = static_cast<int*>(malloc(numPoints * sizeof(int)));
if (!nodeX) {
Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", millis());
return;
}
// Scanline fill algorithm
for (int scanY = minY; scanY <= maxY; scanY++) {
int nodes = 0;
// Find all intersection points with edges
int j = numPoints - 1;
for (int i = 0; i < numPoints; i++) {
if ((yPoints[i] < scanY && yPoints[j] >= scanY) || (yPoints[j] < scanY && yPoints[i] >= scanY)) {
// Calculate X intersection using fixed-point to avoid float
int dy = yPoints[j] - yPoints[i];
if (dy != 0) {
nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) * (xPoints[j] - xPoints[i]) / dy;
}
}
j = i;
}
// Sort nodes by X (simple bubble sort, numPoints is small)
for (int i = 0; i < nodes - 1; i++) {
for (int k = i + 1; k < nodes; k++) {
if (nodeX[i] > nodeX[k]) {
int temp = nodeX[i];
nodeX[i] = nodeX[k];
nodeX[k] = temp;
}
}
}
// Fill between pairs of nodes
for (int i = 0; i < nodes - 1; i += 2) {
int startX = nodeX[i];
int endX = nodeX[i + 1];
// Clip to screen
if (startX < 0) startX = 0;
if (endX >= getScreenWidth()) endX = getScreenWidth() - 1;
// Draw horizontal line
for (int x = startX; x <= endX; x++) {
drawPixel(x, scanY, state);
}
}
}
free(nodeX);
}
void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); }
void GfxRenderer::invertScreen() const {

View File

@ -68,6 +68,8 @@ class GfxRenderer {
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
float cropY = 0) const;
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;
// Text
int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
@ -97,8 +99,8 @@ class GfxRenderer {
void copyGrayscaleLsbBuffers() const;
void copyGrayscaleMsbBuffers() const;
void displayGrayBuffer() const;
bool storeBwBuffer(); // Returns true if buffer was stored successfully
void restoreBwBuffer();
bool storeBwBuffer(); // Returns true if buffer was stored successfully
void restoreBwBuffer(); // Restore and free the stored buffer
void cleanupGrayscaleWithFrameBuffer() const;
// Low level functions

View File

@ -87,8 +87,47 @@ void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) {
}
}
// Helper function: Write BMP header with 1-bit color depth (black and white)
static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes)
const int bytesPerRow = (width + 31) / 32 * 4; // 1 bit per pixel, round up to 4-byte boundary
const int imageSize = bytesPerRow * height;
const uint32_t fileSize = 62 + imageSize; // 14 (file header) + 40 (DIB header) + 8 (palette) + image
// BMP File Header (14 bytes)
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize); // File size
write32(bmpOut, 0); // Reserved
write32(bmpOut, 62); // Offset to pixel data (14 + 40 + 8)
// DIB Header (BITMAPINFOHEADER - 40 bytes)
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height); // Negative height = top-down bitmap
write16(bmpOut, 1); // Color planes
write16(bmpOut, 1); // Bits per pixel (1 bit)
write32(bmpOut, 0); // BI_RGB (no compression)
write32(bmpOut, imageSize);
write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI)
write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI)
write32(bmpOut, 2); // colorsUsed
write32(bmpOut, 2); // colorsImportant
// Color Palette (2 colors x 4 bytes = 8 bytes)
// Format: Blue, Green, Red, Reserved (BGRA)
// Note: In 1-bit BMP, palette index 0 = black, 1 = white
uint8_t palette[8] = {
0x00, 0x00, 0x00, 0x00, // Color 0: Black
0xFF, 0xFF, 0xFF, 0x00 // Color 1: White
};
for (const uint8_t i : palette) {
bmpOut.write(i);
}
}
// Helper function: Write BMP header with 2-bit color depth
void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) {
static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes)
const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up
const int imageSize = bytesPerRow * height;
@ -159,9 +198,11 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un
return 0; // Success
}
// Core function: Convert JPEG file to 2-bit BMP
bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
Serial.printf("[%lu] [JPG] Converting JPEG to BMP\n", millis());
// Internal implementation with configurable target size and bit depth
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit) {
Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit",
targetWidth, targetHeight);
// Setup context for picojpeg callback
JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
@ -196,10 +237,10 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
uint32_t scaleY_fp = 65536;
bool needsScaling = false;
if (USE_PRESCALE && (imageInfo.m_width > TARGET_MAX_WIDTH || imageInfo.m_height > TARGET_MAX_HEIGHT)) {
if (targetWidth > 0 && targetHeight > 0 && (imageInfo.m_width > targetWidth || imageInfo.m_height > targetHeight)) {
// Calculate scale to fit within target dimensions while maintaining aspect ratio
const float scaleToFitWidth = static_cast<float>(TARGET_MAX_WIDTH) / imageInfo.m_width;
const float scaleToFitHeight = static_cast<float>(TARGET_MAX_HEIGHT) / imageInfo.m_height;
const float scaleToFitWidth = static_cast<float>(targetWidth) / imageInfo.m_width;
const float scaleToFitHeight = static_cast<float>(targetHeight) / imageInfo.m_height;
// We scale to the smaller dimension, so we can potentially crop later.
// TODO: ideally, we already crop here.
const float scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
@ -218,16 +259,19 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
needsScaling = true;
Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width,
imageInfo.m_height, outWidth, outHeight, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT);
imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight);
}
// Write BMP header with output dimensions
int bytesPerRow;
if (USE_8BIT_OUTPUT) {
if (USE_8BIT_OUTPUT && !oneBit) {
writeBmpHeader8bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 3) / 4 * 4;
} else if (oneBit) {
writeBmpHeader1bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel
} else {
writeBmpHeader(bmpOut, outWidth, outHeight);
writeBmpHeader2bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth * 2 + 31) / 32 * 4;
}
@ -258,11 +302,16 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
return false;
}
// Create ditherer if enabled (only for 2-bit output)
// Create ditherer if enabled
// Use OUTPUT dimensions for dithering (after prescaling)
AtkinsonDitherer* atkinsonDitherer = nullptr;
FloydSteinbergDitherer* fsDitherer = nullptr;
if (!USE_8BIT_OUTPUT) {
Atkinson1BitDitherer* atkinson1BitDitherer = nullptr;
if (oneBit) {
// For 1-bit output, use Atkinson dithering for better quality
atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth);
} else if (!USE_8BIT_OUTPUT) {
if (USE_ATKINSON) {
atkinsonDitherer = new AtkinsonDitherer(outWidth);
} else if (USE_FLOYD_STEINBERG) {
@ -348,12 +397,25 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
// No scaling - direct output (1:1 mapping)
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT) {
if (USE_8BIT_OUTPUT && !oneBit) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
rowBuffer[x] = adjustPixel(gray);
}
} else if (oneBit) {
// 1-bit output with Atkinson dithering for better quality
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
const uint8_t bit =
atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y);
// Pack 1-bit value: MSB first, 8 pixels per byte
const int byteIndex = x / 8;
const int bitOffset = 7 - (x % 8);
rowBuffer[byteIndex] |= (bit << bitOffset);
}
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
} else {
// 2-bit output
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]);
uint8_t twoBit;
@ -411,12 +473,25 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) {
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT) {
if (USE_8BIT_OUTPUT && !oneBit) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
rowBuffer[x] = adjustPixel(gray);
}
} else if (oneBit) {
// 1-bit output with Atkinson dithering for better quality
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x)
: quantize1bit(gray, x, currentOutY);
// Pack 1-bit value: MSB first, 8 pixels per byte
const int byteIndex = x / 8;
const int bitOffset = 7 - (x % 8);
rowBuffer[byteIndex] |= (bit << bitOffset);
}
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
} else {
// 2-bit output
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0);
uint8_t twoBit;
@ -464,9 +539,29 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
if (fsDitherer) {
delete fsDitherer;
}
if (atkinson1BitDitherer) {
delete atkinson1BitDitherer;
}
free(mcuRowBuffer);
free(rowBuffer);
Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis());
return true;
}
// Core function: Convert JPEG file to 2-bit BMP (uses default target size)
bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false);
}
// Convert with custom target size (for thumbnails, 2-bit)
bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
int targetMaxHeight) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false);
}
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
int targetMaxHeight) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true);
}

View File

@ -5,11 +5,15 @@ class Print;
class ZipFile;
class JpegToBmpConverter {
static void writeBmpHeader(Print& bmpOut, int width, int height);
// [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y);
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit);
public:
static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut);
// Convert with custom target size (for thumbnails)
static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
};

191
lib/Txt/Txt.cpp Normal file
View File

@ -0,0 +1,191 @@
#include "Txt.h"
#include <FsHelpers.h>
#include <JpegToBmpConverter.h>
Txt::Txt(std::string path, std::string cacheBasePath)
: filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) {
// Generate cache path from file path hash
const size_t hash = std::hash<std::string>{}(filepath);
cachePath = this->cacheBasePath + "/txt_" + std::to_string(hash);
}
bool Txt::load() {
if (loaded) {
return true;
}
if (!SdMan.exists(filepath.c_str())) {
Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str());
return false;
}
FsFile file;
if (!SdMan.openFileForRead("TXT", filepath, file)) {
Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str());
return false;
}
fileSize = file.size();
file.close();
loaded = true;
Serial.printf("[%lu] [TXT] Loaded TXT file: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize);
return true;
}
std::string Txt::getTitle() const {
// Extract filename without path and extension
size_t lastSlash = filepath.find_last_of('/');
std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath;
// Remove .txt extension
if (filename.length() >= 4 && filename.substr(filename.length() - 4) == ".txt") {
filename = filename.substr(0, filename.length() - 4);
}
return filename;
}
void Txt::setupCacheDir() const {
if (!SdMan.exists(cacheBasePath.c_str())) {
SdMan.mkdir(cacheBasePath.c_str());
}
if (!SdMan.exists(cachePath.c_str())) {
SdMan.mkdir(cachePath.c_str());
}
}
std::string Txt::findCoverImage() const {
// Get the folder containing the txt file
size_t lastSlash = filepath.find_last_of('/');
std::string folder = (lastSlash != std::string::npos) ? filepath.substr(0, lastSlash) : "";
if (folder.empty()) {
folder = "/";
}
// Get the base filename without extension (e.g., "mybook" from "/books/mybook.txt")
std::string baseName = getTitle();
// Image extensions to try
const char* extensions[] = {".bmp", ".jpg", ".jpeg", ".png", ".BMP", ".JPG", ".JPEG", ".PNG"};
// First priority: look for image with same name as txt file (e.g., mybook.jpg)
for (const auto& ext : extensions) {
std::string coverPath = folder + "/" + baseName + ext;
if (SdMan.exists(coverPath.c_str())) {
Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str());
return coverPath;
}
}
// Fallback: look for cover image files
const char* coverNames[] = {"cover", "Cover", "COVER"};
for (const auto& name : coverNames) {
for (const auto& ext : extensions) {
std::string coverPath = folder + "/" + std::string(name) + ext;
if (SdMan.exists(coverPath.c_str())) {
Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str());
return coverPath;
}
}
}
return "";
}
std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
bool Txt::generateCoverBmp() const {
// Already generated, return true
if (SdMan.exists(getCoverBmpPath().c_str())) {
return true;
}
std::string coverImagePath = findCoverImage();
if (coverImagePath.empty()) {
Serial.printf("[%lu] [TXT] No cover image found for TXT file\n", millis());
return false;
}
// Setup cache directory
setupCacheDir();
// Get file extension
const size_t len = coverImagePath.length();
const bool isJpg =
(len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) ||
(len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG"));
const bool isBmp = len >= 4 && (coverImagePath.substr(len - 4) == ".bmp" || coverImagePath.substr(len - 4) == ".BMP");
if (isBmp) {
// Copy BMP file to cache
Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis());
FsFile src, dst;
if (!SdMan.openFileForRead("TXT", coverImagePath, src)) {
return false;
}
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), dst)) {
src.close();
return false;
}
uint8_t buffer[1024];
while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer));
dst.write(buffer, bytesRead);
}
src.close();
dst.close();
Serial.printf("[%lu] [TXT] Copied BMP cover to cache\n", millis());
return true;
}
if (isJpg) {
// Convert JPG/JPEG to BMP (same approach as Epub)
Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis());
FsFile coverJpg, coverBmp;
if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) {
return false;
}
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) {
coverJpg.close();
return false;
}
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp);
coverJpg.close();
coverBmp.close();
if (!success) {
Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis());
SdMan.remove(getCoverBmpPath().c_str());
} else {
Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis());
}
return success;
}
// PNG files are not supported (would need a PNG decoder)
Serial.printf("[%lu] [TXT] Cover image format not supported (only BMP/JPG/JPEG)\n", millis());
return false;
}
bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const {
if (!loaded) {
return false;
}
FsFile file;
if (!SdMan.openFileForRead("TXT", filepath, file)) {
return false;
}
if (!file.seek(offset)) {
file.close();
return false;
}
size_t bytesRead = file.read(buffer, length);
file.close();
return bytesRead > 0;
}

33
lib/Txt/Txt.h Normal file
View File

@ -0,0 +1,33 @@
#pragma once
#include <SDCardManager.h>
#include <memory>
#include <string>
class Txt {
std::string filepath;
std::string cacheBasePath;
std::string cachePath;
bool loaded = false;
size_t fileSize = 0;
public:
explicit Txt(std::string path, std::string cacheBasePath);
bool load();
[[nodiscard]] const std::string& getPath() const { return filepath; }
[[nodiscard]] const std::string& getCachePath() const { return cachePath; }
[[nodiscard]] std::string getTitle() const;
[[nodiscard]] size_t getFileSize() const { return fileSize; }
void setupCacheDir() const;
// Cover image support - looks for cover.bmp/jpg/jpeg/png in same folder as txt file
[[nodiscard]] std::string getCoverBmpPath() const;
[[nodiscard]] bool generateCoverBmp() const;
[[nodiscard]] std::string findCoverImage() const;
// Read content from file
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
};

View File

@ -293,6 +293,267 @@ bool Xtc::generateCoverBmp() const {
return true;
}
std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
bool Xtc::generateThumbBmp() const {
// Already generated
if (SdMan.exists(getThumbBmpPath().c_str())) {
return true;
}
if (!loaded || !parser) {
Serial.printf("[%lu] [XTC] Cannot generate thumb BMP, file not loaded\n", millis());
return false;
}
if (parser->getPageCount() == 0) {
Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis());
return false;
}
// Setup cache directory
setupCacheDir();
// Get first page info for cover
xtc::PageInfo pageInfo;
if (!parser->getPageInfo(0, pageInfo)) {
Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis());
return false;
}
// Get bit depth
const uint8_t bitDepth = parser->getBitDepth();
// Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card)
constexpr int THUMB_TARGET_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400;
// Calculate scale factor
float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width;
float scaleY = static_cast<float>(THUMB_TARGET_HEIGHT) / pageInfo.height;
float scale = (scaleX < scaleY) ? scaleX : scaleY;
// Only scale down, never up
if (scale >= 1.0f) {
// Page is already small enough, just use cover.bmp
// Copy cover.bmp to thumb.bmp
if (generateCoverBmp()) {
FsFile src, dst;
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) {
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) {
uint8_t buffer[512];
while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer));
dst.write(buffer, bytesRead);
}
dst.close();
}
src.close();
}
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis());
return SdMan.exists(getThumbBmpPath().c_str());
}
return false;
}
uint16_t thumbWidth = static_cast<uint16_t>(pageInfo.width * scale);
uint16_t thumbHeight = static_cast<uint16_t>(pageInfo.height * scale);
Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width,
pageInfo.height, thumbWidth, thumbHeight, scale);
// Allocate buffer for page data
size_t bitmapSize;
if (bitDepth == 2) {
bitmapSize = ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) * 2;
} else {
bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height;
}
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
if (!pageBuffer) {
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize);
return false;
}
// Load first page (cover)
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
if (bytesRead == 0) {
Serial.printf("[%lu] [XTC] Failed to load cover page for thumb\n", millis());
free(pageBuffer);
return false;
}
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
FsFile thumbBmp;
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) {
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis());
free(pageBuffer);
return false;
}
// Write 1-bit BMP header for fast home screen rendering
const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes
const uint32_t imageSize = rowSize * thumbHeight;
const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette
// File header
thumbBmp.write('B');
thumbBmp.write('M');
thumbBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
uint32_t reserved = 0;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes)
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
// DIB header
uint32_t dibHeaderSize = 40;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
int32_t widthVal = thumbWidth;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&widthVal), 4);
int32_t heightVal = -static_cast<int32_t>(thumbHeight); // Negative for top-down
thumbBmp.write(reinterpret_cast<const uint8_t*>(&heightVal), 4);
uint16_t planes = 1;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
uint16_t bitsPerPixel = 1; // 1-bit for black and white
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
uint32_t compression = 0;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
thumbBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
int32_t ppmX = 2835;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
int32_t ppmY = 2835;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
uint32_t colorsUsed = 2;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
uint32_t colorsImportant = 2;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
// Color palette (2 colors for 1-bit: black and white)
uint8_t palette[8] = {
0x00, 0x00, 0x00, 0x00, // Color 0: Black
0xFF, 0xFF, 0xFF, 0x00 // Color 1: White
};
thumbBmp.write(palette, 8);
// Allocate row buffer for 1-bit output
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(rowSize));
if (!rowBuffer) {
free(pageBuffer);
thumbBmp.close();
return false;
}
// Fixed-point scale factor (16.16)
uint32_t scaleInv_fp = static_cast<uint32_t>(65536.0f / scale);
// Pre-calculate plane info for 2-bit mode
const size_t planeSize = (bitDepth == 2) ? ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) : 0;
const uint8_t* plane1 = (bitDepth == 2) ? pageBuffer : nullptr;
const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr;
const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0;
const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0;
for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) {
memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1)
// Calculate source Y range with bounds checking
uint32_t srcYStart = (static_cast<uint32_t>(dstY) * scaleInv_fp) >> 16;
uint32_t srcYEnd = (static_cast<uint32_t>(dstY + 1) * scaleInv_fp) >> 16;
if (srcYStart >= pageInfo.height) srcYStart = pageInfo.height - 1;
if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height;
if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1;
if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height;
for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) {
// Calculate source X range with bounds checking
uint32_t srcXStart = (static_cast<uint32_t>(dstX) * scaleInv_fp) >> 16;
uint32_t srcXEnd = (static_cast<uint32_t>(dstX + 1) * scaleInv_fp) >> 16;
if (srcXStart >= pageInfo.width) srcXStart = pageInfo.width - 1;
if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width;
if (srcXEnd <= srcXStart) srcXEnd = srcXStart + 1;
if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width;
// Area averaging: sum grayscale values (0-255 range)
uint32_t graySum = 0;
uint32_t totalCount = 0;
for (uint32_t srcY = srcYStart; srcY < srcYEnd && srcY < pageInfo.height; srcY++) {
for (uint32_t srcX = srcXStart; srcX < srcXEnd && srcX < pageInfo.width; srcX++) {
uint8_t grayValue = 255; // Default: white
if (bitDepth == 2) {
// XTH 2-bit mode: pixel value 0-3
// Bounds check for column index
if (srcX < pageInfo.width) {
const size_t colIndex = pageInfo.width - 1 - srcX;
const size_t byteInCol = srcY / 8;
const size_t bitInByte = 7 - (srcY % 8);
const size_t byteOffset = colIndex * colBytes + byteInCol;
// Bounds check for buffer access
if (byteOffset < planeSize) {
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
const uint8_t pixelValue = (bit1 << 1) | bit2;
// Convert 2-bit (0-3) to grayscale: 0=black, 3=white
// pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity)
grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0
}
}
} else {
// 1-bit mode
const size_t byteIdx = srcY * srcRowBytes + srcX / 8;
const size_t bitIdx = 7 - (srcX % 8);
// Bounds check for buffer access
if (byteIdx < bitmapSize) {
const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1;
// XTC polarity: 1=black, 0=white
grayValue = pixelBit ? 0 : 255;
}
}
graySum += grayValue;
totalCount++;
}
}
// Calculate average grayscale and quantize to 1-bit with noise dithering
uint8_t avgGray = (totalCount > 0) ? static_cast<uint8_t>(graySum / totalCount) : 255;
// Hash-based noise dithering for 1-bit output
uint32_t hash = static_cast<uint32_t>(dstX) * 374761393u + static_cast<uint32_t>(dstY) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24); // 0-255
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
// Quantize to 1-bit: 0=black, 1=white
uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0;
// Pack 1-bit value into row buffer (MSB first, 8 pixels per byte)
const size_t byteIndex = dstX / 8;
const size_t bitOffset = 7 - (dstX % 8);
// Bounds check for row buffer access
if (byteIndex < rowSize) {
if (oneBit) {
rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white
} else {
rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black
}
}
}
// Write row (already padded to 4-byte boundary by rowSize)
thumbBmp.write(rowBuffer, rowSize);
}
free(rowBuffer);
thumbBmp.close();
free(pageBuffer);
Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight,
getThumbBmpPath().c_str());
return true;
}
uint32_t Xtc::getPageCount() const {
if (!loaded || !parser) {
return 0;

View File

@ -62,6 +62,9 @@ class Xtc {
// Cover image support (for sleep screen)
std::string getCoverBmpPath() const;
bool generateCoverBmp() const;
// Thumbnail support (for Continue Reading card)
std::string getThumbBmpPath() const;
bool generateThumbBmp() const;
// Page access
uint32_t getPageCount() const;

View File

@ -2,7 +2,7 @@
default_envs = default
[crosspoint]
version = 0.13.1
version = 0.14.0
[base]
platform = espressif32 @ 6.12.0
@ -47,6 +47,7 @@ lib_deps =
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
ArduinoJson @ 7.4.2
QRCode @ 0.0.1
links2004/WebSockets @ ^2.4.1
[env:default]
extends = base

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ class CrossPointState {
public:
std::string openEpubPath;
uint8_t lastSleepImage;
~CrossPointState() = default;
// Get singleton instance

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -52,7 +52,7 @@ void FileSelectionActivity::loadFiles() {
} else {
auto filename = std::string(name);
if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") ||
StringUtils::checkFileExtension(filename, ".xtc")) {
StringUtils::checkFileExtension(filename, ".xtc") || StringUtils::checkFileExtension(filename, ".txt")) {
files.emplace_back(filename);
}
}

View File

@ -3,6 +3,8 @@
#include "Epub.h"
#include "EpubReaderActivity.h"
#include "FileSelectionActivity.h"
#include "Txt.h"
#include "TxtReaderActivity.h"
#include "Xtc.h"
#include "XtcReaderActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
@ -20,6 +22,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());
@ -50,6 +58,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();
@ -67,6 +90,18 @@ void ReaderActivity::onSelectBookFile(const std::string& path) {
delay(2000);
onGoToFileSelection();
}
} else if (isTxtFile(path)) {
// Load TXT file
auto txt = loadTxt(path);
if (txt) {
onGoToTxtReader(std::move(txt));
} else {
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load TXT",
EpdFontFamily::REGULAR, EInkDisplay::HALF_REFRESH));
delay(2000);
onGoToFileSelection();
}
} else {
// Load EPUB file
auto epub = loadEpub(path);
@ -108,6 +143,15 @@ void ReaderActivity::onGoToXtcReader(std::unique_ptr<Xtc> xtc) {
[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();
@ -125,6 +169,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) {

View File

@ -5,6 +5,7 @@
class Epub;
class Xtc;
class Txt;
class ReaderActivity final : public ActivityWithSubactivity {
std::string initialBookPath;
@ -12,13 +13,16 @@ class ReaderActivity final : public ActivityWithSubactivity {
const std::function<void()> onGoBack;
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 onGoToFileSelection(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,

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

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

View File

@ -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"}),

View File

@ -43,18 +43,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);
@ -117,6 +118,7 @@ EpdFont opendyslexic14ItalicFont(&opendyslexic_14_italic);
EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic);
EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont,
&opendyslexic14BoldItalicFont);
#endif // OMIT_FONTS
EpdFont smallFont(&notosans_8_regular);
EpdFontFamily smallFontFamily(&smallFont);
@ -239,10 +241,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);
@ -251,6 +255,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);
@ -303,6 +308,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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
*/