ui fixes for audiobook player, firmware version number updates, subdirectory support for both ereader and audiobook player file lists

This commit is contained in:
pelgraine
2026-02-14 15:43:13 +11:00
parent 2dc6977c20
commit e5e41ff50b
5 changed files with 605 additions and 90 deletions

View File

@@ -1,24 +1,41 @@
## Audiobook Player (Audio variant only)
Press **P** from the home screen to open the audiobook player.
Place `.m4b`, `.m4a`, `.mp3`, or `.wav` files in `/audiobooks/` on the SD card.
Place `.mp3`, `.m4b`, `.m4a`, or `.wav` files in `/audiobooks/` on the SD card.
Files can be organised into subfolders (e.g. by author) — use **Enter** to
browse into folders and **.. (up)** to go back.
| Key | Action |
|-----|--------|
| W / S | Scroll file list / Volume up-down |
| Enter | Select book / Play-Pause |
| Enter | Select book or folder / Play-Pause |
| A | Seek back 30 seconds |
| D | Seek forward 30 seconds |
| [ | Previous chapter (M4B only) |
| ] | Next chapter (M4B only) |
| Q | Leave player (audio continues) / Close book (when paused) / Exit (from file list) |
### Recommended Format
**MP3 is the recommended format.** M4B/M4A files are supported but currently
have playback issues with the ESP32-audioI2S library — some files may fail to
decode or produce silence. MP3 files play reliably and are the safest choice.
MP3 files should be encoded at a **44100 Hz sample rate**. Lower sample rates
(e.g. 22050 Hz) can cause distortion or playback failure due to ESP32-S3 I2S
hardware limitations.
**Bookmarks** are saved automatically every 30 seconds during playback and when
you stop or exit. Reopening a book resumes from your last position.
**Cover art** from M4B files is displayed as dithered monochrome on the e-ink
screen, along with title, author, and chapter information.
**Metadata caching** — the first time you open the audiobook player, it reads
title and author tags from each file (which can take a few seconds with many
files). This metadata is cached to the SD card so subsequent visits load
near-instantly. If you add or remove files the cache updates automatically.
### Background Playback
Audio continues playing when you leave the audiobook player screen. Press **Q**
@@ -37,8 +54,7 @@ T-Deck Pro (I2S pins: BCLK=7, DOUT=8, LRC=9). Audio is output via the 3.5mm
headphone jack.
> **Note:** The audiobook player is not available on the 4G modem variant
> due to I2S pin conflicts. MP3 format is recommended over M4B for best
> compatibility with the ESP32-audioI2S library.
> due to I2S pin conflicts.
### SD Card Folder Structure
@@ -48,8 +64,13 @@ SD Card
│ ├── .bookmarks/ (auto-created, stores resume positions)
│ │ ├── mybook.bmk
│ │ └── another.bmk
│ ├── mybook.m4b
│ ├── another.m4b
│ ├── .metacache (auto-created, speeds up file list loading)
│ ├── Ann Leckie/
│ │ ├── Ancillary Justice.mp3
│ │ └── Ancillary Sword.mp3
│ ├── Iain M. Banks/
│ │ └── The Algebraist.mp3
│ ├── mybook.mp3
│ └── podcast.mp3
├── books/ (existing — text reader)
│ └── ...

View File

@@ -12,7 +12,7 @@
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.8.7A"
#define FIRMWARE_VERSION "Meck v0.8.8A"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)

View File

