Merge branch 'master' into pr/43 and fixing some code duplication

This commit is contained in:
Uri Tauber 2026-01-27 16:50:46 +02:00
commit 2e11b5723c
106 changed files with 1218 additions and 1247 deletions

1
.gitignore vendored
View File

@ -4,5 +4,6 @@
.vscode
lib/EpdFont/fontsrc
*.generated.h
.vs
build
**/__pycache__/

View File

@ -41,6 +41,8 @@ This project is **not affiliated with Xteink**; it's built as a community projec
- [ ] Full UTF support
- [x] Screen rotation
Multi-language support: Read EPUBs in various languages, including English, Spanish, French, German, Italian, Portuguese, Russian, Ukrainian, Polish, Swedish, Norwegian, [and more](./USER_GUIDE.md#supported-languages).
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
## Installing

View File

@ -81,6 +81,18 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con
> [!TIP]
> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details.
### 3.4.1 Calibre Wireless Transfers
CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin.
1. Install the plugin in Calibre:
- Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin.
- Download the zip file.
- Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file.
2. On the device: File Transfer → Connect to Calibre → Join a network.
3. Make sure your computer is on the same WiFi network.
4. In Calibre, click "Send to device" to transfer books.
### 3.5 Settings
The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust:
@ -116,6 +128,7 @@ 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
- Back, Confirm, Right, Left
- **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.
- **Long-press Chapter Skip**: Set whether long-pressing page turn buttons skip to the next/previous chapter.
- "Chapter Skip" (default) - Long-pressing skips to next/previous chapter
@ -131,7 +144,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
- **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.
- **OPDS Browser**: Configure OPDS server settings for browsing and downloading books. Set the server URL (for Calibre Content Server, add `/opds` to the end), and optionally configure username and password for servers requiring authentication. Note: Only HTTP Basic authentication is supported. If using Calibre Content Server with authentication enabled, you must set it to use Basic authentication instead of the default Digest authentication.
- **Check for updates**: Check for firmware updates over WiFi.
### 3.6 Sleep Screen
@ -177,6 +190,15 @@ This feature can be disabled in **[Settings](#35-settings)** to help avoid chang
* **Return to Home:** Press and **hold** the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.
* **Chapter Menu:** Press **Confirm** to open the **[Table of Contents/Chapter Selection](#5-chapter-selection-screen)**.
### Supported Languages
CrossPoint renders text using the following Unicode character blocks, enabling support for a wide range of languages:
* **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others.
* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others.
What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic and Farsi.
---
## 5. Chapter Selection Screen

View File

@ -3,6 +3,7 @@
* name: bookerly_12_bold
* size: 12
* mode: 2-bit
* Command used: fontconvert.py bookerly_12_bold 12 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_12_bolditalic
* size: 12
* mode: 2-bit
* Command used: fontconvert.py bookerly_12_bolditalic 12 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_12_italic
* size: 12
* mode: 2-bit
* Command used: fontconvert.py bookerly_12_italic 12 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_12_regular
* size: 12
* mode: 2-bit
* Command used: fontconvert.py bookerly_12_regular 12 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_14_bold
* size: 14
* mode: 2-bit
* Command used: fontconvert.py bookerly_14_bold 14 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_14_bolditalic
* size: 14
* mode: 2-bit
* Command used: fontconvert.py bookerly_14_bolditalic 14 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_14_italic
* size: 14
* mode: 2-bit
* Command used: fontconvert.py bookerly_14_italic 14 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_14_regular
* size: 14
* mode: 2-bit
* Command used: fontconvert.py bookerly_14_regular 14 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_16_bold
* size: 16
* mode: 2-bit
* Command used: fontconvert.py bookerly_16_bold 16 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_16_bolditalic
* size: 16
* mode: 2-bit
* Command used: fontconvert.py bookerly_16_bolditalic 16 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_16_italic
* size: 16
* mode: 2-bit
* Command used: fontconvert.py bookerly_16_italic 16 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_16_regular
* size: 16
* mode: 2-bit
* Command used: fontconvert.py bookerly_16_regular 16 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_18_bold
* size: 18
* mode: 2-bit
* Command used: fontconvert.py bookerly_18_bold 18 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_18_bolditalic
* size: 18
* mode: 2-bit
* Command used: fontconvert.py bookerly_18_bolditalic 18 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_18_italic
* size: 18
* mode: 2-bit
* Command used: fontconvert.py bookerly_18_italic 18 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: bookerly_18_regular
* size: 18
* mode: 2-bit
* Command used: fontconvert.py bookerly_18_regular 18 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_12_bold
* size: 12
* mode: 2-bit
* Command used: fontconvert.py notosans_12_bold 12 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_12_bolditalic
* size: 12
* mode: 2-bit
* Command used: fontconvert.py notosans_12_bolditalic 12 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_12_italic
* size: 12
* mode: 2-bit
* Command used: fontconvert.py notosans_12_italic 12 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_12_regular
* size: 12
* mode: 2-bit
* Command used: fontconvert.py notosans_12_regular 12 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_14_bold
* size: 14
* mode: 2-bit
* Command used: fontconvert.py notosans_14_bold 14 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_14_bolditalic
* size: 14
* mode: 2-bit
* Command used: fontconvert.py notosans_14_bolditalic 14 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_14_italic
* size: 14
* mode: 2-bit
* Command used: fontconvert.py notosans_14_italic 14 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_14_regular
* size: 14
* mode: 2-bit
* Command used: fontconvert.py notosans_14_regular 14 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_16_bold
* size: 16
* mode: 2-bit
* Command used: fontconvert.py notosans_16_bold 16 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_16_bolditalic
* size: 16
* mode: 2-bit
* Command used: fontconvert.py notosans_16_bolditalic 16 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_16_italic
* size: 16
* mode: 2-bit
* Command used: fontconvert.py notosans_16_italic 16 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_16_regular
* size: 16
* mode: 2-bit
* Command used: fontconvert.py notosans_16_regular 16 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_18_bold
* size: 18
* mode: 2-bit
* Command used: fontconvert.py notosans_18_bold 18 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_18_bolditalic
* size: 18
* mode: 2-bit
* Command used: fontconvert.py notosans_18_bolditalic 18 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_18_italic
* size: 18
* mode: 2-bit
* Command used: fontconvert.py notosans_18_italic 18 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_18_regular
* size: 18
* mode: 2-bit
* Command used: fontconvert.py notosans_18_regular 18 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: notosans_8_regular
* size: 8
* mode: 1-bit
* Command used: fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_10_bold
* size: 10
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_10_bold 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_10_bolditalic
* size: 10
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_10_bolditalic 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_10_italic
* size: 10
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_10_italic 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_10_regular
* size: 10
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_10_regular 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_12_bold
* size: 12
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_12_bold 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_12_bolditalic
* size: 12
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_12_bolditalic 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_12_italic
* size: 12
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_12_italic 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_12_regular
* size: 12
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_12_regular 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_14_bold
* size: 14
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_14_bold 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_14_bolditalic
* size: 14
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_14_bolditalic 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_14_italic
* size: 14
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_14_italic 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_14_regular
* size: 14
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_14_regular 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_8_bold
* size: 8
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_8_bold 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_8_bolditalic
* size: 8
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_8_bolditalic 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_8_italic
* size: 8
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_8_italic 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: opendyslexic_8_regular
* size: 8
* mode: 2-bit
* Command used: fontconvert.py opendyslexic_8_regular 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: ubuntu_10_bold
* size: 10
* mode: 1-bit
* Command used: fontconvert.py ubuntu_10_bold 10 ../builtinFonts/source/Ubuntu/Ubuntu-Bold.ttf
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: ubuntu_10_regular
* size: 10
* mode: 1-bit
* Command used: fontconvert.py ubuntu_10_regular 10 ../builtinFonts/source/Ubuntu/Ubuntu-Regular.ttf
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: ubuntu_12_bold
* size: 12
* mode: 1-bit
* Command used: fontconvert.py ubuntu_12_bold 12 ../builtinFonts/source/Ubuntu/Ubuntu-Bold.ttf
*/
#pragma once
#include "EpdFontData.h"

View File

@ -3,6 +3,7 @@
* name: ubuntu_12_regular
* size: 12
* mode: 1-bit
* Command used: fontconvert.py ubuntu_12_regular 12 ../builtinFonts/source/Ubuntu/Ubuntu-Regular.ttf
*/
#pragma once
#include "EpdFontData.h"

View File

@ -270,9 +270,17 @@ for index, glyph in enumerate(all_glyphs):
glyph_data.extend([b for b in packed])
glyph_props.append(props)
print(f"/**\n * generated by fontconvert.py\n * name: {font_name}\n * size: {size}\n * mode: {'2-bit' if is2Bit else '1-bit'}\n */")
print("#pragma once")
print("#include \"EpdFontData.h\"\n")
print(f"""/**
* generated by fontconvert.py
* name: {font_name}
* size: {size}
* mode: {'2-bit' if is2Bit else '1-bit'}
* Command used: {' '.join(sys.argv)}
*/
#pragma once
#include "EpdFontData.h"
""")
print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{")
for c in chunks(glyph_data, 16):
print (" " + " ".join(f"0x{b:02X}," for b in c))

View File

@ -363,7 +363,7 @@ const std::string& Epub::getLanguage() const {
}
std::string Epub::getCoverBmpPath(bool cropped) const {
const auto coverFileName = "cover" + cropped ? "_crop" : "";
const auto coverFileName = std::string("cover") + (cropped ? "_crop" : "");
return cachePath + "/" + coverFileName + ".bmp";
}
@ -386,7 +386,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis());
Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image (%s mode)\n", millis(), cropped ? "cropped" : "fit");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg;
@ -405,7 +405,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
coverJpg.close();
return false;
}
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp);
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped);
coverJpg.close();
coverBmp.close();
SdMan.remove(coverJpgTempPath.c_str());

View File

