mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
ui fixes for audiobook player, firmware version number updates, subdirectory support for both ereader and audiobook player file lists
This commit is contained in:
@@ -1,24 +1,41 @@
|
|||||||
## Audiobook Player (Audio variant only)
|
## Audiobook Player (Audio variant only)
|
||||||
|
|
||||||
Press **P** from the home screen to open the audiobook player.
|
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 |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| W / S | Scroll file list / Volume up-down |
|
| 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 |
|
| A | Seek back 30 seconds |
|
||||||
| D | Seek forward 30 seconds |
|
| D | Seek forward 30 seconds |
|
||||||
| [ | Previous chapter (M4B only) |
|
| [ | Previous chapter (M4B only) |
|
||||||
| ] | Next chapter (M4B only) |
|
| ] | Next chapter (M4B only) |
|
||||||
| Q | Leave player (audio continues) / Close book (when paused) / Exit (from file list) |
|
| 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
|
**Bookmarks** are saved automatically every 30 seconds during playback and when
|
||||||
you stop or exit. Reopening a book resumes from your last position.
|
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
|
**Cover art** from M4B files is displayed as dithered monochrome on the e-ink
|
||||||
screen, along with title, author, and chapter information.
|
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
|
### Background Playback
|
||||||
|
|
||||||
Audio continues playing when you leave the audiobook player screen. Press **Q**
|
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.
|
headphone jack.
|
||||||
|
|
||||||
> **Note:** The audiobook player is not available on the 4G modem variant
|
> **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
|
> due to I2S pin conflicts.
|
||||||
> compatibility with the ESP32-audioI2S library.
|
|
||||||
|
|
||||||
### SD Card Folder Structure
|
### SD Card Folder Structure
|
||||||
|
|
||||||
@@ -48,8 +64,13 @@ SD Card
|
|||||||
│ ├── .bookmarks/ (auto-created, stores resume positions)
|
│ ├── .bookmarks/ (auto-created, stores resume positions)
|
||||||
│ │ ├── mybook.bmk
|
│ │ ├── mybook.bmk
|
||||||
│ │ └── another.bmk
|
│ │ └── another.bmk
|
||||||
│ ├── mybook.m4b
|
│ ├── .metacache (auto-created, speeds up file list loading)
|
||||||
│ ├── another.m4b
|
│ ├── Ann Leckie/
|
||||||
|
│ │ ├── Ancillary Justice.mp3
|
||||||
|
│ │ └── Ancillary Sword.mp3
|
||||||
|
│ ├── Iain M. Banks/
|
||||||
|
│ │ └── The Algebraist.mp3
|
||||||
|
│ ├── mybook.mp3
|
||||||
│ └── podcast.mp3
|
│ └── podcast.mp3
|
||||||
├── books/ (existing — text reader)
|
├── books/ (existing — text reader)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef FIRMWARE_VERSION
|
#ifndef FIRMWARE_VERSION
|
||||||
#define FIRMWARE_VERSION "Meck v0.8.7A"
|
#define FIRMWARE_VERSION "Meck v0.8.8A"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class UITask;
|
|||||||
#define AB_DEFAULT_VOLUME 12 // 0-21 range for ESP32-audioI2S
|
#define AB_DEFAULT_VOLUME 12 // 0-21 range for ESP32-audioI2S
|
||||||
#define AB_SEEK_SECONDS 30 // Skip forward/back amount
|
#define AB_SEEK_SECONDS 30 // Skip forward/back amount
|
||||||
#define AB_POSITION_SAVE_INTERVAL 30000 // Auto-save bookmark every 30s
|
#define AB_POSITION_SAVE_INTERVAL 30000 // Auto-save bookmark every 30s
|
||||||
|
#define AB_METACACHE_FILE "/audiobooks/.metacache"
|
||||||
|
|
||||||
// Supported file extensions
|
// Supported file extensions
|
||||||
static bool isAudiobookFile(const String& name) {
|
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 {
|
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 hasBookmark;
|
||||||
|
bool isDir; // true for subdirectory entries
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -147,11 +153,13 @@ private:
|
|||||||
bool _sdReady;
|
bool _sdReady;
|
||||||
bool _i2sInitialized; // Track whether setPinout has been called
|
bool _i2sInitialized; // Track whether setPinout has been called
|
||||||
bool _dacPowered; // Track GPIO 41 DAC power state
|
bool _dacPowered; // Track GPIO 41 DAC power state
|
||||||
|
DisplayDriver* _displayRef; // Stored for splash screens during scan
|
||||||
|
|
||||||
// File browser state
|
// File browser state
|
||||||
std::vector<AudiobookFileEntry> _fileList;
|
std::vector<AudiobookFileEntry> _fileList;
|
||||||
int _selectedFile;
|
int _selectedFile;
|
||||||
int _scrollOffset;
|
int _scrollOffset;
|
||||||
|
String _currentPath; // Current browsed directory (starts as AUDIOBOOKS_FOLDER)
|
||||||
|
|
||||||
// Current book state
|
// Current book state
|
||||||
String _currentFile;
|
String _currentFile;
|
||||||
@@ -178,6 +186,9 @@ private:
|
|||||||
uint32_t _pendingSeekSec; // 0 = no pending seek
|
uint32_t _pendingSeekSec; // 0 = no pending seek
|
||||||
bool _streamReady; // Set true once library reports duration
|
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,
|
// M4B rename workaround — the audio library only recognises .m4a,
|
||||||
// so we temporarily rename .m4b files on the SD card for playback.
|
// so we temporarily rename .m4b files on the SD card for playback.
|
||||||
bool _m4bRenamed; // true if file was renamed for playback
|
bool _m4bRenamed; // true if file was renamed for playback
|
||||||
@@ -389,6 +400,110 @@ private:
|
|||||||
|
|
||||||
// ---- File Scanning ----
|
// ---- 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() {
|
void scanFiles() {
|
||||||
_fileList.clear();
|
_fileList.clear();
|
||||||
if (!SD.exists(AUDIOBOOKS_FOLDER)) {
|
if (!SD.exists(AUDIOBOOKS_FOLDER)) {
|
||||||
@@ -396,30 +511,142 @@ private:
|
|||||||
Serial.printf("AB: Created %s\n", AUDIOBOOKS_FOLDER);
|
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;
|
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();
|
File f = root.openNextFile();
|
||||||
while (f && _fileList.size() < AB_MAX_FILES) {
|
while (f && (dirs.size() + files.size()) < AB_MAX_FILES) {
|
||||||
if (!f.isDirectory()) {
|
|
||||||
String name = String(f.name());
|
String name = String(f.name());
|
||||||
int slash = name.lastIndexOf('/');
|
int slash = name.lastIndexOf('/');
|
||||||
if (slash >= 0) name = name.substring(slash + 1);
|
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;
|
AudiobookFileEntry entry;
|
||||||
entry.name = name;
|
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);
|
String bmkPath = getBookmarkPath(name);
|
||||||
entry.hasBookmark = SD.exists(bmkPath.c_str());
|
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();
|
f = root.openNextFile();
|
||||||
}
|
}
|
||||||
root.close();
|
root.close();
|
||||||
digitalWrite(SDCARD_CS, HIGH);
|
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 ----
|
// ---- Book Open / Close ----
|
||||||
@@ -457,10 +684,17 @@ private:
|
|||||||
_pendingSeekSec = 0;
|
_pendingSeekSec = 0;
|
||||||
_streamReady = false;
|
_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
|
yield(); // Feed WDT between heavy operations
|
||||||
|
|
||||||
// Parse metadata
|
// Parse metadata
|
||||||
String fullPath = String(AUDIOBOOKS_FOLDER) + "/" + filename;
|
String fullPath = _currentPath + "/" + filename;
|
||||||
File file = SD.open(fullPath.c_str(), FILE_READ);
|
File file = SD.open(fullPath.c_str(), FILE_READ);
|
||||||
if (file) {
|
if (file) {
|
||||||
String lower = filename;
|
String lower = filename;
|
||||||
@@ -538,7 +772,7 @@ private:
|
|||||||
// Ensure I2S is configured (once only, before first connecttoFS)
|
// Ensure I2S is configured (once only, before first connecttoFS)
|
||||||
ensureI2SInit();
|
ensureI2SInit();
|
||||||
|
|
||||||
String fullPath = String(AUDIOBOOKS_FOLDER) + "/" + _currentFile;
|
String fullPath = _currentPath + "/" + _currentFile;
|
||||||
|
|
||||||
// M4B workaround: the ESP32-audioI2S library only recognises .m4a
|
// M4B workaround: the ESP32-audioI2S library only recognises .m4a
|
||||||
// for MP4/AAC container parsing. M4B is identical but the extension
|
// for MP4/AAC container parsing. M4B is identical but the extension
|
||||||
@@ -550,7 +784,7 @@ private:
|
|||||||
if (lower.endsWith(".m4b")) {
|
if (lower.endsWith(".m4b")) {
|
||||||
String m4aFile = _currentFile.substring(0, _currentFile.length() - 1) + "a";
|
String m4aFile = _currentFile.substring(0, _currentFile.length() - 1) + "a";
|
||||||
_m4bOrigPath = fullPath;
|
_m4bOrigPath = fullPath;
|
||||||
_m4bTempPath = String(AUDIOBOOKS_FOLDER) + "/" + m4aFile;
|
_m4bTempPath = _currentPath + "/" + m4aFile;
|
||||||
|
|
||||||
if (SD.rename(_m4bOrigPath.c_str(), _m4bTempPath.c_str())) {
|
if (SD.rename(_m4bOrigPath.c_str(), _m4bTempPath.c_str())) {
|
||||||
Serial.printf("AB: Renamed '%s' -> '%s' for playback\n",
|
Serial.printf("AB: Renamed '%s' -> '%s' for playback\n",
|
||||||
@@ -701,10 +935,13 @@ private:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate visible items — reserve footerHeight=14 at bottom
|
// Switch to tiny font for file list (6x8 built-in)
|
||||||
int itemHeight = 10;
|
display.setTextSize(0);
|
||||||
|
|
||||||
|
// Calculate visible items — tiny font uses ~8 virtual units per line
|
||||||
|
int itemHeight = 8;
|
||||||
int listTop = 13;
|
int listTop = 13;
|
||||||
int listBottom = display.height() - 14;
|
int listBottom = display.height() - 14; // Reserve footer space
|
||||||
int visibleItems = (listBottom - listTop) / itemHeight;
|
int visibleItems = (listBottom - listTop) / itemHeight;
|
||||||
|
|
||||||
// Keep selection visible
|
// Keep selection visible
|
||||||
@@ -714,6 +951,9 @@ private:
|
|||||||
_scrollOffset = _selectedFile - visibleItems + 1;
|
_scrollOffset = _selectedFile - visibleItems + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Approx chars that fit in tiny font (~36 on 128 virtual width)
|
||||||
|
const int charsPerLine = 36;
|
||||||
|
|
||||||
// Draw file list
|
// Draw file list
|
||||||
for (int i = 0; i < visibleItems && (_scrollOffset + i) < (int)_fileList.size(); i++) {
|
for (int i = 0; i < visibleItems && (_scrollOffset + i) < (int)_fileList.size(); i++) {
|
||||||
int fileIdx = _scrollOffset + i;
|
int fileIdx = _scrollOffset + i;
|
||||||
@@ -721,31 +961,76 @@ private:
|
|||||||
|
|
||||||
if (fileIdx == _selectedFile) {
|
if (fileIdx == _selectedFile) {
|
||||||
display.setColor(DisplayDriver::LIGHT);
|
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);
|
display.setColor(DisplayDriver::DARK);
|
||||||
} else {
|
} else {
|
||||||
display.setColor(DisplayDriver::LIGHT);
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display filename without extension
|
// Build display string based on entry type
|
||||||
String name = _fileList[fileIdx].name;
|
const AudiobookFileEntry& fe = _fileList[fileIdx];
|
||||||
int dot = name.lastIndexOf('.');
|
char fullLine[96];
|
||||||
if (dot > 0) name = name.substring(0, dot);
|
|
||||||
if (name.length() > 20) {
|
if (fe.isDir) {
|
||||||
name = name.substring(0, 17) + "...";
|
// 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.setCursor(2, y);
|
||||||
display.print(name.c_str());
|
display.print(fullLine);
|
||||||
|
|
||||||
// Bookmark indicator (cached from scanFiles — no SD access)
|
// Bookmark indicator (right-aligned, files only)
|
||||||
if (_fileList[fileIdx].hasBookmark) {
|
if (!fe.isDir && fe.hasBookmark) {
|
||||||
display.setCursor(display.width() - 8, y);
|
display.setCursor(display.width() - 8, y);
|
||||||
display.print(">");
|
display.print(">");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scrollbar
|
// Scrollbar (if needed)
|
||||||
if ((int)_fileList.size() > visibleItems) {
|
if ((int)_fileList.size() > visibleItems) {
|
||||||
int barH = listBottom - listTop;
|
int barH = listBottom - listTop;
|
||||||
int thumbH = max(4, barH * visibleItems / (int)_fileList.size());
|
int thumbH = max(4, barH * visibleItems / (int)_fileList.size());
|
||||||
@@ -756,9 +1041,22 @@ private:
|
|||||||
display.fillRect(display.width() - 1, thumbY, 1, thumbH);
|
display.fillRect(display.width() - 1, thumbY, 1, thumbH);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer
|
// Footer (stays at size 1 for readability)
|
||||||
char leftBuf[20];
|
char leftBuf[32];
|
||||||
|
if (_currentPath == String(AUDIOBOOKS_FOLDER)) {
|
||||||
snprintf(leftBuf, sizeof(leftBuf), "%d files", (int)_fileList.size());
|
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");
|
drawFooter(display, leftBuf, "W/S:Nav Enter:Open");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,13 +1181,16 @@ public:
|
|||||||
AudiobookPlayerScreen(UITask* task, Audio* audio)
|
AudiobookPlayerScreen(UITask* task, Audio* audio)
|
||||||
: _task(task), _audio(audio), _mode(FILE_LIST),
|
: _task(task), _audio(audio), _mode(FILE_LIST),
|
||||||
_sdReady(false), _i2sInitialized(false), _dacPowered(false),
|
_sdReady(false), _i2sInitialized(false), _dacPowered(false),
|
||||||
|
_displayRef(nullptr),
|
||||||
_selectedFile(0), _scrollOffset(0),
|
_selectedFile(0), _scrollOffset(0),
|
||||||
|
_currentPath(AUDIOBOOKS_FOLDER),
|
||||||
_bookOpen(false), _isPlaying(false), _isPaused(false),
|
_bookOpen(false), _isPlaying(false), _isPaused(false),
|
||||||
_volume(AB_DEFAULT_VOLUME),
|
_volume(AB_DEFAULT_VOLUME),
|
||||||
_coverBitmap(nullptr), _coverW(0), _coverH(0), _hasCover(false),
|
_coverBitmap(nullptr), _coverW(0), _coverH(0), _hasCover(false),
|
||||||
_currentPosSec(0), _durationSec(0), _currentChapter(-1),
|
_currentPosSec(0), _durationSec(0), _currentChapter(-1),
|
||||||
_lastPositionSave(0), _lastPosUpdate(0),
|
_lastPositionSave(0), _lastPosUpdate(0),
|
||||||
_pendingSeekSec(0), _streamReady(false),
|
_pendingSeekSec(0), _streamReady(false),
|
||||||
|
_currentFileSize(0),
|
||||||
_m4bRenamed(false),
|
_m4bRenamed(false),
|
||||||
_transportSel(2), _showingInfo(false) {}
|
_transportSel(2), _showingInfo(false) {}
|
||||||
|
|
||||||
@@ -916,7 +1217,19 @@ public:
|
|||||||
|
|
||||||
if (_durationSec == 0) {
|
if (_durationSec == 0) {
|
||||||
uint32_t dur = _audio->getAudioFileDuration();
|
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
|
// Apply deferred seek once stream is ready
|
||||||
@@ -958,7 +1271,9 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
void enter(DisplayDriver& display) {
|
void enter(DisplayDriver& display) {
|
||||||
|
_displayRef = &display;
|
||||||
if (!_bookOpen) {
|
if (!_bookOpen) {
|
||||||
|
drawLoadingSplash();
|
||||||
scanFiles();
|
scanFiles();
|
||||||
_selectedFile = 0;
|
_selectedFile = 0;
|
||||||
_scrollOffset = 0;
|
_scrollOffset = 0;
|
||||||
@@ -1036,11 +1351,34 @@ public:
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter - open selected audiobook
|
// Enter - open selected item (directory or audiobook)
|
||||||
if (c == '\r' || c == 13) {
|
if (c == '\r' || c == 13) {
|
||||||
if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) {
|
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;
|
return true;
|
||||||
|
} else {
|
||||||
|
// Open audiobook file
|
||||||
|
openBook(entry.name, nullptr);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,8 +182,10 @@ private:
|
|||||||
|
|
||||||
// File list state
|
// File list state
|
||||||
std::vector<String> _fileList;
|
std::vector<String> _fileList;
|
||||||
|
std::vector<String> _dirList; // Subdirectories at current path
|
||||||
std::vector<FileCache> _fileCache;
|
std::vector<FileCache> _fileCache;
|
||||||
int _selectedFile;
|
int _selectedFile;
|
||||||
|
String _currentPath; // Current browsed directory
|
||||||
|
|
||||||
// Reading state
|
// Reading state
|
||||||
File _file;
|
File _file;
|
||||||
@@ -391,8 +393,8 @@ private:
|
|||||||
idxFile.read(&fullyFlag, 1);
|
idxFile.read(&fullyFlag, 1);
|
||||||
idxFile.read((uint8_t*)&lastRead, 4);
|
idxFile.read((uint8_t*)&lastRead, 4);
|
||||||
|
|
||||||
// Verify file hasn't changed - try BOOKS_FOLDER first, then epub cache
|
// Verify file hasn't changed - try current path first, then epub cache
|
||||||
String fullPath = String(BOOKS_FOLDER) + "/" + filename;
|
String fullPath = _currentPath + "/" + filename;
|
||||||
File txtFile = SD.open(fullPath.c_str(), FILE_READ);
|
File txtFile = SD.open(fullPath.c_str(), FILE_READ);
|
||||||
if (!txtFile) {
|
if (!txtFile) {
|
||||||
// Fallback: check epub cache directory
|
// Fallback: check epub cache directory
|
||||||
@@ -482,33 +484,94 @@ private:
|
|||||||
|
|
||||||
// ---- File Scanning ----
|
// ---- 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() {
|
void scanFiles() {
|
||||||
_fileList.clear();
|
_fileList.clear();
|
||||||
|
_dirList.clear();
|
||||||
if (!SD.exists(BOOKS_FOLDER)) {
|
if (!SD.exists(BOOKS_FOLDER)) {
|
||||||
SD.mkdir(BOOKS_FOLDER);
|
SD.mkdir(BOOKS_FOLDER);
|
||||||
Serial.printf("TextReader: Created %s\n", 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;
|
if (!root || !root.isDirectory()) return;
|
||||||
|
|
||||||
File f = root.openNextFile();
|
File f = root.openNextFile();
|
||||||
while (f && _fileList.size() < READER_MAX_FILES) {
|
while (f && (_fileList.size() + _dirList.size()) < READER_MAX_FILES) {
|
||||||
if (!f.isDirectory()) {
|
|
||||||
String name = String(f.name());
|
String name = String(f.name());
|
||||||
int slash = name.lastIndexOf('/');
|
int slash = name.lastIndexOf('/');
|
||||||
if (slash >= 0) name = name.substring(slash + 1);
|
if (slash >= 0) name = name.substring(slash + 1);
|
||||||
|
|
||||||
if (!name.startsWith(".") &&
|
// Skip hidden files/dirs
|
||||||
(name.endsWith(".txt") || name.endsWith(".TXT") ||
|
if (name.startsWith(".")) {
|
||||||
name.endsWith(".epub") || name.endsWith(".EPUB"))) {
|
f = root.openNextFile();
|
||||||
_fileList.push_back(name);
|
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();
|
f = root.openNextFile();
|
||||||
}
|
}
|
||||||
root.close();
|
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 ----
|
// ---- Book Open/Close ----
|
||||||
@@ -518,7 +581,7 @@ private:
|
|||||||
|
|
||||||
// ---- EPUB auto-conversion ----
|
// ---- EPUB auto-conversion ----
|
||||||
String actualFilename = filename;
|
String actualFilename = filename;
|
||||||
String actualFullPath = String(BOOKS_FOLDER) + "/" + filename;
|
String actualFullPath = _currentPath + "/" + filename;
|
||||||
bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB");
|
bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB");
|
||||||
|
|
||||||
if (isEpub) {
|
if (isEpub) {
|
||||||
@@ -755,15 +818,26 @@ private:
|
|||||||
display.setCursor(0, 0);
|
display.setCursor(0, 0);
|
||||||
display.setTextSize(1);
|
display.setTextSize(1);
|
||||||
display.setColor(DisplayDriver::GREEN);
|
display.setColor(DisplayDriver::GREEN);
|
||||||
|
if (isAtBooksRoot()) {
|
||||||
display.print("Text Reader");
|
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.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
||||||
display.print(tmp);
|
display.print(tmp);
|
||||||
|
|
||||||
display.drawRect(0, 11, display.width(), 1);
|
display.drawRect(0, 11, display.width(), 1);
|
||||||
|
|
||||||
if (_fileList.size() == 0) {
|
if (totalItems == 0) {
|
||||||
display.setCursor(0, 18);
|
display.setCursor(0, 18);
|
||||||
display.setColor(DisplayDriver::LIGHT);
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
display.print("No files found");
|
display.print("No files found");
|
||||||
@@ -780,8 +854,8 @@ private:
|
|||||||
if (maxVisible > 15) maxVisible = 15;
|
if (maxVisible > 15) maxVisible = 15;
|
||||||
|
|
||||||
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
|
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
|
||||||
(int)_fileList.size() - maxVisible));
|
totalItems - maxVisible));
|
||||||
int endIdx = min((int)_fileList.size(), startIdx + maxVisible);
|
int endIdx = min(totalItems, startIdx + maxVisible);
|
||||||
|
|
||||||
int y = startY;
|
int y = startY;
|
||||||
for (int i = startIdx; i < endIdx; i++) {
|
for (int i = startIdx; i < endIdx; i++) {
|
||||||
@@ -800,16 +874,29 @@ private:
|
|||||||
// Set cursor AFTER fillRect so text draws on top of highlight
|
// Set cursor AFTER fillRect so text draws on top of highlight
|
||||||
display.setCursor(0, y);
|
display.setCursor(0, y);
|
||||||
|
|
||||||
// Build display string: "> filename.txt *" (asterisk if has bookmark)
|
int type = itemTypeAt(i);
|
||||||
String line = selected ? "> " : " ";
|
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
|
// Check for resume indicator
|
||||||
String suffix = "";
|
String suffix = "";
|
||||||
for (int j = 0; j < (int)_fileCache.size(); j++) {
|
if (fi < (int)_fileCache.size()) {
|
||||||
if (_fileCache[j].filename == name && _fileCache[j].lastReadPage > 0) {
|
if (_fileCache[fi].filename == name && _fileCache[fi].lastReadPage > 0) {
|
||||||
suffix = " *";
|
suffix = " *";
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,8 +906,9 @@ private:
|
|||||||
name = name.substring(0, maxLen - 3) + "...";
|
name = name.substring(0, maxLen - 3) + "...";
|
||||||
}
|
}
|
||||||
line += name + suffix;
|
line += name + suffix;
|
||||||
display.print(line.c_str());
|
}
|
||||||
|
|
||||||
|
display.print(line.c_str());
|
||||||
y += listLineH;
|
y += listLineH;
|
||||||
}
|
}
|
||||||
display.setTextSize(1); // Restore
|
display.setTextSize(1); // Restore
|
||||||
@@ -928,7 +1016,8 @@ public:
|
|||||||
_bootIndexed(false), _display(nullptr),
|
_bootIndexed(false), _display(nullptr),
|
||||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
|
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
|
||||||
_headerHeight(14), _footerHeight(14),
|
_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) {
|
_pageBufLen(0), _contentDirty(true) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,8 +1157,8 @@ public:
|
|||||||
indexProgress++;
|
indexProgress++;
|
||||||
drawBootSplash(indexProgress, needsIndexCount, _fileList[i]);
|
drawBootSplash(indexProgress, needsIndexCount, _fileList[i]);
|
||||||
|
|
||||||
// Try BOOKS_FOLDER first, then epub cache fallback
|
// Try current path first, then epub cache fallback
|
||||||
String fullPath = String(BOOKS_FOLDER) + "/" + _fileList[i];
|
String fullPath = _currentPath + "/" + _fileList[i];
|
||||||
File file = SD.open(fullPath.c_str(), FILE_READ);
|
File file = SD.open(fullPath.c_str(), FILE_READ);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
|
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
|
||||||
@@ -1166,6 +1255,8 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool handleFileListInput(char c) {
|
bool handleFileListInput(char c) {
|
||||||
|
int total = totalListItems();
|
||||||
|
|
||||||
// W - scroll up
|
// W - scroll up
|
||||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||||
if (_selectedFile > 0) {
|
if (_selectedFile > 0) {
|
||||||
@@ -1177,18 +1268,36 @@ public:
|
|||||||
|
|
||||||
// S - scroll down
|
// S - scroll down
|
||||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||||
if (_selectedFile < (int)_fileList.size() - 1) {
|
if (_selectedFile < total - 1) {
|
||||||
_selectedFile++;
|
_selectedFile++;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter - open selected file
|
// Enter - open selected item (directory or file)
|
||||||
if (c == '\r' || c == 13) {
|
if (c == '\r' || c == 13) {
|
||||||
if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) {
|
if (total == 0 || _selectedFile >= total) return false;
|
||||||
openBook(_fileList[_selectedFile]);
|
|
||||||
|
int type = itemTypeAt(_selectedFile);
|
||||||
|
|
||||||
|
if (type == 0) {
|
||||||
|
// ".." — navigate to parent
|
||||||
|
navigateToParent();
|
||||||
|
rescanAndIndex();
|
||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1196,6 +1305,53 @@ public:
|
|||||||
return false;
|
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) {
|
bool handleReadingInput(char c) {
|
||||||
// W/A - previous page
|
// W/A - previous page
|
||||||
if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {
|
if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ build_flags =
|
|||||||
-D PIN_DISPLAY_BL=45
|
-D PIN_DISPLAY_BL=45
|
||||||
-D PIN_USER_BTN=0
|
-D PIN_USER_BTN=0
|
||||||
-D CST328_PIN_RST=38
|
-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
|
-D ARDUINO_LOOP_STACK_SIZE=32768
|
||||||
build_src_filter = ${esp32_base.build_src_filter}
|
build_src_filter = ${esp32_base.build_src_filter}
|
||||||
+<../variants/LilyGo_TDeck_Pro>
|
+<../variants/LilyGo_TDeck_Pro>
|
||||||
|
|||||||
Reference in New Issue
Block a user