@@ -55,6 +55,7 @@ class UITask;
#define AB_DEFAULT_VOLUME 12 // 0-21 range for ESP32-audioI2S
#define AB_SEEK_SECONDS 30 // Skip forward/back amount
#define AB_POSITION_SAVE_INTERVAL 30000 // Auto-save bookmark every 30s
#define AB_METACACHE_FILE "/audiobooks/.metacache"
// Supported file extensions
static bool isAudiobookFile(const String& name) {
@@ -120,11 +121,16 @@ static int coverDrawCallback(JPEGDRAW* pDraw) {
}
// ============================================================================
// File entry with cached bookmark state (avoid SD.exists during render)
// File entry with cached metadata and bookmark state
// ============================================================================
struct AudiobookFileEntry {
String name;
String name; // Original filename on SD (or directory name)
String displayTitle; // Extracted title (or cleaned filename / folder name)
String displayAuthor; // Extracted author (or empty)
String fileType; // "M4B" or "MP3" or "WAV" or "DIR"
uint32_t fileSize; // File size in bytes (for MP3 duration estimation)
bool hasBookmark;
bool isDir; // true for subdirectory entries
};
// ============================================================================
@@ -147,11 +153,13 @@ private:
bool _sdReady;
bool _i2sInitialized; // Track whether setPinout has been called
bool _dacPowered; // Track GPIO 41 DAC power state
DisplayDriver* _displayRef; // Stored for splash screens during scan
// File browser state
std::vector<AudiobookFileEntry> _fileList;
int _selectedFile;
int _scrollOffset;
String _currentPath; // Current browsed directory (starts as AUDIOBOOKS_FOLDER)
// Current book state
String _currentFile;
@@ -178,6 +186,9 @@ private:
uint32_t _pendingSeekSec; // 0 = no pending seek
bool _streamReady; // Set true once library reports duration
// File size for MP3 duration estimation (MP3 has no native duration header)
uint32_t _currentFileSize;
// M4B rename workaround — the audio library only recognises .m4a,
// so we temporarily rename .m4b files on the SD card for playback.
bool _m4bRenamed; // true if file was renamed for playback
@@ -389,6 +400,110 @@ private:
// ---- File Scanning ----
// ---- Loading Splash ----
void drawLoadingSplash() {
if (!_displayRef) return;
_displayRef->startFrame();
_displayRef->setTextSize(2);
_displayRef->setColor(DisplayDriver::GREEN);
_displayRef->setCursor(10, 15);
_displayRef->print("Loading");
_displayRef->setCursor(10, 30);
_displayRef->print("Audiobooks");
_displayRef->setTextSize(1);
_displayRef->setColor(DisplayDriver::LIGHT);
_displayRef->setCursor(10, 55);
_displayRef->print("Please wait...");
_displayRef->endFrame();
}
// ---- Metadata Cache ----
// Simple tab-separated cache file per directory: filename\tsize\ttitle\tauthor\ttype\n
// Avoids re-parsing every file's ID3/M4B tags on each screen entry.
struct MetaCacheEntry {
String filename;
uint32_t fileSize;
String title;
String author;
String fileType;
};
String getMetaCachePath() {
return _currentPath + "/.metacache";
}
// Load metadata cache from SD. Returns entries in a vector.
std::vector<MetaCacheEntry> loadMetaCache() {
std::vector<MetaCacheEntry> cache;
String path = getMetaCachePath();
File f = SD.open(path.c_str(), FILE_READ);
if (!f) return cache;
char line[256];
while (f.available()) {
int len = 0;
while (f.available() && len < (int)sizeof(line) - 1) {
char c = f.read();
if (c == '\n') break;
if (c == '\r') continue;
line[len++] = c;
}
line[len] = '\0';
if (len == 0) continue;
// Parse: filename\tsize\ttitle\tauthor\ttype
MetaCacheEntry e;
char* tok = strtok(line, "\t");
if (!tok) continue;
e.filename = String(tok);
tok = strtok(nullptr, "\t");
if (!tok) continue;
e.fileSize = (uint32_t)atol(tok);
tok = strtok(nullptr, "\t");
if (!tok) continue;
e.title = String(tok);
tok = strtok(nullptr, "\t");
if (!tok) continue;
e.author = String(tok);
tok = strtok(nullptr, "\t");
if (!tok) continue;
e.fileType = String(tok);
cache.push_back(e);
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
return cache;
}
// Save metadata cache to SD.
void saveMetaCache(const std::vector<AudiobookFileEntry>& entries) {
String path = getMetaCachePath();
if (SD.exists(path.c_str())) SD.remove(path.c_str());
File f = SD.open(path.c_str(), FILE_WRITE);
if (!f) return;
for (const auto& e : entries) {
if (e.isDir) continue; // Don't cache directories
f.printf("%s\t%u\t%s\t%s\t%s\n",
e.name.c_str(), e.fileSize,
e.displayTitle.c_str(),
e.displayAuthor.length() > 0 ? e.displayAuthor.c_str() : "",
e.fileType.c_str());
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
Serial.println("AB: Metadata cache saved");
}
// ---- File Scanning ----
void scanFiles() {
_fileList.clear();
if (!SD.exists(AUDIOBOOKS_FOLDER)) {
@@ -396,30 +511,142 @@ private:
Serial.printf("AB: Created %s\n", AUDIOBOOKS_FOLDER);
}
File root = SD.open(AUDIOBOOKS_FOLDER);
File root = SD.open(_currentPath.c_str());
if (!root || !root.isDirectory()) return;
// Add ".." entry if not at the audiobooks root
if (_currentPath != String(AUDIOBOOKS_FOLDER)) {
AudiobookFileEntry upEntry;
upEntry.name = "..";
upEntry.displayTitle = "..";
upEntry.fileType = "DIR";
upEntry.fileSize = 0;
upEntry.hasBookmark = false;
upEntry.isDir = true;
_fileList.push_back(upEntry);
}
// Load metadata cache for this directory
std::vector<MetaCacheEntry> metaCache = loadMetaCache();
bool cacheDirty = false;
// Collect directories and files separately, then combine (dirs first)
std::vector<AudiobookFileEntry> dirs;
std::vector<AudiobookFileEntry> files;
// Reusable metadata parser (only used for cache misses)
M4BMetadata scanMeta;
File f = root.openNextFile();
while (f && _fileList.size() < AB_MAX_FILES) {
if (!f.isDirectory()) {
while (f && (dirs.size() + files.size()) < AB_MAX_FILES) {
String name = String(f.name());
int slash = name.lastIndexOf('/');
if (slash >= 0) name = name.substring(slash + 1);
if (isAudiobookFile(name) && !name.startsWith("._")) {
// Skip hidden files/dirs
if (name.startsWith(".") || name.startsWith("._")) {
f = root.openNextFile();
continue;
}
if (f.isDirectory()) {
AudiobookFileEntry entry;
entry.name = name;
// Cache bookmark existence NOW — avoid SD.exists() during render
entry.displayTitle = name;
entry.fileType = "DIR";
entry.fileSize = 0;
entry.hasBookmark = false;
entry.isDir = true;
dirs.push_back(entry);
} else if (isAudiobookFile(name)) {
AudiobookFileEntry entry;
entry.name = name;
entry.fileSize = f.size();
entry.isDir = false;
// Determine file type
String nameLower = name;
nameLower.toLowerCase();
if (nameLower.endsWith(".m4b") || nameLower.endsWith(".m4a")) {
entry.fileType = "M4B";
} else if (nameLower.endsWith(".mp3")) {
entry.fileType = "MP3";
} else {
entry.fileType = "WAV";
}
// Check metadata cache first (match by filename + size)
bool cacheHit = false;
for (const auto& mc : metaCache) {
if (mc.filename == name && mc.fileSize == entry.fileSize) {
entry.displayTitle = mc.title;
entry.displayAuthor = mc.author;
cacheHit = true;
break;
}
}
if (!cacheHit) {
// Cache miss — parse metadata from file (slow path)
String fullPath = _currentPath + "/" + name;
File metaFile = SD.open(fullPath.c_str(), FILE_READ);
if (metaFile) {
scanMeta.clear();
if (entry.fileType == "M4B") {
if (scanMeta.parse(metaFile)) {
if (scanMeta.title[0]) entry.displayTitle = String(scanMeta.title);
if (scanMeta.author[0]) entry.displayAuthor = String(scanMeta.author);
}
} else if (entry.fileType == "MP3") {
if (scanMeta.parseID3v2(metaFile)) {
if (scanMeta.title[0]) entry.displayTitle = String(scanMeta.title);
if (scanMeta.author[0]) entry.displayAuthor = String(scanMeta.author);
}
}
metaFile.close();
digitalWrite(SDCARD_CS, HIGH);
yield(); // Feed WDT between file parses
}
cacheDirty = true;
}
// Fallback: clean up filename if no metadata title found
if (entry.displayTitle.length() == 0) {
String cleaned = name;
int dot = cleaned.lastIndexOf('.');
if (dot > 0) cleaned = cleaned.substring(0, dot);
cleaned.replace("_", " ");
entry.displayTitle = cleaned;
}
// Cache bookmark existence
String bmkPath = getBookmarkPath(name);
entry.hasBookmark = SD.exists(bmkPath.c_str());
_fileList.push_back(entry);
}
files.push_back(entry);
Serial.printf("AB: [%s] %s - %s (%s)%s\n",
entry.fileType.c_str(),
entry.displayTitle.c_str(),
entry.displayAuthor.length() > 0 ? entry.displayAuthor.c_str() : "?",
entry.name.c_str(),
cacheHit ? " (cached)" : "");
}
f = root.openNextFile();
}
root.close();
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("AB: Found %d audiobook files\n", (int)_fileList.size());
// Append directories first, then files
for (auto& d : dirs) _fileList.push_back(d);
for (auto& fi : files) _fileList.push_back(fi);
// Save metadata cache if any new entries were parsed
if (cacheDirty && files.size() > 0) {
saveMetaCache(files);
}
Serial.printf("AB: %s — %d dirs, %d files\n",
_currentPath.c_str(), (int)dirs.size(), (int)files.size());
}
// ---- Book Open / Close ----
@@ -457,10 +684,17 @@ private:
_pendingSeekSec = 0;
_streamReady = false;
// Cache file size for MP3 duration estimation
if (_selectedFile >= 0 && _selectedFile < (int)_fileList.size()) {
_currentFileSize = _fileList[_selectedFile].fileSize;
} else {
_currentFileSize = 0;
}
yield(); // Feed WDT between heavy operations
// Parse metadata
String fullPath = String(AUDIOBOOKS_FOLDER) + "/" + filename;
String fullPath = _currentPath + "/" + filename;
File file = SD.open(fullPath.c_str(), FILE_READ);
if (file) {
String lower = filename;
@@ -538,7 +772,7 @@ private:
// Ensure I2S is configured (once only, before first connecttoFS)
ensureI2SInit();
String fullPath = String(AUDIOBOOKS_FOLDER) + "/" + _currentFile;
String fullPath = _currentPath + "/" + _currentFile;
// M4B workaround: the ESP32-audioI2S library only recognises .m4a
// for MP4/AAC container parsing. M4B is identical but the extension
@@ -550,7 +784,7 @@ private:
if (lower.endsWith(".m4b")) {
String m4aFile = _currentFile.substring(0, _currentFile.length() - 1) + "a";
_m4bOrigPath = fullPath;
_m4bTempPath = String(AUDIOBOOKS_FOLDER) + "/" + m4aFile;
_m4bTempPath = _currentPath + "/" + m4aFile;
if (SD.rename(_m4bOrigPath.c_str(), _m4bTempPath.c_str())) {
Serial.printf("AB: Renamed '%s' -> '%s' for playback\n",
@@ -701,10 +935,13 @@ private:
return;
}
// Calculate visible items — reserve footerHeight=14 at bottom
int itemHeight = 10;
// Switch to tiny font for file list (6x8 built-in)
display.setTextSize(0);
// Calculate visible items — tiny font uses ~8 virtual units per line
int itemHeight = 8;
int listTop = 13;
int listBottom = display.height() - 14;
int listBottom = display.height() - 14; // Reserve footer space
int visibleItems = (listBottom - listTop) / itemHeight;
// Keep selection visible
@@ -714,6 +951,9 @@ private:
_scrollOffset = _selectedFile - visibleItems + 1;
}
// Approx chars that fit in tiny font (~36 on 128 virtual width)
const int charsPerLine = 36;
// Draw file list
for (int i = 0; i < visibleItems && (_scrollOffset + i) < (int)_fileList.size(); i++) {
int fileIdx = _scrollOffset + i;
@@ -721,31 +961,76 @@ private:
if (fileIdx == _selectedFile) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), itemHeight - 1);
// setCursor adds +5 to y internally, but fillRect does not.
// Offset fillRect by +5 to align highlight bar with text.
display.fillRect(0, y + 5, display.width(), itemHeight - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
// Display filename without extension
String name = _fileList[fileIdx].name;
int dot = name.lastIndexOf('.');
if (dot > 0) name = name.substring(0, dot);
if (name.length() > 20) {
name = name.substring(0, 17) + "...";
// Build display string based on entry type
const AudiobookFileEntry& fe = _fileList[fileIdx];
char fullLine[96];
if (fe.isDir) {
// Directory entry: show as "/ FolderName" or just ".."
if (fe.name == "..") {
snprintf(fullLine, sizeof(fullLine), ".. (up)");
} else {
snprintf(fullLine, sizeof(fullLine), "/%s", fe.name.c_str());
// Truncate if needed
if ((int)strlen(fullLine) > charsPerLine - 1) {
fullLine[charsPerLine - 4] = '.';
fullLine[charsPerLine - 3] = '.';
fullLine[charsPerLine - 2] = '.';
fullLine[charsPerLine - 1] = '\0';
}
}
} else {
// Audio file: "Title - Author [TYPE]"
char lineBuf[80];
// Reserve space for type tag and bookmark indicator
int suffixLen = fe.fileType.length() + 3; // " [M4B]" or " [MP3]"
int bmkLen = fe.hasBookmark ? 2 : 0; // " >"
int availChars = charsPerLine - suffixLen - bmkLen;
if (availChars < 10) availChars = 10;
if (fe.displayAuthor.length() > 0) {
snprintf(lineBuf, sizeof(lineBuf), "%s - %s",
fe.displayTitle.c_str(), fe.displayAuthor.c_str());
} else {
snprintf(lineBuf, sizeof(lineBuf), "%s", fe.displayTitle.c_str());
}
// Truncate with ellipsis if needed
if ((int)strlen(lineBuf) > availChars) {
if (availChars > 3) {
lineBuf[availChars - 3] = '.';
lineBuf[availChars - 2] = '.';
lineBuf[availChars - 1] = '.';
lineBuf[availChars] = '\0';
} else {
lineBuf[availChars] = '\0';
}
}
// Append file type tag
snprintf(fullLine, sizeof(fullLine), "%s [%s]", lineBuf, fe.fileType.c_str());
}
display.setCursor(2, y);
display.print(name.c_str());
display.print(fullLine);
// Bookmark indicator (cached from scanFiles — no SD access)
if (_fileList[fileIdx].hasBookmark) {
// Bookmark indicator (right-aligned, files only)
if (!fe.isDir && fe.hasBookmark) {
display.setCursor(display.width() - 8, y);
display.print(">");
}
}
// Scrollbar
// Scrollbar (if needed)
if ((int)_fileList.size() > visibleItems) {
int barH = listBottom - listTop;
int thumbH = max(4, barH * visibleItems / (int)_fileList.size());
@@ -756,9 +1041,22 @@ private:
display.fillRect(display.width() - 1, thumbY, 1, thumbH);
}
// Footer
char leftBuf[20];
// Footer (stays at size 1 for readability)
char leftBuf[32];
if (_currentPath == String(AUDIOBOOKS_FOLDER)) {
snprintf(leftBuf, sizeof(leftBuf), "%d files", (int)_fileList.size());
} else {
// Show current subfolder name
int lastSlash = _currentPath.lastIndexOf('/');
String folderName = (lastSlash >= 0) ? _currentPath.substring(lastSlash + 1) : _currentPath;
snprintf(leftBuf, sizeof(leftBuf), "/%s", folderName.c_str());
if ((int)strlen(leftBuf) > 16) {
leftBuf[13] = '.';
leftBuf[14] = '.';
leftBuf[15] = '.';
leftBuf[16] = '\0';
}
}
drawFooter(display, leftBuf, "W/S:Nav Enter:Open");
}
@@ -883,13 +1181,16 @@ public:
AudiobookPlayerScreen(UITask* task, Audio* audio)
: _task(task), _audio(audio), _mode(FILE_LIST),
_sdReady(false), _i2sInitialized(false), _dacPowered(false),
_displayRef(nullptr),
_selectedFile(0), _scrollOffset(0),
_currentPath(AUDIOBOOKS_FOLDER),
_bookOpen(false), _isPlaying(false), _isPaused(false),
_volume(AB_DEFAULT_VOLUME),
_coverBitmap(nullptr), _coverW(0), _coverH(0), _hasCover(false),
_currentPosSec(0), _durationSec(0), _currentChapter(-1),
_lastPositionSave(0), _lastPosUpdate(0),
_pendingSeekSec(0), _streamReady(false),
_currentFileSize(0),
_m4bRenamed(false),
_transportSel(2), _showingInfo(false) {}
@@ -916,7 +1217,19 @@ public:
if (_durationSec == 0) {
uint32_t dur = _audio->getAudioFileDuration();
if (dur > 0) _durationSec = dur;
if (dur > 0) {
_durationSec = dur;
Serial.printf("AB: Duration from library: %us\n", dur);
} else {
// MP3 fallback: estimate from bitrate + file size
// getAudioFileDuration() returns 0 for MP3 (no native duration header)
uint32_t br = _audio->getBitRate();
if (br > 0 && _currentFileSize > 0) {
_durationSec = (uint32_t)((uint64_t)_currentFileSize * 8 / br);
Serial.printf("AB: Duration estimated from bitrate: %us (br=%u, sz=%u)\n",
_durationSec, br, _currentFileSize);
}
}
}
// Apply deferred seek once stream is ready
@@ -958,7 +1271,9 @@ public:
}
void enter(DisplayDriver& display) {
_displayRef = &display;
if (!_bookOpen) {
drawLoadingSplash();
scanFiles();
_selectedFile = 0;
_scrollOffset = 0;
@@ -1036,11 +1351,34 @@ public:
return false;
}
// Enter - open selected audiobook
// Enter - open selected item (directory or audiobook)
if (c == '\r' || c == 13) {
if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) {
openBook(_fileList[_selectedFile].name, nullptr);
const AudiobookFileEntry& entry = _fileList[_selectedFile];
if (entry.isDir) {
if (entry.name == "..") {
// Navigate up to parent
int lastSlash = _currentPath.lastIndexOf('/');
if (lastSlash > 0) {
_currentPath = _currentPath.substring(0, lastSlash);
} else {
_currentPath = AUDIOBOOKS_FOLDER;
}
} else {
// Navigate into subdirectory
_currentPath = _currentPath + "/" + entry.name;
}
// Rescan the new directory
scanFiles();
_selectedFile = 0;
_scrollOffset = 0;
return true;
} else {
// Open audiobook file
openBook(entry.name, nullptr);
return true;
}
}
return false;
}

View File

@@ -182,8 +182,10 @@ private:
// File list state
std::vector<String> _fileList;
std::vector<String> _dirList; // Subdirectories at current path
std::vector<FileCache> _fileCache;
int _selectedFile;
String _currentPath; // Current browsed directory
// Reading state
File _file;
@@ -391,8 +393,8 @@ private:
idxFile.read(&fullyFlag, 1);
idxFile.read((uint8_t*)&lastRead, 4);
// Verify file hasn't changed - try BOOKS_FOLDER first, then epub cache
String fullPath = String(BOOKS_FOLDER) + "/" + filename;
// Verify file hasn't changed - try current path first, then epub cache
String fullPath = _currentPath + "/" + filename;
File txtFile = SD.open(fullPath.c_str(), FILE_READ);
if (!txtFile) {
// Fallback: check epub cache directory
@@ -482,33 +484,94 @@ private:
// ---- File Scanning ----
// ---- Folder Navigation Helpers ----
bool isAtBooksRoot() const {
return _currentPath == String(BOOKS_FOLDER);
}
// Number of non-file entries at the start of the visual list
int dirEntryCount() const {
int count = _dirList.size();
if (!isAtBooksRoot()) count++; // ".." entry
return count;
}
// Total items in the visual list (parent + dirs + files)
int totalListItems() const {
return dirEntryCount() + (int)_fileList.size();
}
// What type of entry is at visual list index idx?
// Returns: 0 = ".." parent, 1 = directory, 2 = file
int itemTypeAt(int idx) const {
bool hasParent = !isAtBooksRoot();
if (hasParent && idx == 0) return 0; // ".."
int dirStart = hasParent ? 1 : 0;
if (idx < dirStart + (int)_dirList.size()) return 1; // directory
return 2; // file
}
// Get directory name for visual index (only valid when itemTypeAt == 1)
const String& dirNameAt(int idx) const {
int dirStart = isAtBooksRoot() ? 0 : 1;
return _dirList[idx - dirStart];
}
// Get file list index for visual index (only valid when itemTypeAt == 2)
int fileIndexAt(int idx) const {
return idx - dirEntryCount();
}
void navigateToParent() {
int lastSlash = _currentPath.lastIndexOf('/');
if (lastSlash > 0) {
_currentPath = _currentPath.substring(0, lastSlash);
} else {
_currentPath = BOOKS_FOLDER;
}
}
void navigateToChild(const String& dirName) {
_currentPath = _currentPath + "/" + dirName;
}
// ---- File Scanning ----
void scanFiles() {
_fileList.clear();
_dirList.clear();
if (!SD.exists(BOOKS_FOLDER)) {
SD.mkdir(BOOKS_FOLDER);
Serial.printf("TextReader: Created %s\n", BOOKS_FOLDER);
}
File root = SD.open(BOOKS_FOLDER);
File root = SD.open(_currentPath.c_str());
if (!root || !root.isDirectory()) return;
File f = root.openNextFile();
while (f && _fileList.size() < READER_MAX_FILES) {
if (!f.isDirectory()) {
while (f && (_fileList.size() + _dirList.size()) < READER_MAX_FILES) {
String name = String(f.name());
int slash = name.lastIndexOf('/');
if (slash >= 0) name = name.substring(slash + 1);
if (!name.startsWith(".") &&
(name.endsWith(".txt") || name.endsWith(".TXT") ||
name.endsWith(".epub") || name.endsWith(".EPUB"))) {
_fileList.push_back(name);
// Skip hidden files/dirs
if (name.startsWith(".")) {
f = root.openNextFile();
continue;
}
if (f.isDirectory()) {
_dirList.push_back(name);
} else if (name.endsWith(".txt") || name.endsWith(".TXT") ||
name.endsWith(".epub") || name.endsWith(".EPUB")) {
_fileList.push_back(name);
}
f = root.openNextFile();
}
root.close();
Serial.printf("TextReader: Found %d files\n", _fileList.size());
Serial.printf("TextReader: %s — %d dirs, %d files\n",
_currentPath.c_str(), (int)_dirList.size(), (int)_fileList.size());
}
// ---- Book Open/Close ----
@@ -518,7 +581,7 @@ private:
// ---- EPUB auto-conversion ----
String actualFilename = filename;
String actualFullPath = String(BOOKS_FOLDER) + "/" + filename;
String actualFullPath = _currentPath + "/" + filename;
bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB");
if (isEpub) {
@@ -755,15 +818,26 @@ private:
display.setCursor(0, 0);
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
if (isAtBooksRoot()) {
display.print("Text Reader");
} else {
// Show current subfolder name
int lastSlash = _currentPath.lastIndexOf('/');
String folderName = (lastSlash >= 0) ? _currentPath.substring(lastSlash + 1) : _currentPath;
char hdrBuf[20];
strncpy(hdrBuf, folderName.c_str(), 17);
hdrBuf[17] = '\0';
display.print(hdrBuf);
}
sprintf(tmp, "[%d]", (int)_fileList.size());
int totalItems = totalListItems();
sprintf(tmp, "[%d]", totalItems);
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
display.print(tmp);
display.drawRect(0, 11, display.width(), 1);
if (_fileList.size() == 0) {
if (totalItems == 0) {
display.setCursor(0, 18);
display.setColor(DisplayDriver::LIGHT);
display.print("No files found");
@@ -780,8 +854,8 @@ private:
if (maxVisible > 15) maxVisible = 15;
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
(int)_fileList.size() - maxVisible));
int endIdx = min((int)_fileList.size(), startIdx + maxVisible);
totalItems - maxVisible));
int endIdx = min(totalItems, startIdx + maxVisible);
int y = startY;
for (int i = startIdx; i < endIdx; i++) {
@@ -800,16 +874,29 @@ private:
// Set cursor AFTER fillRect so text draws on top of highlight
display.setCursor(0, y);
// Build display string: "> filename.txt *" (asterisk if has bookmark)
int type = itemTypeAt(i);
String line = selected ? "> " : " ";
String name = _fileList[i];
if (type == 0) {
// ".." parent directory
line += ".. (up)";
} else if (type == 1) {
// Subdirectory
line += "/" + dirNameAt(i);
// Truncate if needed
if ((int)line.length() > _charsPerLine) {
line = line.substring(0, _charsPerLine - 3) + "...";
}
} else {
// File
int fi = fileIndexAt(i);
String name = _fileList[fi];
// Check for resume indicator
String suffix = "";
for (int j = 0; j < (int)_fileCache.size(); j++) {
if (_fileCache[j].filename == name && _fileCache[j].lastReadPage > 0) {
if (fi < (int)_fileCache.size()) {
if (_fileCache[fi].filename == name && _fileCache[fi].lastReadPage > 0) {
suffix = " *";
break;
}
}
@@ -819,8 +906,9 @@ private:
name = name.substring(0, maxLen - 3) + "...";
}
line += name + suffix;
display.print(line.c_str());
}
display.print(line.c_str());
y += listLineH;
}
display.setTextSize(1); // Restore
@@ -928,7 +1016,8 @@ public:
_bootIndexed(false), _display(nullptr),
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
_headerHeight(14), _footerHeight(14),
_selectedFile(0), _fileOpen(false), _currentPage(0), _totalPages(0),
_selectedFile(0), _currentPath(BOOKS_FOLDER),
_fileOpen(false), _currentPage(0), _totalPages(0),
_pageBufLen(0), _contentDirty(true) {
}
@@ -1068,8 +1157,8 @@ public:
indexProgress++;
drawBootSplash(indexProgress, needsIndexCount, _fileList[i]);
// Try BOOKS_FOLDER first, then epub cache fallback
String fullPath = String(BOOKS_FOLDER) + "/" + _fileList[i];
// Try current path first, then epub cache fallback
String fullPath = _currentPath + "/" + _fileList[i];
File file = SD.open(fullPath.c_str(), FILE_READ);
if (!file) {
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
@@ -1166,6 +1255,8 @@ public:
}
bool handleFileListInput(char c) {
int total = totalListItems();
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_selectedFile > 0) {
@@ -1177,18 +1268,36 @@ public:
// S - scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_selectedFile < (int)_fileList.size() - 1) {
if (_selectedFile < total - 1) {
_selectedFile++;
return true;
}
return false;
}
// Enter - open selected file
// Enter - open selected item (directory or file)
if (c == '\r' || c == 13) {
if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) {
openBook(_fileList[_selectedFile]);
if (total == 0 || _selectedFile >= total) return false;
int type = itemTypeAt(_selectedFile);
if (type == 0) {
// ".." — navigate to parent
navigateToParent();
rescanAndIndex();
return true;
} else if (type == 1) {
// Subdirectory — navigate into it
navigateToChild(dirNameAt(_selectedFile));
rescanAndIndex();
return true;
} else {
// File — open it
int fi = fileIndexAt(_selectedFile);
if (fi >= 0 && fi < (int)_fileList.size()) {
openBook(_fileList[fi]);
return true;
}
}
return false;
}
@@ -1196,6 +1305,53 @@ public:
return false;
}
// Rescan current directory and re-index its files.
// Called when navigating into or out of a subfolder.
void rescanAndIndex() {
scanFiles();
_selectedFile = 0;
// Rebuild file cache for the new directory's files
_fileCache.clear();
_fileCache.resize(_fileList.size());
for (int i = 0; i < (int)_fileList.size(); i++) {
if (!loadIndex(_fileList[i], _fileCache[i])) {
// Not cached — skip EPUB auto-indexing here (it happens on open)
// For .txt files, index now
if (!(_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB"))) {
String fullPath = _currentPath + "/" + _fileList[i];
File file = SD.open(fullPath.c_str(), FILE_READ);
if (!file) {
// Try epub cache fallback
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
file = SD.open(cacheFallback.c_str(), FILE_READ);
}
if (file) {
FileCache& cache = _fileCache[i];
cache.filename = _fileList[i];
cache.fileSize = file.size();
cache.fullyIndexed = false;
cache.lastReadPage = 0;
cache.pagePositions.clear();
cache.pagePositions.push_back(0);
indexPagesWordWrap(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
PREINDEX_PAGES - 1);
cache.fullyIndexed = !file.available();
file.close();
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
cache.fullyIndexed, 0);
}
} else {
_fileCache[i].filename = "";
}
}
yield(); // Feed WDT between files
}
digitalWrite(SDCARD_CS, HIGH);
}
bool handleReadingInput(char c) {
// W/A - previous page
if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {

View File

@@ -80,7 +80,7 @@ build_flags =
-D PIN_DISPLAY_BL=45
-D PIN_USER_BTN=0
-D CST328_PIN_RST=38
-D FIRMWARE_VERSION='"Meck v0.8.7A"'
-D FIRMWARE_VERSION='"Meck v0.8.8A"'
-D ARDUINO_LOOP_STACK_SIZE=32768
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_TDeck_Pro>