@ -125,6 +125,8 @@ bool isExplicitHyphen(const uint32_t cp) {
case 0xFE58: // small em dash
case 0xFE63: // small hyphen-minus
case 0xFF0D: // fullwidth hyphen-minus
case 0x005F: // Underscore
case 0x2026: // Ellipsis
return true;
default:
return false;

View File

@ -84,6 +84,26 @@ std::string replaceHtmlEntities(const char* text) {
return s;
}
EpdFontFamily::Style ChapterHtmlSlimParser::getCurrentFontStyle() const {
if (boldUntilDepth < depth && italicUntilDepth < depth) {
return EpdFontFamily::BOLD_ITALIC;
} else if (boldUntilDepth < depth) {
return EpdFontFamily::BOLD;
} else if (italicUntilDepth < depth) {
return EpdFontFamily::ITALIC;
}
return EpdFontFamily::REGULAR;
}
// flush the contents of partWordBuffer to currentTextBlock
void ChapterHtmlSlimParser::flushPartWordBuffer() {
EpdFontFamily::Style fontStyle = getCurrentFontStyle();
// flush the buffer
partWordBuffer[partWordBufferIndex] = '\0';
currentTextBlock->addWord(std::move(replaceHtmlEntities(partWordBuffer)), fontStyle);
partWordBufferIndex = 0;
}
// start a new text block if needed
void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) {
if (currentTextBlock) {
@ -288,23 +308,12 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
if (self->anchorDepth != -1 && !self->insideNoteref) {
Serial.printf("[%lu] [NOTEREF] Found <sup> inside <a>, promoting to noteref\n", millis());
// 1. Flush the current word buffer (text before the sup is normal text)
// Flush the current word buffer (text before the sup is normal text)
if (self->partWordBufferIndex > 0) {
// Copy of the existing flush logic
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth)
fontStyle = EpdFontFamily::BOLD_ITALIC;
else if (self->boldUntilDepth < self->depth)
fontStyle = EpdFontFamily::BOLD;
else if (self->italicUntilDepth < self->depth)
fontStyle = EpdFontFamily::ITALIC;
self->partWordBuffer[self->partWordBufferIndex] = '\0';
self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle);
self->partWordBufferIndex = 0;
self->flushPartWordBuffer();
}
// 2. Activate footnote mode
// Activate footnote mode
self->insideNoteref = true;
self->currentNoterefTextLen = 0;
self->currentNoterefText[0] = '\0';
@ -346,23 +355,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
if (isNoteref) {
// ... (Rest of original isNoteref logic) ...
Serial.printf("[%lu] [NOTEREF] Found noteref: href=%s\n", millis(), href ? href : "null");
// Flush word buffer
if (self->partWordBufferIndex > 0) {
// ... (flush logic) ...
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
// ... calculate style ...
self->partWordBuffer[self->partWordBufferIndex] = '\0';
self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle);
self->partWordBufferIndex = 0;
self->flushPartWordBuffer();
}
self->insideNoteref = true;
self->currentNoterefTextLen = 0;
self->currentNoterefText[0] = '\0';
self->depth += 1;
return;
}
@ -412,7 +412,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "alt") == 0) {
alt = "[Image: " + std::string(atts[i + 1]) + "]";
// add " " (counts as whitespace) at the end of alt
// so the corresponding text block ends.
// TODO: A zero-width breaking space would be more appropriate (once/if we support it)
alt = "[Image: " + std::string(atts[i + 1]) + "] ";
}
}
Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str());
@ -421,7 +424,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
self->depth += 1;
self->characterData(userData, alt.c_str(), alt.length());
return;
} else {
// Skip for now
self->skipUntilDepth = self->depth;
@ -453,6 +456,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
if (strcmp(name, "br") == 0) {
if (self->partWordBufferIndex > 0) {
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
self->flushPartWordBuffer();
}
self->startNewTextBlock(self->currentTextBlock->getStyle());
} else {
self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment);
@ -550,30 +557,17 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
return;
}
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) {
fontStyle = EpdFontFamily::BOLD_ITALIC;
} else if (self->boldUntilDepth < self->depth) {
fontStyle = EpdFontFamily::BOLD;
} else if (self->italicUntilDepth < self->depth) {
fontStyle = EpdFontFamily::ITALIC;
}
for (int i = 0; i < len; i++) {
if (isWhitespace(s[i])) {
if (self->partWordBufferIndex > 0) {
self->partWordBuffer[self->partWordBufferIndex] = '\0';
self->currentTextBlock->addWord(self->partWordBuffer, fontStyle);
self->partWordBufferIndex = 0;
self->flushPartWordBuffer();
}
continue;
}
// 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';
self->currentTextBlock->addWord(self->partWordBuffer, fontStyle);
self->partWordBufferIndex = 0;
self->flushPartWordBuffer();
}
// Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF
@ -590,9 +584,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
}
}
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
self->partWordBuffer[self->partWordBufferIndex] = '\0';
self->currentTextBlock->addWord(self->partWordBuffer, fontStyle);
self->partWordBufferIndex = 0;
self->flushPartWordBuffer();
}
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
@ -719,14 +711,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
}
// Ensure [1] appears inline after the word it references
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) {
fontStyle = EpdFontFamily::BOLD_ITALIC;
} else if (self->boldUntilDepth < self->depth) {
fontStyle = EpdFontFamily::BOLD;
} else if (self->italicUntilDepth < self->depth) {
fontStyle = EpdFontFamily::ITALIC;
}
EpdFontFamily::Style fontStyle = self->getCurrentFontStyle();
// Format the noteref text with brackets
char formattedNoteref[32];
@ -764,18 +749,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || self->depth == 1;
if (shouldBreakText) {
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) {
fontStyle = EpdFontFamily::BOLD_ITALIC;
} else if (self->boldUntilDepth < self->depth) {
fontStyle = EpdFontFamily::BOLD;
} else if (self->italicUntilDepth < self->depth) {
fontStyle = EpdFontFamily::ITALIC;
}
self->partWordBuffer[self->partWordBufferIndex] = '\0';
self->currentTextBlock->addWord(self->partWordBuffer, fontStyle);
self->partWordBufferIndex = 0;
self->flushPartWordBuffer();
}
}

View File

@ -108,6 +108,8 @@ class ChapterHtmlSlimParser {
void addFootnoteToCurrentPage(const char* number, const char* href);
void startNewTextBlock(TextBlock::Style style);
EpdFontFamily::Style getCurrentFontStyle() const;
void flushPartWordBuffer();
void makePages();
// XML callbacks

View File

@ -145,10 +145,25 @@ void GfxRenderer::fillRect(const int x, const int y, const int width, const int
}
void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
// TODO: Rotate bits
int rotatedX = 0;
int rotatedY = 0;
rotateCoordinates(x, y, &rotatedX, &rotatedY);
// Rotate origin corner
switch (orientation) {
case Portrait:
rotatedY = rotatedY - height;
break;
case PortraitInverted:
rotatedX = rotatedX - width;
break;
case LandscapeClockwise:
rotatedY = rotatedY - height;
rotatedX = rotatedX - width;
break;
case LandscapeCounterClockwise:
break;
}
// TODO: Rotate bits
einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height);
}

View File

@ -200,7 +200,7 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un
// Internal implementation with configurable target size and bit depth
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit) {
bool oneBit, bool crop) {
Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit",
targetWidth, targetHeight);
@ -242,8 +242,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
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;
float scale = 1.0;
if (crop) { // if we will crop, scale to the smaller dimension
scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
} else { // else, scale to the larger dimension to fit
scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
}
outWidth = static_cast<int>(imageInfo.m_width * scale);
outHeight = static_cast<int>(imageInfo.m_height * scale);
@ -550,8 +554,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
}
// 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);
bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut, bool crop) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop);
}
// Convert with custom target size (for thumbnails, 2-bit)

View File

@ -8,10 +8,10 @@ class JpegToBmpConverter {
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);
bool oneBit, bool crop = true);
public:
static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut);
static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut, bool crop = true);
// 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

View File

@ -33,10 +33,10 @@ std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePat
size_t KOReaderDocumentId::getOffset(int i) {
// Offset = 1024 << (2*i)
// For i = -1: 1024 >> 2 = 256
// For i = -1: KOReader uses a value of 0
// For i >= 0: 1024 << (2*i)
if (i < 0) {
return CHUNK_SIZE >> (-2 * i);
return 0;
}
return CHUNK_SIZE << (2 * i);
}

View File

@ -7,7 +7,6 @@
#include "Xtc.h"
#include <FsHelpers.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
@ -87,6 +86,15 @@ std::string Xtc::getTitle() const {
return filepath.substr(lastSlash, lastDot - lastSlash);
}
std::string Xtc::getAuthor() const {
if (!loaded || !parser) {
return "";
}
// Try to get author from XTC metadata
return parser->getAuthor();
}
bool Xtc::hasChapters() const {
if (!loaded || !parser) {
return false;

View File

@ -56,6 +56,7 @@ class Xtc {
// Metadata
std::string getTitle() const;
std::string getAuthor() const;
bool hasChapters() const;
const std::vector<xtc::ChapterInfo>& getChapters() const;

View File

@ -47,8 +47,21 @@ XtcError XtcParser::open(const char* filepath) {
return m_lastError;
}
// Read title if available
readTitle();
// Read title & author if available
if (m_header.hasMetadata) {
m_lastError = readTitle();
if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read title: %s\n", millis(), errorToString(m_lastError));
m_file.close();
return m_lastError;
}
m_lastError = readAuthor();
if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read author: %s\n", millis(), errorToString(m_lastError));
m_file.close();
return m_lastError;
}
}
// Read page table
m_lastError = readPageTable();
@ -124,24 +137,34 @@ XtcError XtcParser::readHeader() {
}
XtcError XtcParser::readTitle() {
// Title is usually at offset 0x38 (56) for 88-byte headers
// Read title as null-terminated UTF-8 string
if (m_header.titleOffset == 0) {
m_header.titleOffset = 0x38; // Default offset
}
if (!m_file.seek(m_header.titleOffset)) {
constexpr auto titleOffset = 0x38;
if (!m_file.seek(titleOffset)) {
return XtcError::READ_ERROR;
}
char titleBuf[128] = {0};
m_file.read(reinterpret_cast<uint8_t*>(titleBuf), sizeof(titleBuf) - 1);
m_file.read(titleBuf, sizeof(titleBuf) - 1);
m_title = titleBuf;
Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str());
return XtcError::OK;
}
XtcError XtcParser::readAuthor() {
// Read author as null-terminated UTF-8 string with max length 64, directly following title
constexpr auto authorOffset = 0xB8;
if (!m_file.seek(authorOffset)) {
return XtcError::READ_ERROR;
}
char authorBuf[64] = {0};
m_file.read(authorBuf, sizeof(authorBuf) - 1);
m_author = authorBuf;
Serial.printf("[%lu] [XTC] Author: %s\n", millis(), m_author.c_str());
return XtcError::OK;
}
XtcError XtcParser::readPageTable() {
if (m_header.pageTableOffset == 0) {
Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis());

View File

@ -67,8 +67,9 @@ class XtcParser {
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
size_t chunkSize = 1024);
// Get title from metadata
// Get title/author from metadata
std::string getTitle() const { return m_title; }
std::string getAuthor() const { return m_author; }
bool hasChapters() const { return m_hasChapters; }
const std::vector<ChapterInfo>& getChapters() const { return m_chapters; }
@ -86,6 +87,7 @@ class XtcParser {
std::vector<PageInfo> m_pageTable;
std::vector<ChapterInfo> m_chapters;
std::string m_title;
std::string m_author;
uint16_t m_defaultWidth;
uint16_t m_defaultHeight;
uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit)
@ -96,6 +98,7 @@ class XtcParser {
XtcError readHeader();
XtcError readPageTable();
XtcError readTitle();
XtcError readAuthor();
XtcError readChapters();
};

View File

@ -38,14 +38,16 @@ struct XtcHeader {
uint8_t versionMajor; // 0x04: Format version major (typically 1) (together with minor = 1.0)
uint8_t versionMinor; // 0x05: Format version minor (typically 0)
uint16_t pageCount; // 0x06: Total page count
uint32_t flags; // 0x08: Flags/reserved
uint32_t headerSize; // 0x0C: Size of header section (typically 88)
uint32_t reserved1; // 0x10: Reserved
uint32_t tocOffset; // 0x14: TOC offset (0 if unused) - 4 bytes, not 8!
uint8_t readDirection; // 0x08: Reading direction (0-2)
uint8_t hasMetadata; // 0x09: Has metadata (0-1)
uint8_t hasThumbnails; // 0x0A: Has thumbnails (0-1)
uint8_t hasChapters; // 0x0B: Has chapters (0-1)
uint32_t currentPage; // 0x0C: Current page (1-based) (0-65535)
uint64_t metadataOffset; // 0x10: Metadata offset (0 if unused)
uint64_t pageTableOffset; // 0x18: Page table offset
uint64_t dataOffset; // 0x20: First page data offset
uint64_t reserved2; // 0x28: Reserved
uint32_t titleOffset; // 0x30: Title string offset
uint64_t thumbOffset; // 0x28: Thumbnail offset
uint32_t chapterOffset; // 0x30: Chapter data offset
uint32_t padding; // 0x34: Padding to 56 bytes
};
#pragma pack(pop)

View File

@ -11,10 +11,18 @@
// Initialize the static instance
CrossPointSettings CrossPointSettings::instance;
void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
uint8_t tempValue;
serialization::readPod(file, tempValue);
if (tempValue < maxValue) {
member = tempValue;
}
}
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 20;
constexpr uint8_t SETTINGS_COUNT = 22;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace
@ -49,6 +57,9 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, hideBatteryPercentage);
serialization::writePod(outputFile, longPressChapterSkip);
serialization::writePod(outputFile, hyphenationEnabled);
// New fields added at end for backward compatibility
serialization::writeString(outputFile, std::string(opdsUsername));
serialization::writeString(outputFile, std::string(opdsPassword));
outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@ -75,35 +86,35 @@ bool CrossPointSettings::loadFromFile() {
// load settings that exist (support older files with fewer fields)
uint8_t settingsRead = 0;
do {
serialization::readPod(inputFile, sleepScreen);
readAndValidate(inputFile, sleepScreen, SLEEP_SCREEN_MODE_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, extraParagraphSpacing);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, shortPwrBtn);
readAndValidate(inputFile, shortPwrBtn, SHORT_PWRBTN_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, statusBar);
readAndValidate(inputFile, statusBar, STATUS_BAR_MODE_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, orientation);
readAndValidate(inputFile, orientation, ORIENTATION_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, frontButtonLayout);
readAndValidate(inputFile, frontButtonLayout, FRONT_BUTTON_LAYOUT_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, sideButtonLayout);
readAndValidate(inputFile, sideButtonLayout, SIDE_BUTTON_LAYOUT_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, fontFamily);
readAndValidate(inputFile, fontFamily, FONT_FAMILY_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, fontSize);
readAndValidate(inputFile, fontSize, FONT_SIZE_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, lineSpacing);
readAndValidate(inputFile, lineSpacing, LINE_COMPRESSION_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, paragraphAlignment);
readAndValidate(inputFile, paragraphAlignment, PARAGRAPH_ALIGNMENT_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, sleepTimeout);
readAndValidate(inputFile, sleepTimeout, SLEEP_TIMEOUT_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, refreshFrequency);
readAndValidate(inputFile, refreshFrequency, REFRESH_FREQUENCY_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, screenMargin);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, sleepScreenCoverMode);
readAndValidate(inputFile, sleepScreenCoverMode, SLEEP_SCREEN_COVER_MODE_COUNT);
if (++settingsRead >= fileSettingsCount) break;
{
std::string urlStr;
@ -114,12 +125,27 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, textAntiAliasing);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, hideBatteryPercentage);
readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, longPressChapterSkip);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, hyphenationEnabled);
if (++settingsRead >= fileSettingsCount) break;
// New fields added at end for backward compatibility
{
std::string usernameStr;
serialization::readString(inputFile, usernameStr);
strncpy(opdsUsername, usernameStr.c_str(), sizeof(opdsUsername) - 1);
opdsUsername[sizeof(opdsUsername) - 1] = '\0';
}
if (++settingsRead >= fileSettingsCount) break;
{
std::string passwordStr;
serialization::readString(inputFile, passwordStr);
strncpy(opdsPassword, passwordStr.c_str(), sizeof(opdsPassword) - 1);
opdsPassword[sizeof(opdsPassword) - 1] = '\0';
}
if (++settingsRead >= fileSettingsCount) break;
} while (false);
inputFile.close();

View File

@ -15,48 +15,81 @@ class CrossPointSettings {
CrossPointSettings(const CrossPointSettings&) = delete;
CrossPointSettings& operator=(const CrossPointSettings&) = delete;
// Should match with SettingsActivity text
enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4 };
enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1 };
enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4, SLEEP_SCREEN_MODE_COUNT };
enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1, SLEEP_SCREEN_COVER_MODE_COUNT };
// Status bar display type enum
enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 };
enum STATUS_BAR_MODE {
NONE = 0,
NO_PROGRESS = 1,
FULL = 2,
FULL_WITH_PROGRESS_BAR = 3,
ONLY_PROGRESS_BAR = 4,
STATUS_BAR_MODE_COUNT
};
enum ORIENTATION {
PORTRAIT = 0, // 480x800 logical coordinates (current default)
LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
INVERTED = 2, // 480x800 logical coordinates, inverted
LANDSCAPE_CCW = 3 // 800x480 logical coordinates, native panel orientation
LANDSCAPE_CCW = 3, // 800x480 logical coordinates, native panel orientation
ORIENTATION_COUNT
};
// Front button layout options
// Default: Back, Confirm, Left, Right
// Swapped: Left, Right, Back, Confirm
enum FRONT_BUTTON_LAYOUT { BACK_CONFIRM_LEFT_RIGHT = 0, LEFT_RIGHT_BACK_CONFIRM = 1, LEFT_BACK_CONFIRM_RIGHT = 2 };
enum FRONT_BUTTON_LAYOUT {
BACK_CONFIRM_LEFT_RIGHT = 0,
LEFT_RIGHT_BACK_CONFIRM = 1,
LEFT_BACK_CONFIRM_RIGHT = 2,
BACK_CONFIRM_RIGHT_LEFT = 3,
FRONT_BUTTON_LAYOUT_COUNT
};
// Side button layout options
// Default: Previous, Next
// Swapped: Next, Previous
enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1 };
enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1, SIDE_BUTTON_LAYOUT_COUNT };
// Font family options
enum FONT_FAMILY { BOOKERLY = 0, NOTOSANS = 1, OPENDYSLEXIC = 2 };
enum FONT_FAMILY { BOOKERLY = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, FONT_FAMILY_COUNT };
// Font size options
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 };
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 };
enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3 };
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3, FONT_SIZE_COUNT };
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2, LINE_COMPRESSION_COUNT };
enum PARAGRAPH_ALIGNMENT {
JUSTIFIED = 0,
LEFT_ALIGN = 1,
CENTER_ALIGN = 2,
RIGHT_ALIGN = 3,
PARAGRAPH_ALIGNMENT_COUNT
};
// Auto-sleep timeout options (in minutes)
enum SLEEP_TIMEOUT { SLEEP_1_MIN = 0, SLEEP_5_MIN = 1, SLEEP_10_MIN = 2, SLEEP_15_MIN = 3, SLEEP_30_MIN = 4 };
enum SLEEP_TIMEOUT {
SLEEP_1_MIN = 0,
SLEEP_5_MIN = 1,
SLEEP_10_MIN = 2,
SLEEP_15_MIN = 3,
SLEEP_30_MIN = 4,
SLEEP_TIMEOUT_COUNT
};
// E-ink refresh frequency (pages between full refreshes)
enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 };
enum REFRESH_FREQUENCY {
REFRESH_1 = 0,
REFRESH_5 = 1,
REFRESH_10 = 2,
REFRESH_15 = 3,
REFRESH_30 = 4,
REFRESH_FREQUENCY_COUNT
};
// Short power button press actions
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2 };
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, SHORT_PWRBTN_COUNT };
// Hide battery percentage
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2 };
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT };
// Sleep screen settings
uint8_t sleepScreen = DARK;
@ -90,6 +123,8 @@ class CrossPointSettings {
uint8_t screenMargin = 5;
// OPDS browser settings
char opdsServerUrl[128] = "";
char opdsUsername[64] = "";
char opdsPassword[64] = "";
// Hide battery percentage
uint8_t hideBatteryPercentage = HIDE_NEVER;
// Long-press chapter skip on side buttons

View File

@ -2,81 +2,73 @@
#include "CrossPointSettings.h"
decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button button) const {
namespace {
using ButtonIndex = uint8_t;
struct FrontLayoutMap {
ButtonIndex back;
ButtonIndex confirm;
ButtonIndex left;
ButtonIndex right;
};
struct SideLayoutMap {
ButtonIndex pageBack;
ButtonIndex pageForward;
};
// Order matches CrossPointSettings::FRONT_BUTTON_LAYOUT.
constexpr FrontLayoutMap kFrontLayouts[] = {
{InputManager::BTN_BACK, InputManager::BTN_CONFIRM, InputManager::BTN_LEFT, InputManager::BTN_RIGHT},
{InputManager::BTN_LEFT, InputManager::BTN_RIGHT, InputManager::BTN_BACK, InputManager::BTN_CONFIRM},
{InputManager::BTN_CONFIRM, InputManager::BTN_LEFT, InputManager::BTN_BACK, InputManager::BTN_RIGHT},
{InputManager::BTN_BACK, InputManager::BTN_CONFIRM, InputManager::BTN_RIGHT, InputManager::BTN_LEFT},
};
// Order matches CrossPointSettings::SIDE_BUTTON_LAYOUT.
constexpr SideLayoutMap kSideLayouts[] = {
{InputManager::BTN_UP, InputManager::BTN_DOWN},
{InputManager::BTN_DOWN, InputManager::BTN_UP},
};
} // namespace
bool MappedInputManager::mapButton(const Button button, bool (InputManager::*fn)(uint8_t) const) const {
const auto frontLayout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
const auto sideLayout = static_cast<CrossPointSettings::SIDE_BUTTON_LAYOUT>(SETTINGS.sideButtonLayout);
const auto& front = kFrontLayouts[frontLayout];
const auto& side = kSideLayouts[sideLayout];
switch (button) {
case Button::Back:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_LEFT;
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_CONFIRM;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_BACK;
}
return (inputManager.*fn)(front.back);
case Button::Confirm:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_RIGHT;
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_LEFT;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_CONFIRM;
}
return (inputManager.*fn)(front.confirm);
case Button::Left:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_BACK;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_LEFT;
}
return (inputManager.*fn)(front.left);
case Button::Right:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_CONFIRM;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
default:
return InputManager::BTN_RIGHT;
}
return (inputManager.*fn)(front.right);
case Button::Up:
return InputManager::BTN_UP;
return (inputManager.*fn)(InputManager::BTN_UP);
case Button::Down:
return InputManager::BTN_DOWN;
return (inputManager.*fn)(InputManager::BTN_DOWN);
case Button::Power:
return InputManager::BTN_POWER;
return (inputManager.*fn)(InputManager::BTN_POWER);
case Button::PageBack:
switch (sideLayout) {
case CrossPointSettings::NEXT_PREV:
return InputManager::BTN_DOWN;
case CrossPointSettings::PREV_NEXT:
default:
return InputManager::BTN_UP;
}
return (inputManager.*fn)(side.pageBack);
case Button::PageForward:
switch (sideLayout) {
case CrossPointSettings::NEXT_PREV:
return InputManager::BTN_UP;
case CrossPointSettings::PREV_NEXT:
default:
return InputManager::BTN_DOWN;
}
return (inputManager.*fn)(side.pageForward);
}
return InputManager::BTN_BACK;
return false;
}
bool MappedInputManager::wasPressed(const Button button) const { return inputManager.wasPressed(mapButton(button)); }
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &InputManager::wasPressed); }
bool MappedInputManager::wasReleased(const Button button) const { return inputManager.wasReleased(mapButton(button)); }
bool MappedInputManager::wasReleased(const Button button) const {
return mapButton(button, &InputManager::wasReleased);
}
bool MappedInputManager::isPressed(const Button button) const { return inputManager.isPressed(mapButton(button)); }
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &InputManager::isPressed); }
bool MappedInputManager::wasAnyPressed() const { return inputManager.wasAnyPressed(); }

View File

@ -25,5 +25,6 @@ class MappedInputManager {
private:
InputManager& inputManager;
decltype(InputManager::BTN_BACK) mapButton(Button button) const;
bool mapButton(Button button, bool (InputManager::*fn)(uint8_t) const) const;
};

View File

@ -42,6 +42,17 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left,
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
}
void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {
int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft;
renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom,
&vieweableMarginLeft);
const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight;
const int progressBarY = renderer.getScreenHeight() - vieweableMarginBottom - BOOK_PROGRESS_BAR_HEIGHT;
const int barWidth = progressBarMaxWidth * bookProgress / 100;
renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BOOK_PROGRESS_BAR_HEIGHT, true);
}
int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs) {
constexpr int tabPadding = 20; // Horizontal padding between tabs
constexpr int leftMargin = 20; // Left margin for first tab

View File

@ -13,7 +13,10 @@ struct TabInfo {
class ScreenComponents {
public:
static const int BOOK_PROGRESS_BAR_HEIGHT = 4;
static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true);
static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress);
// Draw a horizontal tab bar with underline indicator for selected tab
// Returns the height of the tab bar (for positioning content below)

View File

@ -12,7 +12,7 @@ void BootActivity::onEnter() {
const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "BOOTING");
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION);

View File

@ -124,7 +124,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
@ -260,6 +260,7 @@ void SleepActivity::renderCoverSleepScreen() const {
if (SdMan.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[SLP] Rendering sleep cover: %s\n", coverBmpPath);
renderBitmapSleepScreen(bitmap);
return;
}

View File

@ -18,7 +18,6 @@
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL
} // namespace
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
@ -33,7 +32,7 @@ void OpdsBookBrowserActivity::onEnter() {
state = BrowserState::CHECK_WIFI;
entries.clear();
navigationHistory.clear();
currentPath = OPDS_ROOT_PATH;
currentPath = ""; // Root path - user provides full URL in settings
selectorIndex = 0;
errorMessage.clear();
statusMessage = "Checking WiFi...";
@ -172,7 +171,7 @@ void OpdsBookBrowserActivity::render() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre Library", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD);
if (state == BrowserState::CHECK_WIFI) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());

View File

@ -71,6 +71,9 @@ void HomeActivity::onEnter() {
if (!xtc.getTitle().empty()) {
lastBookTitle = std::string(xtc.getTitle());
}
if (!xtc.getAuthor().empty()) {
lastBookAuthor = std::string(xtc.getAuthor());
}
// Try to generate thumbnail image for Continue Reading card
if (xtc.generateThumbBmp()) {
coverBmpPath = xtc.getThumbBmpPath();
@ -502,8 +505,8 @@ void HomeActivity::render() {
// Build menu items dynamically
std::vector<const char*> menuItems = {"My Library", "File Transfer", "Settings"};
if (hasOpdsUrl) {
// Insert Calibre Library after My Library
menuItems.insert(menuItems.begin() + 1, "Calibre Library");
// Insert OPDS Browser after My Library
menuItems.insert(menuItems.begin() + 1, "OPDS Browser");
}
const int menuTileWidth = pageWidth - 2 * margin;

View File

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

View File

@ -0,0 +1,276 @@
#include "CalibreConnectActivity.h"
#include <ESPmDNS.h>
#include <GfxRenderer.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "WifiSelectionActivity.h"
#include "fontIds.h"
namespace {
constexpr const char* HOSTNAME = "crosspoint";
} // namespace
void CalibreConnectActivity::taskTrampoline(void* param) {
auto* self = static_cast<CalibreConnectActivity*>(param);
self->displayTaskLoop();
}
void CalibreConnectActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
updateRequired = true;
state = CalibreConnectState::WIFI_SELECTION;
connectedIP.clear();
connectedSSID.clear();
lastHandleClientTime = 0;
lastProgressReceived = 0;
lastProgressTotal = 0;
currentUploadName.clear();
lastCompleteName.clear();
lastCompleteAt = 0;
exitRequested = false;
xTaskCreate(&CalibreConnectActivity::taskTrampoline, "CalibreConnectTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
if (WiFi.status() != WL_CONNECTED) {
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
} else {
connectedIP = WiFi.localIP().toString().c_str();
connectedSSID = WiFi.SSID().c_str();
startWebServer();
}
}
void CalibreConnectActivity::onExit() {
ActivityWithSubactivity::onExit();
stopWebServer();
MDNS.end();
delay(50);
WiFi.disconnect(false);
delay(30);
WiFi.mode(WIFI_OFF);
delay(30);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void CalibreConnectActivity::onWifiSelectionComplete(const bool connected) {
if (!connected) {
exitActivity();
onComplete();
return;
}
if (subActivity) {
connectedIP = static_cast<WifiSelectionActivity*>(subActivity.get())->getConnectedIP();
} else {
connectedIP = WiFi.localIP().toString().c_str();
}
connectedSSID = WiFi.SSID().c_str();
exitActivity();
startWebServer();
}
void CalibreConnectActivity::startWebServer() {
state = CalibreConnectState::SERVER_STARTING;
updateRequired = true;
if (MDNS.begin(HOSTNAME)) {
// mDNS is optional for the Calibre plugin but still helpful for users.
Serial.printf("[%lu] [CAL] mDNS started: http://%s.local/\n", millis(), HOSTNAME);
}
webServer.reset(new CrossPointWebServer());
webServer->begin();
if (webServer->isRunning()) {
state = CalibreConnectState::SERVER_RUNNING;
updateRequired = true;
} else {
state = CalibreConnectState::ERROR;
updateRequired = true;
}
}
void CalibreConnectActivity::stopWebServer() {
if (webServer) {
webServer->stop();
webServer.reset();
}
}
void CalibreConnectActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
exitRequested = true;
}
if (webServer && webServer->isRunning()) {
const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) {
Serial.printf("[%lu] [CAL] WARNING: %lu ms gap since last handleClient\n", millis(), timeSinceLastHandleClient);
}
esp_task_wdt_reset();
constexpr int MAX_ITERATIONS = 80;
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
webServer->handleClient();
if ((i & 0x07) == 0x07) {
esp_task_wdt_reset();
}
if ((i & 0x0F) == 0x0F) {
yield();
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
exitRequested = true;
break;
}
}
}
lastHandleClientTime = millis();
const auto status = webServer->getWsUploadStatus();
bool changed = false;
if (status.inProgress) {
if (status.received != lastProgressReceived || status.total != lastProgressTotal ||
status.filename != currentUploadName) {
lastProgressReceived = status.received;
lastProgressTotal = status.total;
currentUploadName = status.filename;
changed = true;
}
} else if (lastProgressReceived != 0 || lastProgressTotal != 0) {
lastProgressReceived = 0;
lastProgressTotal = 0;
currentUploadName.clear();
changed = true;
}
if (status.lastCompleteAt != 0 && status.lastCompleteAt != lastCompleteAt) {
lastCompleteAt = status.lastCompleteAt;
lastCompleteName = status.lastCompleteName;
changed = true;
}
if (lastCompleteAt > 0 && (millis() - lastCompleteAt) >= 6000) {
lastCompleteAt = 0;
lastCompleteName.clear();
changed = true;
}
if (changed) {
updateRequired = true;
}
}
if (exitRequested) {
onComplete();
return;
}
}
void CalibreConnectActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void CalibreConnectActivity::render() const {
if (state == CalibreConnectState::SERVER_RUNNING) {
renderer.clearScreen();
renderServerRunning();
renderer.displayBuffer();
return;
}
renderer.clearScreen();
const auto pageHeight = renderer.getScreenHeight();
if (state == CalibreConnectState::SERVER_STARTING) {
renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Calibre...", true, EpdFontFamily::BOLD);
} else if (state == CalibreConnectState::ERROR) {
renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Calibre setup failed", true, EpdFontFamily::BOLD);
}
renderer.displayBuffer();
}
void CalibreConnectActivity::renderServerRunning() const {
constexpr int LINE_SPACING = 24;
constexpr int SMALL_SPACING = 20;
constexpr int SECTION_SPACING = 40;
constexpr int TOP_PADDING = 14;
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Connect to Calibre", true, EpdFontFamily::BOLD);
int y = 55 + TOP_PADDING;
renderer.drawCenteredText(UI_10_FONT_ID, y, "Network", true, EpdFontFamily::BOLD);
y += LINE_SPACING;
std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_10_FONT_ID, y, ssidInfo.c_str());
renderer.drawCenteredText(UI_10_FONT_ID, y + LINE_SPACING, ("IP: " + connectedIP).c_str());
y += LINE_SPACING * 2 + SECTION_SPACING;
renderer.drawCenteredText(UI_10_FONT_ID, y, "Setup", true, EpdFontFamily::BOLD);
y += LINE_SPACING;
renderer.drawCenteredText(SMALL_FONT_ID, y, "1) Install CrossPoint Reader plugin");
renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING, "2) Be on the same WiFi network");
renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING * 2, "3) In Calibre: \"Send to device\"");
renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING * 3, "Keep this screen open while sending");
y += SMALL_SPACING * 3 + SECTION_SPACING;
renderer.drawCenteredText(UI_10_FONT_ID, y, "Status", true, EpdFontFamily::BOLD);
y += LINE_SPACING;
if (lastProgressTotal > 0 && lastProgressReceived <= lastProgressTotal) {
std::string label = "Receiving";
if (!currentUploadName.empty()) {
label += ": " + currentUploadName;
if (label.length() > 34) {
label.replace(31, label.length() - 31, "...");
}
}
renderer.drawCenteredText(SMALL_FONT_ID, y, label.c_str());
constexpr int barWidth = 300;
constexpr int barHeight = 16;
constexpr int barX = (480 - barWidth) / 2;
ScreenComponents::drawProgressBar(renderer, barX, y + 22, barWidth, barHeight, lastProgressReceived,
lastProgressTotal);
y += 40;
}
if (lastCompleteAt > 0 && (millis() - lastCompleteAt) < 6000) {
std::string msg = "Received: " + lastCompleteName;
if (msg.length() > 36) {
msg.replace(33, msg.length() - 33, "...");
}
renderer.drawCenteredText(SMALL_FONT_ID, y, msg.c_str());
}
const auto labels = mappedInput.mapLabels("« Exit", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}

View File

@ -0,0 +1,55 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <memory>
#include <string>
#include "activities/ActivityWithSubactivity.h"
#include "network/CrossPointWebServer.h"
enum class CalibreConnectState { WIFI_SELECTION, SERVER_STARTING, SERVER_RUNNING, ERROR };
/**
* CalibreConnectActivity starts the file transfer server in STA mode,
* but renders Calibre-specific instructions instead of the web transfer UI.
*/
class CalibreConnectActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
CalibreConnectState state = CalibreConnectState::WIFI_SELECTION;
const std::function<void()> onComplete;
std::unique_ptr<CrossPointWebServer> webServer;
std::string connectedIP;
std::string connectedSSID;
unsigned long lastHandleClientTime = 0;
size_t lastProgressReceived = 0;
size_t lastProgressTotal = 0;
std::string currentUploadName;
std::string lastCompleteName;
unsigned long lastCompleteAt = 0;
bool exitRequested = false;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
void renderServerRunning() const;
void onWifiSelectionComplete(bool connected);
void startWebServer();
void stopWebServer();
public:
explicit CalibreConnectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onComplete)
: ActivityWithSubactivity("CalibreConnect", renderer, mappedInput), onComplete(onComplete) {}
void onEnter() override;
void onExit() override;
void loop() override;
bool skipLoopDelay() override { return webServer && webServer->isRunning(); }
bool preventAutoSleep() override { return webServer && webServer->isRunning(); }
};

View File

@ -1,756 +0,0 @@
#include "CalibreWirelessActivity.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <WiFi.h>
#include <cstring>
#include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "fontIds.h"
#include "util/StringUtils.h"
namespace {
constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678};
constexpr uint16_t LOCAL_UDP_PORT = 8134; // Port to receive responses
} // namespace
void CalibreWirelessActivity::displayTaskTrampoline(void* param) {
auto* self = static_cast<CalibreWirelessActivity*>(param);
self->displayTaskLoop();
}
void CalibreWirelessActivity::networkTaskTrampoline(void* param) {
auto* self = static_cast<CalibreWirelessActivity*>(param);
self->networkTaskLoop();
}
void CalibreWirelessActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
stateMutex = xSemaphoreCreateMutex();
state = WirelessState::DISCOVERING;
statusMessage = "Discovering Calibre...";
errorMessage.clear();
calibreHostname.clear();
calibreHost.clear();
calibrePort = 0;
calibreAltPort = 0;
currentFilename.clear();
currentFileSize = 0;
bytesReceived = 0;
inBinaryMode = false;
recvBuffer.clear();
updateRequired = true;
// Start UDP listener for Calibre responses
udp.begin(LOCAL_UDP_PORT);
// Create display task
xTaskCreate(&CalibreWirelessActivity::displayTaskTrampoline, "CalDisplayTask", 2048, this, 1, &displayTaskHandle);
// Create network task with larger stack for JSON parsing
xTaskCreate(&CalibreWirelessActivity::networkTaskTrampoline, "CalNetworkTask", 12288, this, 2, &networkTaskHandle);
}
void CalibreWirelessActivity::onExit() {
Activity::onExit();
// Turn off WiFi when exiting
WiFi.mode(WIFI_OFF);
// Stop UDP listening
udp.stop();
// Close TCP client if connected
if (tcpClient.connected()) {
tcpClient.stop();
}
// Close any open file
if (currentFile) {
currentFile.close();
}
// Acquire stateMutex before deleting network task to avoid race condition
xSemaphoreTake(stateMutex, portMAX_DELAY);
if (networkTaskHandle) {
vTaskDelete(networkTaskHandle);
networkTaskHandle = nullptr;
}
xSemaphoreGive(stateMutex);
// Acquire renderingMutex before deleting display task
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
vSemaphoreDelete(stateMutex);
stateMutex = nullptr;
}
void CalibreWirelessActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onComplete();
return;
}
}
void CalibreWirelessActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(50 / portTICK_PERIOD_MS);
}
}
void CalibreWirelessActivity::networkTaskLoop() {
while (true) {
xSemaphoreTake(stateMutex, portMAX_DELAY);
const auto currentState = state;
xSemaphoreGive(stateMutex);
switch (currentState) {
case WirelessState::DISCOVERING:
listenForDiscovery();
break;
case WirelessState::CONNECTING:
case WirelessState::WAITING:
case WirelessState::RECEIVING:
handleTcpClient();
break;
case WirelessState::COMPLETE:
case WirelessState::DISCONNECTED:
case WirelessState::ERROR:
// Just wait, user will exit
vTaskDelay(100 / portTICK_PERIOD_MS);
break;
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void CalibreWirelessActivity::listenForDiscovery() {
// Broadcast "hello" on all UDP discovery ports to find Calibre
for (const uint16_t port : UDP_PORTS) {
udp.beginPacket("255.255.255.255", port);
udp.write(reinterpret_cast<const uint8_t*>("hello"), 5);
udp.endPacket();
}
// Wait for Calibre's response
vTaskDelay(500 / portTICK_PERIOD_MS);
// Check for response
const int packetSize = udp.parsePacket();
if (packetSize > 0) {
char buffer[256];
const int len = udp.read(buffer, sizeof(buffer) - 1);
if (len > 0) {
buffer[len] = '\0';
// Parse Calibre's response format:
// "calibre wireless device client (on hostname);port,content_server_port"
// or just the hostname and port info
std::string response(buffer);
// Try to extract host and port
// Format: "calibre wireless device client (on HOSTNAME);PORT,..."
size_t onPos = response.find("(on ");
size_t closePos = response.find(')');
size_t semiPos = response.find(';');
size_t commaPos = response.find(',', semiPos);
if (semiPos != std::string::npos) {
// Get ports after semicolon (format: "port1,port2")
std::string portStr;
if (commaPos != std::string::npos && commaPos > semiPos) {
portStr = response.substr(semiPos + 1, commaPos - semiPos - 1);
// Get alternative port after comma
std::string altPortStr = response.substr(commaPos + 1);
// Trim whitespace and non-digits from alt port
size_t altEnd = 0;
while (altEnd < altPortStr.size() && altPortStr[altEnd] >= '0' && altPortStr[altEnd] <= '9') {
altEnd++;
}
if (altEnd > 0) {
calibreAltPort = static_cast<uint16_t>(std::stoi(altPortStr.substr(0, altEnd)));
}
} else {
portStr = response.substr(semiPos + 1);
}
// Trim whitespace from main port
while (!portStr.empty() && (portStr[0] == ' ' || portStr[0] == '\t')) {
portStr = portStr.substr(1);
}
if (!portStr.empty()) {
calibrePort = static_cast<uint16_t>(std::stoi(portStr));
}
// Get hostname if present, otherwise use sender IP
if (onPos != std::string::npos && closePos != std::string::npos && closePos > onPos + 4) {
calibreHostname = response.substr(onPos + 4, closePos - onPos - 4);
}
}
// Use the sender's IP as the host to connect to
calibreHost = udp.remoteIP().toString().c_str();
if (calibreHostname.empty()) {
calibreHostname = calibreHost;
}
if (calibrePort > 0) {
// Connect to Calibre's TCP server - try main port first, then alt port
setState(WirelessState::CONNECTING);
setStatus("Connecting to " + calibreHostname + "...");
// Small delay before connecting
vTaskDelay(100 / portTICK_PERIOD_MS);
bool connected = false;
// Try main port first
if (tcpClient.connect(calibreHost.c_str(), calibrePort, 5000)) {
connected = true;
}
// Try alternative port if main failed
if (!connected && calibreAltPort > 0) {
vTaskDelay(200 / portTICK_PERIOD_MS);
if (tcpClient.connect(calibreHost.c_str(), calibreAltPort, 5000)) {
connected = true;
}
}
if (connected) {
setState(WirelessState::WAITING);
setStatus("Connected to " + calibreHostname + "\nWaiting for commands...");
} else {
// Don't set error yet, keep trying discovery
setState(WirelessState::DISCOVERING);
setStatus("Discovering Calibre...\n(Connection failed, retrying)");
calibrePort = 0;
calibreAltPort = 0;
}
}
}
}
}
void CalibreWirelessActivity::handleTcpClient() {
if (!tcpClient.connected()) {
setState(WirelessState::DISCONNECTED);
setStatus("Calibre disconnected");
return;
}
if (inBinaryMode) {
receiveBinaryData();
return;
}
std::string message;
if (readJsonMessage(message)) {
// Parse opcode from JSON array format: [opcode, {...}]
// Find the opcode (first number after '[')
size_t start = message.find('[');
if (start != std::string::npos) {
start++;
size_t end = message.find(',', start);
if (end != std::string::npos) {
const int opcodeInt = std::stoi(message.substr(start, end - start));
if (opcodeInt < 0 || opcodeInt >= OpCode::ERROR) {
Serial.printf("[%lu] [CAL] Invalid opcode: %d\n", millis(), opcodeInt);
sendJsonResponse(OpCode::OK, "{}");
return;
}
const auto opcode = static_cast<OpCode>(opcodeInt);
// Extract data object (everything after the comma until the last ']')
size_t dataStart = end + 1;
size_t dataEnd = message.rfind(']');
std::string data = "";
if (dataEnd != std::string::npos && dataEnd > dataStart) {
data = message.substr(dataStart, dataEnd - dataStart);
}
handleCommand(opcode, data);
}
}
}
}
bool CalibreWirelessActivity::readJsonMessage(std::string& message) {
// Read available data into buffer
int available = tcpClient.available();
if (available > 0) {
// Limit buffer growth to prevent memory issues
if (recvBuffer.size() > 100000) {
recvBuffer.clear();
return false;
}
// Read in chunks
char buf[1024];
while (available > 0) {
int toRead = std::min(available, static_cast<int>(sizeof(buf)));
int bytesRead = tcpClient.read(reinterpret_cast<uint8_t*>(buf), toRead);
if (bytesRead > 0) {
recvBuffer.append(buf, bytesRead);
available -= bytesRead;
} else {
break;
}
}
}
if (recvBuffer.empty()) {
return false;
}
// Find '[' which marks the start of JSON
size_t bracketPos = recvBuffer.find('[');
if (bracketPos == std::string::npos) {
// No '[' found - if buffer is getting large, something is wrong
if (recvBuffer.size() > 1000) {
recvBuffer.clear();
}
return false;
}
// Try to extract length from digits before '['
// Calibre ALWAYS sends a length prefix, so if it's not valid digits, it's garbage
size_t msgLen = 0;
bool validPrefix = false;
if (bracketPos > 0 && bracketPos <= 12) {
// Check if prefix is all digits
bool allDigits = true;
for (size_t i = 0; i < bracketPos; i++) {
char c = recvBuffer[i];
if (c < '0' || c > '9') {
allDigits = false;
break;
}
}
if (allDigits) {
msgLen = std::stoul(recvBuffer.substr(0, bracketPos));
validPrefix = true;
}
}
if (!validPrefix) {
// Not a valid length prefix - discard everything up to '[' and treat '[' as start
if (bracketPos > 0) {
recvBuffer = recvBuffer.substr(bracketPos);
}
// Without length prefix, we can't reliably parse - wait for more data
// that hopefully starts with a proper length prefix
return false;
}
// Sanity check the message length
if (msgLen > 1000000) {
recvBuffer = recvBuffer.substr(bracketPos + 1); // Skip past this '[' and try again
return false;
}
// Check if we have the complete message
size_t totalNeeded = bracketPos + msgLen;
if (recvBuffer.size() < totalNeeded) {
// Not enough data yet - wait for more
return false;
}
// Extract the message
message = recvBuffer.substr(bracketPos, msgLen);
// Keep the rest in buffer (may contain binary data or next message)
if (recvBuffer.size() > totalNeeded) {
recvBuffer = recvBuffer.substr(totalNeeded);
} else {
recvBuffer.clear();
}
return true;
}
void CalibreWirelessActivity::sendJsonResponse(const OpCode opcode, const std::string& data) {
// Format: length + [opcode, {data}]
std::string json = "[" + std::to_string(opcode) + "," + data + "]";
const std::string lengthPrefix = std::to_string(json.length());
json.insert(0, lengthPrefix);
tcpClient.write(reinterpret_cast<const uint8_t*>(json.c_str()), json.length());
tcpClient.flush();
}
void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::string& data) {
switch (opcode) {
case OpCode::GET_INITIALIZATION_INFO:
handleGetInitializationInfo(data);
break;
case OpCode::GET_DEVICE_INFORMATION:
handleGetDeviceInformation();
break;
case OpCode::FREE_SPACE:
handleFreeSpace();
break;
case OpCode::GET_BOOK_COUNT:
handleGetBookCount();
break;
case OpCode::SEND_BOOK:
handleSendBook(data);
break;
case OpCode::SEND_BOOK_METADATA:
handleSendBookMetadata(data);
break;
case OpCode::DISPLAY_MESSAGE:
handleDisplayMessage(data);
break;
case OpCode::NOOP:
handleNoop(data);
break;
case OpCode::SET_CALIBRE_DEVICE_INFO:
case OpCode::SET_CALIBRE_DEVICE_NAME:
// These set metadata about the connected Calibre instance.
// We don't need this info, just acknowledge receipt.
sendJsonResponse(OpCode::OK, "{}");
break;
case OpCode::SET_LIBRARY_INFO:
// Library metadata (name, UUID) - not needed for receiving books
sendJsonResponse(OpCode::OK, "{}");
break;
case OpCode::SEND_BOOKLISTS:
// Calibre asking us to send our book list. We report 0 books in
// handleGetBookCount, so this is effectively a no-op.
sendJsonResponse(OpCode::OK, "{}");
break;
case OpCode::TOTAL_SPACE:
handleFreeSpace();
break;
default:
Serial.printf("[%lu] [CAL] Unknown opcode: %d\n", millis(), opcode);
sendJsonResponse(OpCode::OK, "{}");
break;
}
}
void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& data) {
setState(WirelessState::WAITING);
setStatus("Connected to " + calibreHostname +
"\nWaiting for transfer...\n\nIf transfer fails, enable\n'Ignore free space' in Calibre's\nSmartDevice "
"plugin settings.");
// Build response with device capabilities
// Format must match what Calibre expects from a smart device
std::string response = "{";
response += "\"appName\":\"CrossPoint\",";
response += "\"acceptedExtensions\":[\"epub\"],";
response += "\"cacheUsesLpaths\":true,";
response += "\"canAcceptLibraryInfo\":true,";
response += "\"canDeleteMultipleBooks\":true,";
response += "\"canReceiveBookBinary\":true,";
response += "\"canSendOkToSendbook\":true,";
response += "\"canStreamBooks\":true,";
response += "\"canStreamMetadata\":true,";
response += "\"canUseCachedMetadata\":true,";
// ccVersionNumber: Calibre Companion protocol version. 212 matches CC 5.4.20+.
// Using a known version ensures compatibility with Calibre's feature detection.
response += "\"ccVersionNumber\":212,";
// coverHeight: Max cover image height. We don't process covers, so this is informational only.
response += "\"coverHeight\":800,";
response += "\"deviceKind\":\"CrossPoint\",";
response += "\"deviceName\":\"CrossPoint\",";
response += "\"extensionPathLengths\":{\"epub\":37},";
response += "\"maxBookContentPacketLen\":4096,";
response += "\"passwordHash\":\"\",";
response += "\"useUuidFileNames\":false,";
response += "\"versionOK\":true";
response += "}";
sendJsonResponse(OpCode::OK, response);
}
void CalibreWirelessActivity::handleGetDeviceInformation() {
std::string response = "{";
response += "\"device_info\":{";
response += "\"device_store_uuid\":\"" + getDeviceUuid() + "\",";
response += "\"device_name\":\"CrossPoint Reader\",";
response += "\"device_version\":\"" CROSSPOINT_VERSION "\"";
response += "},";
response += "\"version\":1,";
response += "\"device_version\":\"" CROSSPOINT_VERSION "\"";
response += "}";
sendJsonResponse(OpCode::OK, response);
}
void CalibreWirelessActivity::handleFreeSpace() {
// TODO: Report actual SD card free space instead of hardcoded value
// Report 10GB free space for now
sendJsonResponse(OpCode::OK, "{\"free_space_on_device\":10737418240}");
}
void CalibreWirelessActivity::handleGetBookCount() {
// We report 0 books - Calibre will send books without checking for duplicates
std::string response = "{\"count\":0,\"willStream\":true,\"willScan\":false}";
sendJsonResponse(OpCode::OK, response);
}
void CalibreWirelessActivity::handleSendBook(const std::string& data) {
// Manually extract lpath and length from SEND_BOOK data
// Full JSON parsing crashes on large metadata, so we just extract what we need
// Extract "lpath" field - format: "lpath": "value"
std::string lpath;
size_t lpathPos = data.find("\"lpath\"");
if (lpathPos != std::string::npos) {
size_t colonPos = data.find(':', lpathPos + 7);
if (colonPos != std::string::npos) {
size_t quoteStart = data.find('"', colonPos + 1);
if (quoteStart != std::string::npos) {
size_t quoteEnd = data.find('"', quoteStart + 1);
if (quoteEnd != std::string::npos) {
lpath = data.substr(quoteStart + 1, quoteEnd - quoteStart - 1);
}
}
}
}
// Extract top-level "length" field - must track depth to skip nested objects
// The metadata contains nested "length" fields (e.g., cover image length)
size_t length = 0;
int depth = 0;
for (size_t i = 0; i < data.size(); i++) {
char c = data[i];
if (c == '{' || c == '[') {
depth++;
} else if (c == '}' || c == ']') {
depth--;
} else if (depth == 1 && c == '"') {
// At top level, check if this is "length"
if (i + 9 < data.size() && data.substr(i, 8) == "\"length\"") {
// Found top-level "length" - extract the number after ':'
size_t colonPos = data.find(':', i + 8);
if (colonPos != std::string::npos) {
size_t numStart = colonPos + 1;
while (numStart < data.size() && (data[numStart] == ' ' || data[numStart] == '\t')) {
numStart++;
}
size_t numEnd = numStart;
while (numEnd < data.size() && data[numEnd] >= '0' && data[numEnd] <= '9') {
numEnd++;
}
if (numEnd > numStart) {
length = std::stoul(data.substr(numStart, numEnd - numStart));
break;
}
}
}
}
}
if (lpath.empty() || length == 0) {
sendJsonResponse(OpCode::ERROR, "{\"message\":\"Invalid book data\"}");
return;
}
// Extract filename from lpath
std::string filename = lpath;
const size_t lastSlash = filename.rfind('/');
if (lastSlash != std::string::npos) {
filename = filename.substr(lastSlash + 1);
}
// Sanitize and create full path
currentFilename = "/" + StringUtils::sanitizeFilename(filename);
if (!StringUtils::checkFileExtension(currentFilename, ".epub")) {
currentFilename += ".epub";
}
currentFileSize = length;
bytesReceived = 0;
setState(WirelessState::RECEIVING);
setStatus("Receiving: " + filename);
// Open file for writing
if (!SdMan.openFileForWrite("CAL", currentFilename.c_str(), currentFile)) {
setError("Failed to create file");
sendJsonResponse(OpCode::ERROR, "{\"message\":\"Failed to create file\"}");
return;
}
// Send OK to start receiving binary data
sendJsonResponse(OpCode::OK, "{}");
// Switch to binary mode
inBinaryMode = true;
binaryBytesRemaining = length;
// Check if recvBuffer has leftover data (binary file data that arrived with the JSON)
if (!recvBuffer.empty()) {
size_t toWrite = std::min(recvBuffer.size(), binaryBytesRemaining);
size_t written = currentFile.write(reinterpret_cast<const uint8_t*>(recvBuffer.data()), toWrite);
bytesReceived += written;
binaryBytesRemaining -= written;
recvBuffer = recvBuffer.substr(toWrite);
updateRequired = true;
}
}
void CalibreWirelessActivity::handleSendBookMetadata(const std::string& data) {
// We receive metadata after the book - just acknowledge
sendJsonResponse(OpCode::OK, "{}");
}
void CalibreWirelessActivity::handleDisplayMessage(const std::string& data) {
// Calibre may send messages to display
// Check messageKind - 1 means password error
if (data.find("\"messageKind\":1") != std::string::npos) {
setError("Password required");
}
sendJsonResponse(OpCode::OK, "{}");
}
void CalibreWirelessActivity::handleNoop(const std::string& data) {
// Check for ejecting flag
if (data.find("\"ejecting\":true") != std::string::npos) {
setState(WirelessState::DISCONNECTED);
setStatus("Calibre disconnected");
}
sendJsonResponse(OpCode::NOOP, "{}");
}
void CalibreWirelessActivity::receiveBinaryData() {
const int available = tcpClient.available();
if (available == 0) {
// Check if connection is still alive
if (!tcpClient.connected()) {
currentFile.close();
inBinaryMode = false;
setError("Transfer interrupted");
}
return;
}
uint8_t buffer[1024];
const size_t toRead = std::min(sizeof(buffer), binaryBytesRemaining);
const size_t bytesRead = tcpClient.read(buffer, toRead);
if (bytesRead > 0) {
currentFile.write(buffer, bytesRead);
bytesReceived += bytesRead;
binaryBytesRemaining -= bytesRead;
updateRequired = true;
if (binaryBytesRemaining == 0) {
// Transfer complete
currentFile.flush();
currentFile.close();
inBinaryMode = false;
setState(WirelessState::WAITING);
setStatus("Received: " + currentFilename + "\nWaiting for more...");
// Send OK to acknowledge completion
sendJsonResponse(OpCode::OK, "{}");
}
}
}
void CalibreWirelessActivity::render() const {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Draw header
renderer.drawCenteredText(UI_12_FONT_ID, 30, "Calibre Wireless", true, EpdFontFamily::BOLD);
// Draw IP address
const std::string ipAddr = WiFi.localIP().toString().c_str();
renderer.drawCenteredText(UI_10_FONT_ID, 60, ("IP: " + ipAddr).c_str());
// Draw status message
int statusY = pageHeight / 2 - 40;
// Split status message by newlines and draw each line
std::string status = statusMessage;
size_t pos = 0;
while ((pos = status.find('\n')) != std::string::npos) {
renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.substr(0, pos).c_str());
statusY += 25;
status = status.substr(pos + 1);
}
if (!status.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.c_str());
statusY += 25;
}
// Draw progress if receiving
if (state == WirelessState::RECEIVING && currentFileSize > 0) {
const int barWidth = pageWidth - 100;
constexpr int barHeight = 20;
constexpr int barX = 50;
const int barY = statusY + 20;
ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, bytesReceived, currentFileSize);
}
// Draw error if present
if (!errorMessage.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight - 120, errorMessage.c_str());
}
// Draw button hints
const auto labels = mappedInput.mapLabels("Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
std::string CalibreWirelessActivity::getDeviceUuid() const {
// Generate a consistent UUID based on MAC address
uint8_t mac[6];
WiFi.macAddress(mac);
char uuid[37];
snprintf(uuid, sizeof(uuid), "%02x%02x%02x%02x-%02x%02x-4000-8000-%02x%02x%02x%02x%02x%02x", mac[0], mac[1], mac[2],
mac[3], mac[4], mac[5], mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
return std::string(uuid);
}
void CalibreWirelessActivity::setState(WirelessState newState) {
xSemaphoreTake(stateMutex, portMAX_DELAY);
state = newState;
xSemaphoreGive(stateMutex);
updateRequired = true;
}
void CalibreWirelessActivity::setStatus(const std::string& message) {
statusMessage = message;
updateRequired = true;
}
void CalibreWirelessActivity::setError(const std::string& message) {
errorMessage = message;
setState(WirelessState::ERROR);
}

View File

@ -1,135 +0,0 @@
#pragma once
#include <SDCardManager.h>
#include <WiFiClient.h>
#include <WiFiUdp.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include "activities/Activity.h"
/**
* CalibreWirelessActivity implements Calibre's "wireless device" protocol.
* This allows Calibre desktop to send books directly to the device over WiFi.
*
* Protocol specification sourced from Calibre's smart device driver:
* https://github.com/kovidgoyal/calibre/blob/master/src/calibre/devices/smart_device_app/driver.py
*
* Protocol overview:
* 1. Device broadcasts "hello" on UDP ports 54982, 48123, 39001, 44044, 59678
* 2. Calibre responds with its TCP server address
* 3. Device connects to Calibre's TCP server
* 4. Calibre sends JSON commands with length-prefixed messages
* 5. Books are transferred as binary data after SEND_BOOK command
*/
class CalibreWirelessActivity final : public Activity {
// Calibre wireless device states
enum class WirelessState {
DISCOVERING, // Listening for Calibre server broadcasts
CONNECTING, // Establishing TCP connection
WAITING, // Connected, waiting for commands
RECEIVING, // Receiving a book file
COMPLETE, // Transfer complete
DISCONNECTED, // Calibre disconnected
ERROR // Connection/transfer error
};
// Calibre protocol opcodes (from calibre/devices/smart_device_app/driver.py)
enum OpCode : uint8_t {
OK = 0,
SET_CALIBRE_DEVICE_INFO = 1,
SET_CALIBRE_DEVICE_NAME = 2,
GET_DEVICE_INFORMATION = 3,
TOTAL_SPACE = 4,
FREE_SPACE = 5,
GET_BOOK_COUNT = 6,
SEND_BOOKLISTS = 7,
SEND_BOOK = 8,
GET_INITIALIZATION_INFO = 9,
BOOK_DONE = 11,
NOOP = 12, // Was incorrectly 18
DELETE_BOOK = 13,
GET_BOOK_FILE_SEGMENT = 14,
GET_BOOK_METADATA = 15,
SEND_BOOK_METADATA = 16,
DISPLAY_MESSAGE = 17,
CALIBRE_BUSY = 18,
SET_LIBRARY_INFO = 19,
ERROR = 20,
};
TaskHandle_t displayTaskHandle = nullptr;
TaskHandle_t networkTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
SemaphoreHandle_t stateMutex = nullptr;
bool updateRequired = false;
WirelessState state = WirelessState::DISCOVERING;
const std::function<void()> onComplete;
// UDP discovery
WiFiUDP udp;
// TCP connection (we connect to Calibre)
WiFiClient tcpClient;
std::string calibreHost;
uint16_t calibrePort = 0;
uint16_t calibreAltPort = 0; // Alternative port (content server)
std::string calibreHostname;
// Transfer state
std::string currentFilename;
size_t currentFileSize = 0;
size_t bytesReceived = 0;
std::string statusMessage;
std::string errorMessage;
// Protocol state
bool inBinaryMode = false;
size_t binaryBytesRemaining = 0;
FsFile currentFile;
std::string recvBuffer; // Buffer for incoming data (like KOReader)
static void displayTaskTrampoline(void* param);
static void networkTaskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
[[noreturn]] void networkTaskLoop();
void render() const;
// Network operations
void listenForDiscovery();
void handleTcpClient();
bool readJsonMessage(std::string& message);
void sendJsonResponse(OpCode opcode, const std::string& data);
void handleCommand(OpCode opcode, const std::string& data);
void receiveBinaryData();
// Protocol handlers
void handleGetInitializationInfo(const std::string& data);
void handleGetDeviceInformation();
void handleFreeSpace();
void handleGetBookCount();
void handleSendBook(const std::string& data);
void handleSendBookMetadata(const std::string& data);
void handleDisplayMessage(const std::string& data);
void handleNoop(const std::string& data);
// Utility
std::string getDeviceUuid() const;
void setState(WirelessState newState);
void setStatus(const std::string& message);
void setError(const std::string& message);
public:
explicit CalibreWirelessActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onComplete)
: Activity("CalibreWireless", renderer, mappedInput), onComplete(onComplete) {}
void onEnter() override;
void onExit() override;
void loop() override;
bool preventAutoSleep() override { return true; }
bool skipLoopDelay() override { return true; }
};

View File

@ -12,6 +12,7 @@
#include "MappedInputManager.h"
#include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.h"
#include "activities/network/CalibreConnectActivity.h"
#include "fontIds.h"
namespace {
@ -125,8 +126,13 @@ void CrossPointWebServerActivity::onExit() {
}
void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) {
Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(),
mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot");
const char* modeName = "Join Network";
if (mode == NetworkMode::CONNECT_CALIBRE) {
modeName = "Connect to Calibre";
} else if (mode == NetworkMode::CREATE_HOTSPOT) {
modeName = "Create Hotspot";
}
Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), modeName);
networkMode = mode;
isApMode = (mode == NetworkMode::CREATE_HOTSPOT);
@ -134,6 +140,18 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
// Exit mode selection subactivity
exitActivity();
if (mode == NetworkMode::CONNECT_CALIBRE) {
exitActivity();
enterNewActivity(new CalibreConnectActivity(renderer, mappedInput, [this] {
exitActivity();
state = WebServerActivityState::MODE_SELECTION;
enterNewActivity(new NetworkModeSelectionActivity(
renderer, mappedInput, [this](const NetworkMode nextMode) { onNetworkModeSelected(nextMode); },
[this]() { onGoBack(); }));
}));
return;
}
if (mode == NetworkMode::JOIN_NETWORK) {
// STA mode - launch WiFi selection
Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis());

View File

@ -23,7 +23,7 @@ enum class WebServerActivityState {
/**
* CrossPointWebServerActivity is the entry point for file transfer functionality.
* It:
* - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP)
* - First presents a choice between "Join a Network" (STA), "Connect to Calibre", and "Create Hotspot" (AP)
* - For STA mode: Launches WifiSelectionActivity to connect to an existing network
* - For AP mode: Creates an Access Point that clients can connect to
* - Starts the CrossPointWebServer when connected

View File

@ -6,10 +6,13 @@
#include "fontIds.h"
namespace {
constexpr int MENU_ITEM_COUNT = 2;
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"};
const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Connect to an existing WiFi network",
"Create a WiFi network others can join"};
constexpr int MENU_ITEM_COUNT = 3;
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Connect to Calibre", "Create Hotspot"};
const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {
"Connect to an existing WiFi network",
"Use Calibre wireless device transfers",
"Create a WiFi network others can join",
};
} // namespace
void NetworkModeSelectionActivity::taskTrampoline(void* param) {
@ -58,7 +61,12 @@ void NetworkModeSelectionActivity::loop() {
// Handle confirm button - select current option
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT;
NetworkMode mode = NetworkMode::JOIN_NETWORK;
if (selectedIndex == 1) {
mode = NetworkMode::CONNECT_CALIBRE;
} else if (selectedIndex == 2) {
mode = NetworkMode::CREATE_HOTSPOT;
}
onModeSelected(mode);
return;
}

View File

@ -8,11 +8,12 @@
#include "../Activity.h"
// Enum for network mode selection
enum class NetworkMode { JOIN_NETWORK, CREATE_HOTSPOT };
enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT };
/**
* NetworkModeSelectionActivity presents the user with a choice:
* - "Join a Network" - Connect to an existing WiFi network (STA mode)
* - "Connect to Calibre" - Use Calibre wireless device transfers
* - "Create Hotspot" - Create an Access Point that others can connect to (AP mode)
*
* The onModeSelected callback is called with the user's choice.

View File

@ -354,8 +354,8 @@ void WifiSelectionActivity::loop() {
updateRequired = true;
}
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (forgetPromptSelection == 0) {
// User chose "Yes" - forget the network
if (forgetPromptSelection == 1) {
// User chose "Forget network" - forget the network
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.removeCredential(selectedSSID);
xSemaphoreGive(renderingMutex);
@ -366,7 +366,7 @@ void WifiSelectionActivity::loop() {
network->hasSavedPassword = false;
}
}
// Go back to network list
// Go back to network list (whether Cancel or Forget network was selected)
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
@ -391,7 +391,7 @@ void WifiSelectionActivity::loop() {
// If we used saved credentials, offer to forget the network
if (usedSavedPassword) {
state = WifiSelectionState::FORGET_PROMPT;
forgetPromptSelection = 0; // Default to "Yes"
forgetPromptSelection = 0; // Default to "Cancel"
} else {
// Go back to network list on failure
state = WifiSelectionState::NETWORK_LIST;
@ -623,7 +623,9 @@ void WifiSelectionActivity::renderConnected() const {
const std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, ipInfo.c_str());
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue");
// Use centralized button hints
const auto labels = mappedInput.mapLabels("", "Continue", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
void WifiSelectionActivity::renderSavePrompt() const {
@ -663,7 +665,9 @@ void WifiSelectionActivity::renderSavePrompt() const {
renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No");
}
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm");
// Use centralized button hints
const auto labels = mappedInput.mapLabels("« Skip", "Select", "Left", "Right");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
void WifiSelectionActivity::renderConnectionFailed() const {
@ -673,7 +677,10 @@ void WifiSelectionActivity::renderConnectionFailed() const {
renderer.drawCenteredText(UI_12_FONT_ID, top - 20, "Connection Failed", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, top + 20, connectionError.c_str());
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue");
// Use centralized button hints
const auto labels = mappedInput.mapLabels("« Back", "Continue", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
void WifiSelectionActivity::renderForgetPrompt() const {
@ -692,26 +699,28 @@ void WifiSelectionActivity::renderForgetPrompt() const {
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Remove saved password?");
// Draw Yes/No buttons
// Draw Cancel/Forget network buttons
const int buttonY = top + 80;
constexpr int buttonWidth = 60;
constexpr int buttonWidth = 120;
constexpr int buttonSpacing = 30;
constexpr int totalWidth = buttonWidth * 2 + buttonSpacing;
const int startX = (pageWidth - totalWidth) / 2;
// Draw "Yes" button
// Draw "Cancel" button
if (forgetPromptSelection == 0) {
renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Yes]");
renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Cancel]");
} else {
renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, "Yes");
renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, "Cancel");
}
// Draw "No" button
// Draw "Forget network" button
if (forgetPromptSelection == 1) {
renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]");
renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[Forget network]");
} else {
renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No");
renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "Forget network");
}
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm");
// Use centralized button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}

View File

@ -20,6 +20,8 @@ namespace {
constexpr unsigned long skipChapterMs = 700;
constexpr unsigned long goHomeMs = 1000;
constexpr int statusBarMargin = 19;
constexpr int progressBarMarginTop = 1;
} // namespace
void EpubReaderActivity::taskTrampoline(void* param) {
@ -57,12 +59,17 @@ void EpubReaderActivity::onEnter() {
FsFile f;
if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
if (f.read(data, 4) == 4) {
uint8_t data[6];
int dataSize = f.read(data, 6);
if (dataSize == 4 || dataSize == 6) {
currentSpineIndex = data[0] + (data[1] << 8);
nextPageNumber = data[2] + (data[3] << 8);
cachedSpineIndex = currentSpineIndex;
Serial.printf("[%lu] [ERS] Loaded cache: %d, %d\n", millis(), currentSpineIndex, nextPageNumber);
}
if (dataSize == 6) {
cachedChapterTotalPageCount = data[4] + (data[5] << 8);
}
f.close();
}
// We may want a better condition to detect if we are opening for the first time.
@ -205,14 +212,21 @@ void EpubReaderActivity::loop() {
}
}
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);
// When long-press chapter skip is disabled, turn pages on press instead of release.
const bool usePressForPageTurn = !SETTINGS.longPressChapterSkip;
const bool prevTriggered = usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) ||
mappedInput.wasPressed(MappedInputManager::Button::Left))
: (mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left));
const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
mappedInput.wasReleased(MappedInputManager::Button::Power);
const bool nextTriggered = usePressForPageTurn
? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasPressed(MappedInputManager::Button::Right))
: (mappedInput.wasReleased(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasReleased(MappedInputManager::Button::Right));
if (!prevReleased && !nextReleased) {
if (!prevTriggered && !nextTriggered) {
return;
}
@ -230,7 +244,7 @@ void EpubReaderActivity::loop() {
// We don't want to delete the section mid-render, so grab the semaphore
xSemaphoreTake(renderingMutex, portMAX_DELAY);
nextPageNumber = 0;
currentSpineIndex = nextReleased ? currentSpineIndex + 1 : currentSpineIndex - 1;
currentSpineIndex = nextTriggered ? currentSpineIndex + 1 : currentSpineIndex - 1;
section.reset();
xSemaphoreGive(renderingMutex);
updateRequired = true;
@ -243,7 +257,7 @@ void EpubReaderActivity::loop() {
return;
}
if (prevReleased) {
if (prevTriggered) {
if (section->currentPage > 0) {
section->currentPage--;
} else {
@ -311,7 +325,16 @@ void EpubReaderActivity::renderScreen() {
orientedMarginTop += SETTINGS.screenMargin;
orientedMarginLeft += SETTINGS.screenMargin;
orientedMarginRight += SETTINGS.screenMargin;
orientedMarginBottom += statusBarMargin;
orientedMarginBottom += SETTINGS.screenMargin;
// Add status bar margin
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
// Add additional margin for status bar if progress bar is shown
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR;
orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin +
(showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0);
}
if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
@ -382,6 +405,17 @@ void EpubReaderActivity::renderScreen() {
} else {
section->currentPage = nextPageNumber;
}
// handles changes in reader settings and reset to approximate position based on cached progress
if (cachedChapterTotalPageCount > 0) {
// only goes to relative position if spine index matches cached value
if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) {
float progress = static_cast<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
int newPage = static_cast<int>(progress * section->pageCount);
section->currentPage = newPage;
}
cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again
}
}
renderer.clearScreen();
@ -433,12 +467,14 @@ void EpubReaderActivity::renderScreen() {
FsFile f;
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
uint8_t data[6];
data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF;
data[2] = section->currentPage & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF;
f.write(data, 4);
data[4] = section->pageCount & 0xFF;
data[5] = (section->pageCount >> 8) & 0xFF;
f.write(data, 6);
f.close();
}
}
@ -485,29 +521,51 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) const {
// determine visible status bar elements
const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR;
const bool showProgressText = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR;
const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR;
const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR;
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
// Position status bar near the bottom of the logical screen, regardless of orientation
const auto screenHeight = renderer.getScreenHeight();
const auto textY = screenHeight - orientedMarginBottom - 4;
int progressTextWidth = 0;
if (showProgress) {
// Calculate progress in book
// TODO: use progress values for UI
// cppcheck-suppress unreadVariable
const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
// cppcheck-suppress unreadVariable
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
if (showProgressText || showProgressPercentage) {
// Right aligned text for progress counter
char progressStr[32];
// Hide percentage when progress bar is shown to reduce clutter
if (showProgressPercentage) {
snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", section->currentPage + 1, section->pageCount,
bookProgress);
} else {
snprintf(progressStr, sizeof(progressStr), "%d/%d", section->currentPage + 1, section->pageCount);
}
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY,
progressStr);
}
if (showProgressBar) {
// Draw progress bar at the very bottom of the screen, from edge to edge of viewable area
ScreenComponents::drawBookProgressBar(renderer, static_cast<size_t>(bookProgress));
}
// Left aligned battery icon and percentage
if (showBattery) {
ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage);
}
@ -517,7 +575,6 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
// Page width minus existing content with 30px padding on each side
const int rendererableScreenWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
int progressTextWidth = 0;
const int batterySize = showBattery ? (showBatteryPercentage ? 50 : 20) : 0;
const int titleMarginLeft = batterySize + 30;
const int titleMarginRight = progressTextWidth + 30;

View File

@ -16,6 +16,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
int currentSpineIndex = 0;
int nextPageNumber = 0;
int pagesUntilFullRefresh = 0;
int cachedSpineIndex = 0;
int cachedChapterTotalPageCount = 0;
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;

View File

@ -14,6 +14,24 @@ constexpr int SKIP_PAGE_MS = 700;
bool EpubReaderChapterSelectionActivity::hasSyncOption() const { return KOREADER_STORE.hasCredentials(); }
int EpubReaderChapterSelectionActivity::getTotalItems() const {
// Add 2 for sync options (top and bottom) if credentials are configured
const int syncCount = hasSyncOption() ? 2 : 0;
return epub->getTocItemsCount() + syncCount;
}
bool EpubReaderChapterSelectionActivity::isSyncItem(int index) const {
if (!hasSyncOption()) return false;
// First item and last item are sync options
return index == 0 || index == getTotalItems() - 1;
}
int EpubReaderChapterSelectionActivity::tocIndexFromItemIndex(int itemIndex) const {
// Account for the sync option at the top
const int offset = hasSyncOption() ? 1 : 0;
return itemIndex - offset;
}
int EpubReaderChapterSelectionActivity::getPageItems() const {
// Layout constants used in renderScreen
constexpr int startY = 60;
@ -204,15 +222,7 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems();
const int syncCount = hasSyncOption() ? 2 : 0;
const int totalItems = filteredSpineIndices.size() + syncCount;
if (totalItems == 0) {
renderer.drawCenteredText(SMALL_FONT_ID, 300, "No chapters available", true);
renderer.displayBuffer();
return;
}
const int totalItems = getTotalItems();
const std::string title =
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD);
@ -221,37 +231,33 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
for (int i = pageStartIndex; i < totalItems && i < pageStartIndex + pageItems; i++) {
const int displayY = 60 + (i % pageItems) * 30;
const bool isSelected = (i == selectorIndex); // Use i for comparison
for (int i = 0; i < pageItems; i++) {
int itemIndex = pageStartIndex + i;
if (itemIndex >= totalItems) break;
// Check for sync item
bool isSync = false;
if (hasSyncOption()) {
if (i == 0 || i == totalItems - 1) isSync = true;
}
const int displayY = 60 + i * 30;
const bool isSelected = (itemIndex == selectorIndex);
if (isSync) {
if (isSyncItem(itemIndex)) {
renderer.drawText(UI_10_FONT_ID, 20, displayY, ">> Sync Progress", !isSelected);
} else {
// It's a filtered chapter
int filteredIndex = i;
if (hasSyncOption()) filteredIndex -= 1;
const int actualSpineIndex = filteredSpineIndices[filteredIndex];
const int tocIndex = epub->getTocIndexForSpineIndex(actualSpineIndex);
const int tocIndex = tocIndexFromItemIndex(itemIndex);
if (tocIndex == -1) {
renderer.drawText(UI_10_FONT_ID, 20, displayY, "Unnamed", !isSelected);
} else {
// Master's rendering logic
auto item = epub->getTocItem(tocIndex);
const int indentSize = 20 + (item.level - 1) * 15;
const std::string chapterName =
renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - indentSize);
renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected);
}
}
}
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);

View File

@ -27,9 +27,18 @@ class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity
// This adapts automatically when switching between portrait and landscape.
int getPageItems() const;
// Total items including sync options (top and bottom)
int getTotalItems() const;
// Check if sync option is available (credentials configured)
bool hasSyncOption() const;
// Check if given item index is a sync option (first or last)
bool isSyncItem(int index) const;
// Convert item index to TOC index (accounting for top sync option offset)
int tocIndexFromItemIndex(int itemIndex) const;
// Filtered list of spine indices (excluding footnote pages)
std::vector<int> filteredSpineIndices;

View File

@ -22,9 +22,8 @@ bool ReaderActivity::isXtcFile(const std::string& path) {
}
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";
return StringUtils::checkFileExtension(path, ".txt") ||
StringUtils::checkFileExtension(path, ".md"); // Treat .md as txt files (until we have a markdown reader)
}
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {

View File

@ -8,12 +8,14 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "ScreenComponents.h"
#include "fontIds.h"
namespace {
constexpr unsigned long goHomeMs = 1000;
constexpr int statusBarMargin = 25;
constexpr int progressBarMarginTop = 1;
constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading
// Cache file magic and version
@ -55,9 +57,10 @@ void TxtReaderActivity::onEnter() {
txt->setupCacheDir();
// Save current txt as last opened file
// Save current txt as last opened file and add to recent books
APP_STATE.openEpubPath = txt->getPath();
APP_STATE.saveToFile();
RECENT_BOOKS.addBook(txt->getPath());
// Trigger first update
updateRequired = true;
@ -107,21 +110,28 @@ void TxtReaderActivity::loop() {
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);
// When long-press chapter skip is disabled, turn pages on press instead of release.
const bool usePressForPageTurn = !SETTINGS.longPressChapterSkip;
const bool prevTriggered = usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) ||
mappedInput.wasPressed(MappedInputManager::Button::Left))
: (mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left));
const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
mappedInput.wasReleased(MappedInputManager::Button::Power);
const bool nextTriggered = usePressForPageTurn
? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasPressed(MappedInputManager::Button::Right))
: (mappedInput.wasReleased(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasReleased(MappedInputManager::Button::Right));
if (!prevReleased && !nextReleased) {
if (!prevTriggered && !nextTriggered) {
return;
}
if (prevReleased && currentPage > 0) {
if (prevTriggered && currentPage > 0) {
currentPage--;
updateRequired = true;
} else if (nextReleased && currentPage < totalPages - 1) {
} else if (nextTriggered && currentPage < totalPages - 1) {
currentPage++;
updateRequired = true;
}
@ -156,7 +166,16 @@ void TxtReaderActivity::initializeReader() {
orientedMarginTop += cachedScreenMargin;
orientedMarginLeft += cachedScreenMargin;
orientedMarginRight += cachedScreenMargin;
orientedMarginBottom += statusBarMargin;
orientedMarginBottom += cachedScreenMargin;
// Add status bar margin
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
// Add additional margin for status bar if progress bar is shown
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR;
orientedMarginBottom += statusBarMargin - cachedScreenMargin +
(showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0);
}
viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
const int viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
@ -497,27 +516,46 @@ void TxtReaderActivity::renderPage() {
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 showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR;
const bool showProgressText = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR;
const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR;
const bool showTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR;
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
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());
const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0;
if (showProgressText || showProgressPercentage) {
char progressStr[32];
if (showProgressPercentage) {
snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", currentPage + 1, totalPages, progress);
} else {
snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage + 1, totalPages);
}
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY,
progressStr.c_str());
progressStr);
}
if (showProgressBar) {
// Draw progress bar at the very bottom of the screen, from edge to edge of viewable area
ScreenComponents::drawBookProgressBar(renderer, static_cast<size_t>(progress));
}
if (showBattery) {
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY);
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage);
}
if (showTitle) {

View File

@ -111,14 +111,21 @@ void XtcReaderActivity::loop() {
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);
// When long-press chapter skip is disabled, turn pages on press instead of release.
const bool usePressForPageTurn = !SETTINGS.longPressChapterSkip;
const bool prevTriggered = usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) ||
mappedInput.wasPressed(MappedInputManager::Button::Left))
: (mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left));
const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
mappedInput.wasReleased(MappedInputManager::Button::Power);
const bool nextTriggered = usePressForPageTurn
? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasPressed(MappedInputManager::Button::Right))
: (mappedInput.wasReleased(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasReleased(MappedInputManager::Button::Right));
if (!prevReleased && !nextReleased) {
if (!prevTriggered && !nextTriggered) {
return;
}
@ -132,14 +139,14 @@ void XtcReaderActivity::loop() {
const bool skipPages = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipPageMs;
const int skipAmount = skipPages ? 10 : 1;
if (prevReleased) {
if (prevTriggered) {
if (currentPage >= static_cast<uint32_t>(skipAmount)) {
currentPage -= skipAmount;
} else {
currentPage = 0;
}
updateRequired = true;
} else if (nextReleased) {
} else if (nextTriggered) {
currentPage += skipAmount;
if (currentPage >= xtc->getPageCount()) {
currentPage = xtc->getPageCount(); // Allow showing "End of book"

View File

@ -1,20 +1,17 @@
#include "CalibreSettingsActivity.h"
#include <GfxRenderer.h>
#include <WiFi.h>
#include <cstring>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "activities/network/CalibreWirelessActivity.h"
#include "activities/network/WifiSelectionActivity.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "fontIds.h"
namespace {
constexpr int MENU_ITEMS = 2;
const char* menuNames[MENU_ITEMS] = {"Calibre Web URL", "Connect as Wireless Device"};
constexpr int MENU_ITEMS = 3;
const char* menuNames[MENU_ITEMS] = {"OPDS Server URL", "Username", "Password"};
} // namespace
void CalibreSettingsActivity::taskTrampoline(void* param) {
@ -80,10 +77,10 @@ void CalibreSettingsActivity::handleSelection() {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (selectedIndex == 0) {
// Calibre Web URL
// OPDS Server URL
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, "Calibre Web URL", SETTINGS.opdsServerUrl, 10,
renderer, mappedInput, "OPDS Server URL", SETTINGS.opdsServerUrl, 10,
127, // maxLength
false, // not password
[this](const std::string& url) {
@ -98,27 +95,42 @@ void CalibreSettingsActivity::handleSelection() {
updateRequired = true;
}));
} else if (selectedIndex == 1) {
// Wireless Device - launch the activity (handles WiFi connection internally)
// Username
exitActivity();
if (WiFi.status() != WL_CONNECTED) {
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) {
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, "Username", SETTINGS.opdsUsername, 10,
63, // maxLength
false, // not password
[this](const std::string& username) {
strncpy(SETTINGS.opdsUsername, username.c_str(), sizeof(SETTINGS.opdsUsername) - 1);
SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0';
SETTINGS.saveToFile();
exitActivity();
if (connected) {
enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] {
updateRequired = true;
},
[this]() {
exitActivity();
updateRequired = true;
}));
} else {
} else if (selectedIndex == 2) {
// Password
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, "Password", SETTINGS.opdsPassword, 10,
63, // maxLength
false, // not password mode
[this](const std::string& password) {
strncpy(SETTINGS.opdsPassword, password.c_str(), sizeof(SETTINGS.opdsPassword) - 1);
SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0';
SETTINGS.saveToFile();
exitActivity();
updateRequired = true;
}
}));
} else {
enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] {
},
[this]() {
exitActivity();
updateRequired = true;
}));
}
}
xSemaphoreGive(renderingMutex);
}
@ -141,25 +153,33 @@ void CalibreSettingsActivity::render() {
const auto pageWidth = renderer.getScreenWidth();
// Draw header
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD);
// Draw info text about Calibre
renderer.drawCenteredText(UI_10_FONT_ID, 40, "For Calibre, add /opds to your URL");
// Draw selection highlight
renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30);
renderer.fillRect(0, 70 + selectedIndex * 30 - 2, pageWidth - 1, 30);
// Draw menu items
for (int i = 0; i < MENU_ITEMS; i++) {
const int settingY = 60 + i * 30;
const int settingY = 70 + i * 30;
const bool isSelected = (i == selectedIndex);
renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected);
// Draw status for URL setting
// Draw status for each setting
const char* status = "[Not Set]";
if (i == 0) {
const char* status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]";
status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]";
} else if (i == 1) {
status = (strlen(SETTINGS.opdsUsername) > 0) ? "[Set]" : "[Not Set]";
} else if (i == 2) {
status = (strlen(SETTINGS.opdsPassword) > 0) ? "[Set]" : "[Not Set]";
}
const auto width = renderer.getTextWidth(UI_10_FONT_ID, status);
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected);
}
}
// Draw button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");

View File

@ -8,8 +8,8 @@
#include "activities/ActivityWithSubactivity.h"
/**
* Submenu for Calibre settings.
* Shows Calibre Web URL and Calibre Wireless Device options.
* Submenu for OPDS Browser settings.
* Shows OPDS Server URL and HTTP authentication options.
*/
class CalibreSettingsActivity final : public ActivityWithSubactivity {
public:

View File

@ -103,7 +103,7 @@ void CategorySettingsActivity::toggleCurrentSetting() {
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Calibre Settings") == 0) {
} else if (strcmp(setting.name, "OPDS Browser") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {

Some files were not shown because too many files have changed in this diff Show More