mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
9 Commits
consolidat
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67cb93e643 | ||
|
|
51f770ba74 | ||
|
|
65c7bff963 | ||
|
|
e056ea3c2c | ||
|
|
b2967fc1a7 | ||
|
|
addcbcd00e | ||
|
|
8cdf19a848 | ||
|
|
5019d12fb0 | ||
|
|
306e9815b4 |
@@ -1,78 +0,0 @@
|
||||
## Audiobook Player (Audio variant only)
|
||||
|
||||
Press **P** from the home screen to open the audiobook player.
|
||||
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 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**
|
||||
while audio is playing to return to the home screen — a **>>** indicator will
|
||||
appear in the status bar next to the battery icon to show that audio is active
|
||||
in the background. Press **P** at any time to return to the player screen and
|
||||
resume control.
|
||||
|
||||
If you pause or stop playback first and then press **Q**, the book is closed
|
||||
and you're returned to the file list instead.
|
||||
|
||||
### Audio Hardware
|
||||
|
||||
The audiobook player uses the PCM5102A I2S DAC on the audio variant of the
|
||||
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.
|
||||
|
||||
### SD Card Folder Structure
|
||||
|
||||
```
|
||||
SD Card
|
||||
├── audiobooks/
|
||||
│ ├── .bookmarks/ (auto-created, stores resume positions)
|
||||
│ │ ├── mybook.bmk
|
||||
│ │ └── another.bmk
|
||||
│ ├── .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)
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
@@ -40,8 +40,7 @@ public:
|
||||
void enableSerial() { _serial->enable(); }
|
||||
void disableSerial() { _serial->disable(); }
|
||||
virtual void msgRead(int msgcount) = 0;
|
||||
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
|
||||
const uint8_t* path = nullptr) = 0;
|
||||
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0;
|
||||
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||
virtual void loop() = 0;
|
||||
virtual void showAlert(const char* text, int duration_millis) {}
|
||||
|
||||
@@ -439,8 +439,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
||||
// we only want to show text messages on display, not cli data
|
||||
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
|
||||
if (should_display && _ui) {
|
||||
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path);
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len);
|
||||
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled
|
||||
}
|
||||
#endif
|
||||
@@ -582,8 +581,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
|
||||
channel_name = channel_details.name;
|
||||
}
|
||||
if (_ui) {
|
||||
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
|
||||
_ui->newMsg(path_len, channel_name, text, offline_queue_len, msg_path);
|
||||
_ui->newMsg(path_len, channel_name, text, offline_queue_len);
|
||||
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::channelMessage); //buzz if enabled
|
||||
}
|
||||
#endif
|
||||
@@ -1183,8 +1181,6 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
memcpy(&msg_timestamp, &cmd_frame[i], 4);
|
||||
i += 4;
|
||||
const char *text = (char *)&cmd_frame[i];
|
||||
int text_len = len - i;
|
||||
cmd_frame[len] = '\0'; // Null-terminate for C string use
|
||||
|
||||
if (txt_type != TXT_TYPE_PLAIN) {
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
@@ -1193,11 +1189,6 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
bool success = getChannel(channel_idx, channel);
|
||||
if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) {
|
||||
writeOKFrame();
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_ui) {
|
||||
_ui->addSentChannelMessage(channel_idx, _prefs.node_name, text);
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "20 Feb 2026"
|
||||
#define FIRMWARE_BUILD_DATE "15 Feb 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.9.1A"
|
||||
#define FIRMWARE_VERSION "Meck v0.8.9"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
|
||||
@@ -48,15 +48,6 @@
|
||||
// Notes mode state
|
||||
static bool notesMode = false;
|
||||
|
||||
// Audiobook player — Audio object is heap-allocated on first use to avoid
|
||||
// consuming ~40KB of DMA/decode buffers at boot (starves BLE stack).
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#include "AudiobookPlayerScreen.h"
|
||||
#include "Audio.h"
|
||||
Audio* audio = nullptr;
|
||||
static bool audiobookMode = false;
|
||||
#endif
|
||||
|
||||
// Power management
|
||||
#if HAS_GPS
|
||||
GPSDutyCycle gpsDuty;
|
||||
@@ -330,9 +321,6 @@ void setup() {
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("setup() - radio_init() done");
|
||||
|
||||
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
|
||||
cpuPower.begin();
|
||||
|
||||
MESH_DEBUG_PRINTLN("setup() - about to call fast_rng.begin()");
|
||||
fast_rng.begin(radio_get_rng_seed());
|
||||
MESH_DEBUG_PRINTLN("setup() - fast_rng.begin() done");
|
||||
@@ -531,11 +519,6 @@ void setup() {
|
||||
notesScr->setSDReady(true);
|
||||
}
|
||||
|
||||
// Audiobook player screen creation is deferred to first use (case 'p' in
|
||||
// handleKeyboardInput) to avoid allocating Audio I2S/DMA buffers at boot,
|
||||
// which would starve BLE of heap memory.
|
||||
MESH_DEBUG_PRINTLN("setup() - Audiobook player deferred (lazy init on first use)");
|
||||
|
||||
// Do an initial settings backup to SD (captures any first-boot defaults)
|
||||
backupSettingsToSD();
|
||||
}
|
||||
@@ -573,6 +556,9 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// CPU frequency scaling  drop to 80 MHz for idle mesh listening
|
||||
cpuPower.begin();
|
||||
|
||||
// T-Deck Pro: BLE starts disabled for standalone-first operation
|
||||
// User can toggle it on from the Bluetooth home page (Enter or long-press)
|
||||
#if defined(LilyGo_TDeck_Pro) && defined(BLE_PIN_CODE)
|
||||
@@ -580,8 +566,6 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
|
||||
#endif
|
||||
|
||||
Serial.printf("setup() complete — free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
|
||||
}
|
||||
|
||||
@@ -605,21 +589,6 @@ void loop() {
|
||||
|
||||
// CPU frequency auto-timeout back to idle
|
||||
cpuPower.loop();
|
||||
|
||||
// Audiobook: service audio decode regardless of which screen is active
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
{
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
if (abPlayer) {
|
||||
abPlayer->audioTick();
|
||||
// Keep CPU at high freq during active audio decode
|
||||
if (abPlayer->isAudioActive()) {
|
||||
cpuPower.setBoost();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Skip UITask rendering when in compose mode to prevent flickering
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
@@ -647,12 +616,9 @@ void loop() {
|
||||
composeNeedsRefresh = false;
|
||||
}
|
||||
}
|
||||
// Track reader/notes/audiobook mode state for key routing
|
||||
// Track reader/notes mode state for key routing
|
||||
readerMode = ui_task.isOnTextReader();
|
||||
notesMode = ui_task.isOnNotesScreen();
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
audiobookMode = ui_task.isOnAudiobookPlayer();
|
||||
#endif
|
||||
#else
|
||||
ui_task.loop();
|
||||
#endif
|
||||
@@ -842,42 +808,6 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// *** AUDIOBOOK MODE ***
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
if (audiobookMode) {
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
|
||||
// Q key: behavior depends on playback state
|
||||
// - Playing: navigate home, audio continues in background
|
||||
// - Paused/stopped: close book, return to file list
|
||||
// - File list: exit player entirely
|
||||
if (key == 'q') {
|
||||
if (abPlayer->isBookOpen()) {
|
||||
if (abPlayer->isAudioActive()) {
|
||||
// Audio is playing — leave screen, audio continues via audioTick()
|
||||
Serial.println("Leaving audiobook player (audio continues in background)");
|
||||
ui_task.gotoHomeScreen();
|
||||
} else {
|
||||
// Paused or stopped — close book, show file list
|
||||
abPlayer->closeCurrentBook();
|
||||
Serial.println("Closed audiobook (was paused/stopped)");
|
||||
// Stay on audiobook screen showing file list
|
||||
}
|
||||
} else {
|
||||
abPlayer->exitPlayer();
|
||||
Serial.println("Exiting audiobook player");
|
||||
ui_task.gotoHomeScreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All other keys pass through to the player screen
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
#endif // MECK_AUDIO_VARIANT
|
||||
|
||||
// *** TEXT READER MODE ***
|
||||
if (readerMode) {
|
||||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||||
@@ -1128,26 +1058,6 @@ void handleKeyboardInput() {
|
||||
ui_task.gotoTextReader();
|
||||
break;
|
||||
|
||||
case 'p':
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
// Open audiobook player -- lazy-init Audio + screen on first use
|
||||
Serial.println("Opening audiobook player");
|
||||
if (!ui_task.getAudiobookScreen()) {
|
||||
Serial.printf("Audiobook: lazy init -- free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
audio = new Audio();
|
||||
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio);
|
||||
abScreen->setSDReady(sdCardReady);
|
||||
ui_task.setAudiobookScreen(abScreen);
|
||||
Serial.printf("Audiobook: init complete -- free heap: %d\n", ESP.getFreeHeap());
|
||||
}
|
||||
ui_task.gotoAudiobookPlayer();
|
||||
#else
|
||||
Serial.println("Audio not available on this build variant");
|
||||
ui_task.showAlert("No audio hardware", 1500);
|
||||
#endif
|
||||
break;
|
||||
|
||||
case 'n':
|
||||
// Open notes
|
||||
Serial.println("Opening notes");
|
||||
@@ -1204,7 +1114,7 @@ void handleKeyboardInput() {
|
||||
|
||||
case '\r':
|
||||
// Select/Enter - if on contacts screen, enter DM compose for chat contacts
|
||||
// or repeater admin for repeater contacts
|
||||
// or repeater admin for repeater contacts
|
||||
if (ui_task.isOnContactsScreen()) {
|
||||
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
||||
int idx = cs->getSelectedContactIdx();
|
||||
@@ -1230,11 +1140,6 @@ void handleKeyboardInput() {
|
||||
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
|
||||
}
|
||||
} else if (ui_task.isOnChannelScreen()) {
|
||||
// Don't enter compose if path overlay is showing
|
||||
ChannelScreen* chScr2 = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (chScr2 && chScr2->isShowingPathOverlay()) {
|
||||
break;
|
||||
}
|
||||
composeDM = false;
|
||||
composeDMContactIdx = -1;
|
||||
composeChannelIdx = ui_task.getChannelScreenViewIdx();
|
||||
@@ -1252,14 +1157,6 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'q':
|
||||
case '\b':
|
||||
// If channel screen path overlay is showing, dismiss it instead of going home
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (chScr && chScr->isShowingPathOverlay()) {
|
||||
ui_task.injectKey('q');
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Go back to home screen (admin mode handled above)
|
||||
Serial.println("Nav: Back to home");
|
||||
ui_task.gotoHomeScreen();
|
||||
@@ -1275,13 +1172,6 @@ void handleKeyboardInput() {
|
||||
// UTC offset edit (home screen GPS page handles this)
|
||||
ui_task.injectKey('u');
|
||||
break;
|
||||
|
||||
case 'v':
|
||||
// View path overlay (channel screen only)
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ui_task.injectKey('v');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
|
||||
@@ -1477,23 +1367,4 @@ void sendComposedMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ESP32-audioI2S CALLBACKS
|
||||
// ============================================================================
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
void audio_info(const char *info) {
|
||||
Serial.printf("Audio: %s\n", info);
|
||||
}
|
||||
|
||||
void audio_eof_mp3(const char *info) {
|
||||
Serial.printf("Audio: End of file - %s\n", info);
|
||||
// Signal the player screen for auto-advance to next track
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
if (abPlayer) {
|
||||
abPlayer->onEOF();
|
||||
}
|
||||
}
|
||||
#endif // MECK_AUDIO_VARIANT
|
||||
|
||||
#endif // LilyGo_TDeck_Pro
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@
|
||||
// Maximum messages to store in history
|
||||
#define CHANNEL_MSG_HISTORY_SIZE 300
|
||||
#define CHANNEL_MSG_TEXT_LEN 160
|
||||
#define MSG_PATH_MAX 8 // Max repeater hops stored per message
|
||||
|
||||
#ifndef MAX_GROUP_CHANNELS
|
||||
#define MAX_GROUP_CHANNELS 20
|
||||
@@ -24,7 +23,7 @@
|
||||
// On-disk format for message persistence (SD card)
|
||||
// ---------------------------------------------------------------------------
|
||||
#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store
|
||||
#define MSG_FILE_VERSION 2
|
||||
#define MSG_FILE_VERSION 1
|
||||
#define MSG_FILE_PATH "/meshcore/messages.bin"
|
||||
|
||||
struct __attribute__((packed)) MsgFileHeader {
|
||||
@@ -42,9 +41,8 @@ struct __attribute__((packed)) MsgFileRecord {
|
||||
uint8_t channel_idx;
|
||||
uint8_t valid;
|
||||
uint8_t reserved;
|
||||
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes (first byte of pub key)
|
||||
char text[CHANNEL_MSG_TEXT_LEN];
|
||||
// 176 bytes total
|
||||
// 168 bytes total
|
||||
};
|
||||
|
||||
class UITask; // Forward declaration
|
||||
@@ -57,7 +55,6 @@ public:
|
||||
uint32_t timestamp;
|
||||
uint8_t path_len;
|
||||
uint8_t channel_idx; // Which channel this message belongs to
|
||||
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes
|
||||
char text[CHANNEL_MSG_TEXT_LEN];
|
||||
bool valid;
|
||||
};
|
||||
@@ -73,24 +70,21 @@ private:
|
||||
int _msgsPerPage; // Messages that fit on screen
|
||||
uint8_t _viewChannelIdx; // Which channel we're currently viewing
|
||||
bool _sdReady; // SD card is available for persistence
|
||||
bool _showPathOverlay; // Show path detail overlay for last received msg
|
||||
|
||||
public:
|
||||
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
|
||||
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false) {
|
||||
_msgsPerPage(CHANNEL_MSG_HISTORY_SIZE), _viewChannelIdx(0), _sdReady(false) {
|
||||
// Initialize all messages as invalid
|
||||
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
_messages[i].valid = false;
|
||||
memset(_messages[i].path, 0, MSG_PATH_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
void setSDReady(bool ready) { _sdReady = ready; }
|
||||
|
||||
// Add a new message to the history
|
||||
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
|
||||
const uint8_t* path_bytes = nullptr) {
|
||||
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text) {
|
||||
// Move to next slot in circular buffer
|
||||
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
|
||||
|
||||
@@ -100,13 +94,6 @@ public:
|
||||
msg->channel_idx = channel_idx;
|
||||
msg->valid = true;
|
||||
|
||||
// Store path hop hashes
|
||||
memset(msg->path, 0, MSG_PATH_MAX);
|
||||
if (path_bytes && path_len > 0 && path_len != 0xFF) {
|
||||
int n = path_len < MSG_PATH_MAX ? path_len : MSG_PATH_MAX;
|
||||
memcpy(msg->path, path_bytes, n);
|
||||
}
|
||||
|
||||
// Sanitize emoji: replace UTF-8 emoji sequences with single-byte escape codes
|
||||
// The text already contains "Sender: message" format
|
||||
emojiSanitize(text, msg->text, CHANNEL_MSG_TEXT_LEN);
|
||||
@@ -117,7 +104,6 @@ public:
|
||||
|
||||
// Reset scroll to show newest message
|
||||
_scrollPos = 0;
|
||||
_showPathOverlay = false; // Dismiss overlay on new message
|
||||
|
||||
// Persist to SD card
|
||||
saveToSD();
|
||||
@@ -137,23 +123,7 @@ public:
|
||||
int getMessageCount() const { return _msgCount; }
|
||||
|
||||
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
|
||||
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; _showPathOverlay = false; }
|
||||
bool isShowingPathOverlay() const { return _showPathOverlay; }
|
||||
|
||||
// Find the newest RECEIVED message for the current channel
|
||||
// (path_len != 0 means received, path_len 0 = locally sent)
|
||||
ChannelMessage* getNewestReceivedMsg() {
|
||||
for (int i = 0; i < _msgCount; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx
|
||||
&& _messages[idx].path_len != 0) {
|
||||
return &_messages[idx];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; }
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SD card persistence
|
||||
@@ -193,7 +163,6 @@ public:
|
||||
rec.channel_idx = _messages[i].channel_idx;
|
||||
rec.valid = _messages[i].valid ? 1 : 0;
|
||||
rec.reserved = 0;
|
||||
memcpy(rec.path, _messages[i].path, MSG_PATH_MAX);
|
||||
memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN);
|
||||
f.write((uint8_t*)&rec, sizeof(rec));
|
||||
}
|
||||
@@ -259,7 +228,6 @@ public:
|
||||
_messages[i].path_len = rec.path_len;
|
||||
_messages[i].channel_idx = rec.channel_idx;
|
||||
_messages[i].valid = (rec.valid != 0);
|
||||
memcpy(_messages[i].path, rec.path, MSG_PATH_MAX);
|
||||
memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN);
|
||||
if (_messages[i].valid) loaded++;
|
||||
}
|
||||
@@ -312,120 +280,6 @@ public:
|
||||
// Divider line
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// --- Path detail overlay ---
|
||||
if (_showPathOverlay) {
|
||||
display.setTextSize(0);
|
||||
int lineH = 9;
|
||||
int y = 14;
|
||||
|
||||
ChannelMessage* msg = getNewestReceivedMsg();
|
||||
if (!msg) {
|
||||
display.setCursor(0, y);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("No received messages");
|
||||
} else {
|
||||
// Message preview (first ~30 chars)
|
||||
display.setCursor(0, y);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
char preview[32];
|
||||
strncpy(preview, msg->text, 31);
|
||||
preview[31] = '\0';
|
||||
display.print(preview);
|
||||
y += lineH;
|
||||
|
||||
// Age
|
||||
uint32_t age = _rtc->getCurrentTime() - msg->timestamp;
|
||||
display.setCursor(0, y);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
if (age < 60) sprintf(tmp, "Age: %ds", age);
|
||||
else if (age < 3600) sprintf(tmp, "Age: %dm", age / 60);
|
||||
else if (age < 86400) sprintf(tmp, "Age: %dh", age / 3600);
|
||||
else sprintf(tmp, "Age: %dd", age / 86400);
|
||||
display.print(tmp);
|
||||
y += lineH;
|
||||
|
||||
// Route type
|
||||
display.setCursor(0, y);
|
||||
uint8_t plen = msg->path_len;
|
||||
if (plen == 0xFF) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("Route: Direct");
|
||||
} else if (plen == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("Route: Local/Sent");
|
||||
} else {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
sprintf(tmp, "Route: %d hop%s", plen, plen == 1 ? "" : "s");
|
||||
display.print(tmp);
|
||||
}
|
||||
y += lineH + 2;
|
||||
|
||||
// Show each hop resolved against contacts
|
||||
if (plen > 0 && plen != 0xFF) {
|
||||
int displayHops = plen < MSG_PATH_MAX ? plen : MSG_PATH_MAX;
|
||||
int maxY = display.height() - 26;
|
||||
|
||||
for (int h = 0; h < displayHops && y + lineH <= maxY; h++) {
|
||||
uint8_t hopHash = msg->path[h];
|
||||
display.setCursor(0, y);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
sprintf(tmp, " %d: ", h + 1);
|
||||
display.print(tmp);
|
||||
|
||||
// Try to resolve: prefer repeaters, then any contact
|
||||
bool resolved = false;
|
||||
int numContacts = the_mesh.getNumContacts();
|
||||
ContactInfo contact;
|
||||
|
||||
// First pass: repeaters only
|
||||
for (uint32_t ci = 0; ci < numContacts && !resolved; ci++) {
|
||||
if (the_mesh.getContactByIdx(ci, contact)) {
|
||||
if (contact.id.pub_key[0] == hopHash && contact.type == ADV_TYPE_REPEATER) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.print(contact.name);
|
||||
resolved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: any contact type
|
||||
if (!resolved) {
|
||||
for (uint32_t ci = 0; ci < numContacts; ci++) {
|
||||
if (the_mesh.getContactByIdx(ci, contact)) {
|
||||
if (contact.id.pub_key[0] == hopHash) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print(contact.name);
|
||||
resolved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: show hex hash
|
||||
if (!resolved) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
sprintf(tmp, "?%02X", hopHash);
|
||||
display.print(tmp);
|
||||
}
|
||||
y += lineH;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay footer
|
||||
display.setTextSize(1);
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("Q:Back");
|
||||
|
||||
#if AUTO_OFF_MILLIS == 0
|
||||
return 5000;
|
||||
#else
|
||||
return 1000;
|
||||
#endif
|
||||
}
|
||||
|
||||
if (channelMsgCount == 0) {
|
||||
display.setTextSize(0); // Tiny font for body text
|
||||
display.setCursor(0, 20);
|
||||
@@ -441,7 +295,6 @@ public:
|
||||
int lineHeight = 9; // 8px font + 1px spacing
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int scrollBarW = 4; // Width of scroll indicator on right edge
|
||||
// Hard cutoff: no text may START at or beyond this y value
|
||||
// This ensures rendered glyphs (which extend lineHeight below y) stay above the footer
|
||||
int maxY = display.height() - footerHeight;
|
||||
@@ -449,8 +302,7 @@ public:
|
||||
int y = headerHeight;
|
||||
|
||||
// Build list of messages for this channel (newest first)
|
||||
// Static to avoid 1200-byte stack allocation every render cycle
|
||||
static int channelMsgs[CHANNEL_MSG_HISTORY_SIZE];
|
||||
int channelMsgs[CHANNEL_MSG_HISTORY_SIZE];
|
||||
int numChannelMsgs = 0;
|
||||
|
||||
for (int i = 0; i < _msgCount && numChannelMsgs < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
@@ -468,10 +320,6 @@ public:
|
||||
int tmp = channelMsgs[l]; channelMsgs[l] = channelMsgs[r]; channelMsgs[r] = tmp;
|
||||
}
|
||||
|
||||
// Clamp scroll position to valid range
|
||||
int maxScroll = numChannelMsgs > _msgsPerPage ? numChannelMsgs - _msgsPerPage : 0;
|
||||
if (_scrollPos > maxScroll) _scrollPos = maxScroll;
|
||||
|
||||
// Calculate start index so newest messages appear at the bottom
|
||||
// scrollPos=0 shows the most recent messages, scrollPos++ scrolls up to older
|
||||
int startIdx = numChannelMsgs - _msgsPerPage - _scrollPos;
|
||||
@@ -513,7 +361,7 @@ public:
|
||||
|
||||
// Track position in pixels for emoji placement
|
||||
// Uses advance width (cursor movement) not bounding box for px tracking
|
||||
int lineW = display.width() - scrollBarW - 1; // Reserve space for scroll bar
|
||||
int lineW = display.width();
|
||||
int px = display.getTextWidth(tmp); // Pixel X after timestamp
|
||||
char dblStr[3] = {0, 0, 0};
|
||||
|
||||
@@ -612,39 +460,13 @@ public:
|
||||
if (y + lineHeight > maxY) screenFull = true;
|
||||
}
|
||||
|
||||
// Only update _msgsPerPage when at the bottom (scrollPos==0) and the
|
||||
// screen actually filled up. While scrolled, freezing _msgsPerPage
|
||||
// prevents a feedback loop where variable-height messages cause
|
||||
// msgsPerPage to oscillate, shifting startIdx every render (flicker).
|
||||
if (screenFull && msgsDrawn > 0 && _scrollPos == 0) {
|
||||
// Only update _msgsPerPage when the screen actually filled up.
|
||||
// If we ran out of messages before filling the screen, keep the
|
||||
// previous (higher) value so startIdx doesn't under-count.
|
||||
if (screenFull && msgsDrawn > 0) {
|
||||
_msgsPerPage = msgsDrawn;
|
||||
}
|
||||
|
||||
// --- Scroll bar (emoji picker style) ---
|
||||
int sbX = display.width() - scrollBarW;
|
||||
int sbTop = headerHeight;
|
||||
int sbHeight = maxY - headerHeight;
|
||||
|
||||
// Draw track outline
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(sbX, sbTop, scrollBarW, sbHeight);
|
||||
|
||||
if (channelMsgCount > _msgsPerPage) {
|
||||
// Scrollable: draw proportional thumb
|
||||
int maxScroll = channelMsgCount - _msgsPerPage;
|
||||
if (maxScroll < 1) maxScroll = 1;
|
||||
int thumbH = (_msgsPerPage * sbHeight) / channelMsgCount;
|
||||
if (thumbH < 4) thumbH = 4;
|
||||
// _scrollPos=0 is newest (bottom), so invert for thumb position
|
||||
int thumbY = sbTop + ((maxScroll - _scrollPos) * (sbHeight - thumbH)) / maxScroll;
|
||||
for (int ty = thumbY + 1; ty < thumbY + thumbH - 1; ty++)
|
||||
display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
|
||||
} else {
|
||||
// All messages fit: fill entire track
|
||||
for (int ty = sbTop + 1; ty < sbTop + sbHeight - 1; ty++)
|
||||
display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
|
||||
}
|
||||
|
||||
display.setTextSize(1); // Restore for footer
|
||||
}
|
||||
|
||||
@@ -654,11 +476,11 @@ public:
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
// Left side: abbreviated controls
|
||||
display.print("Q:Bck A/D:Ch V:Pth");
|
||||
// Left side: Q:Back A/D:Ch
|
||||
display.print("Q:Back A/D:Ch");
|
||||
|
||||
// Right side: Ent:New
|
||||
const char* rightText = "Ent:New";
|
||||
// Right side: Entr:New
|
||||
const char* rightText = "Entr:New";
|
||||
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
|
||||
display.print(rightText);
|
||||
|
||||
@@ -670,26 +492,8 @@ public:
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// If overlay is showing, only handle dismiss
|
||||
if (_showPathOverlay) {
|
||||
if (c == 'q' || c == 'Q' || c == '\b' || c == 'v' || c == 'V') {
|
||||
_showPathOverlay = false;
|
||||
return true;
|
||||
}
|
||||
return true; // Consume all keys while overlay is up
|
||||
}
|
||||
|
||||
int channelMsgCount = getMessageCountForChannel();
|
||||
|
||||
// V - show path detail for last received message
|
||||
if (c == 'v' || c == 'V') {
|
||||
if (getNewestReceivedMsg() != nullptr) {
|
||||
_showPathOverlay = true;
|
||||
return true;
|
||||
}
|
||||
return false; // No received messages to show
|
||||
}
|
||||
|
||||
// W or KEY_PREV - scroll up (older messages)
|
||||
if (c == 0xF2 || c == 'w' || c == 'W') {
|
||||
if (_scrollPos + _msgsPerPage < channelMsgCount) {
|
||||
|
||||
@@ -1,651 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// M4BMetadata.h - Lightweight MP4/M4B atom parser for metadata extraction
|
||||
//
|
||||
// Walks the MP4 atom (box) tree to extract:
|
||||
// - Title (moov/udta/meta/ilst/©nam)
|
||||
// - Author (moov/udta/meta/ilst/©ART)
|
||||
// - Cover art (moov/udta/meta/ilst/covr) - JPEG offset+size within file
|
||||
// - Duration (moov/mvhd timescale + duration)
|
||||
// - Chapter markers (moov/udta/chpl) - Nero-style chapter list
|
||||
//
|
||||
// Designed for embedded use: no dynamic allocation, reads directly from SD
|
||||
// via Arduino File API, uses a small stack buffer for atom headers.
|
||||
//
|
||||
// Usage:
|
||||
// M4BMetadata meta;
|
||||
// File f = SD.open("/audiobooks/mybook.m4b");
|
||||
// if (meta.parse(f)) {
|
||||
// Serial.printf("Title: %s\n", meta.title);
|
||||
// Serial.printf("Author: %s\n", meta.author);
|
||||
// if (meta.hasCoverArt) {
|
||||
// // JPEG data is at meta.coverOffset, meta.coverSize bytes
|
||||
// }
|
||||
// }
|
||||
// f.close();
|
||||
// =============================================================================
|
||||
|
||||
#include <SD.h>
|
||||
|
||||
// Maximum metadata string lengths (including null terminator)
|
||||
#define M4B_MAX_TITLE 128
|
||||
#define M4B_MAX_AUTHOR 64
|
||||
#define M4B_MAX_CHAPTERS 100
|
||||
|
||||
struct M4BChapter {
|
||||
uint32_t startMs; // Chapter start time in milliseconds
|
||||
char name[48]; // Chapter title (truncated to fit)
|
||||
};
|
||||
|
||||
class M4BMetadata {
|
||||
public:
|
||||
// Extracted metadata
|
||||
char title[M4B_MAX_TITLE];
|
||||
char author[M4B_MAX_AUTHOR];
|
||||
bool hasCoverArt;
|
||||
uint32_t coverOffset; // Byte offset of JPEG/PNG data within file
|
||||
uint32_t coverSize; // Size of cover image data in bytes
|
||||
uint8_t coverFormat; // 13=JPEG, 14=PNG (from MP4 well-known type)
|
||||
uint32_t durationMs; // Total duration in milliseconds
|
||||
uint32_t sampleRate; // Audio sample rate (from audio stsd)
|
||||
uint32_t bitrate; // Approximate bitrate in bps
|
||||
|
||||
// Chapter data
|
||||
M4BChapter chapters[M4B_MAX_CHAPTERS];
|
||||
int chapterCount;
|
||||
|
||||
M4BMetadata() { clear(); }
|
||||
|
||||
void clear() {
|
||||
title[0] = '\0';
|
||||
author[0] = '\0';
|
||||
hasCoverArt = false;
|
||||
coverOffset = 0;
|
||||
coverSize = 0;
|
||||
coverFormat = 0;
|
||||
durationMs = 0;
|
||||
sampleRate = 44100;
|
||||
bitrate = 0;
|
||||
chapterCount = 0;
|
||||
}
|
||||
|
||||
// Parse an open file. Returns true if at least title or duration was found.
|
||||
// File position is NOT preserved — caller should seek as needed afterward.
|
||||
bool parse(File& file) {
|
||||
clear();
|
||||
if (!file || file.size() < 8) return false;
|
||||
|
||||
_fileSize = file.size();
|
||||
|
||||
// Walk top-level atoms looking for 'moov'
|
||||
uint32_t pos = 0;
|
||||
while (pos < _fileSize) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
if (hdr.type == ATOM_MOOV) {
|
||||
parseMoov(file, hdr.dataOffset, hdr.dataOffset + hdr.dataSize);
|
||||
break; // moov found and parsed, we're done
|
||||
}
|
||||
|
||||
// Skip to next top-level atom
|
||||
pos += hdr.size;
|
||||
if (hdr.size == 0) break; // size=0 means "extends to EOF"
|
||||
}
|
||||
|
||||
return (title[0] != '\0' || durationMs > 0);
|
||||
}
|
||||
|
||||
// Get chapter index for a given playback position (milliseconds).
|
||||
// Returns -1 if no chapters or position is before first chapter.
|
||||
int getChapterForPosition(uint32_t positionMs) const {
|
||||
if (chapterCount == 0) return -1;
|
||||
int ch = 0;
|
||||
for (int i = 1; i < chapterCount; i++) {
|
||||
if (chapters[i].startMs > positionMs) break;
|
||||
ch = i;
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
// Get the start position of the next chapter after the given position.
|
||||
// Returns 0 if no next chapter.
|
||||
uint32_t getNextChapterMs(uint32_t positionMs) const {
|
||||
for (int i = 0; i < chapterCount; i++) {
|
||||
if (chapters[i].startMs > positionMs) return chapters[i].startMs;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get the start position of the current or previous chapter.
|
||||
uint32_t getPrevChapterMs(uint32_t positionMs) const {
|
||||
uint32_t prev = 0;
|
||||
for (int i = 0; i < chapterCount; i++) {
|
||||
if (chapters[i].startMs >= positionMs) break;
|
||||
prev = chapters[i].startMs;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
private:
|
||||
uint32_t _fileSize;
|
||||
|
||||
// MP4 atom type codes (big-endian FourCC)
|
||||
static constexpr uint32_t ATOM_MOOV = 0x6D6F6F76; // 'moov'
|
||||
static constexpr uint32_t ATOM_MVHD = 0x6D766864; // 'mvhd'
|
||||
static constexpr uint32_t ATOM_UDTA = 0x75647461; // 'udta'
|
||||
static constexpr uint32_t ATOM_META = 0x6D657461; // 'meta'
|
||||
static constexpr uint32_t ATOM_ILST = 0x696C7374; // 'ilst'
|
||||
static constexpr uint32_t ATOM_NAM = 0xA96E616D; // '©nam'
|
||||
static constexpr uint32_t ATOM_ART = 0xA9415254; // '©ART'
|
||||
static constexpr uint32_t ATOM_COVR = 0x636F7672; // 'covr'
|
||||
static constexpr uint32_t ATOM_DATA = 0x64617461; // 'data'
|
||||
static constexpr uint32_t ATOM_CHPL = 0x6368706C; // 'chpl' (Nero chapters)
|
||||
static constexpr uint32_t ATOM_TRAK = 0x7472616B; // 'trak'
|
||||
static constexpr uint32_t ATOM_MDIA = 0x6D646961; // 'mdia'
|
||||
static constexpr uint32_t ATOM_MDHD = 0x6D646864; // 'mdhd'
|
||||
static constexpr uint32_t ATOM_HDLR = 0x68646C72; // 'hdlr'
|
||||
|
||||
struct AtomHeader {
|
||||
uint32_t type;
|
||||
uint64_t size; // Total atom size including header
|
||||
uint32_t dataOffset; // File offset where data begins (after header)
|
||||
uint64_t dataSize; // size - header_length
|
||||
};
|
||||
|
||||
// Read a 32-bit big-endian value from file at current position
|
||||
static uint32_t readU32BE(File& file) {
|
||||
uint8_t buf[4];
|
||||
file.read(buf, 4);
|
||||
return ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) |
|
||||
((uint32_t)buf[2] << 8) | buf[3];
|
||||
}
|
||||
|
||||
// Read a 64-bit big-endian value
|
||||
static uint64_t readU64BE(File& file) {
|
||||
uint32_t hi = readU32BE(file);
|
||||
uint32_t lo = readU32BE(file);
|
||||
return ((uint64_t)hi << 32) | lo;
|
||||
}
|
||||
|
||||
// Read a 16-bit big-endian value
|
||||
static uint16_t readU16BE(File& file) {
|
||||
uint8_t buf[2];
|
||||
file.read(buf, 2);
|
||||
return ((uint16_t)buf[0] << 8) | buf[1];
|
||||
}
|
||||
|
||||
// Read atom header at given file offset
|
||||
bool readAtomHeader(File& file, uint32_t offset, AtomHeader& hdr) {
|
||||
if (offset + 8 > _fileSize) return false;
|
||||
|
||||
file.seek(offset);
|
||||
uint32_t size32 = readU32BE(file);
|
||||
hdr.type = readU32BE(file);
|
||||
|
||||
if (size32 == 1) {
|
||||
// 64-bit extended size
|
||||
if (offset + 16 > _fileSize) return false;
|
||||
hdr.size = readU64BE(file);
|
||||
hdr.dataOffset = offset + 16;
|
||||
hdr.dataSize = (hdr.size > 16) ? hdr.size - 16 : 0;
|
||||
} else if (size32 == 0) {
|
||||
// Atom extends to end of file
|
||||
hdr.size = _fileSize - offset;
|
||||
hdr.dataOffset = offset + 8;
|
||||
hdr.dataSize = hdr.size - 8;
|
||||
} else {
|
||||
hdr.size = size32;
|
||||
hdr.dataOffset = offset + 8;
|
||||
hdr.dataSize = (size32 > 8) ? size32 - 8 : 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse the moov container atom
|
||||
void parseMoov(File& file, uint32_t start, uint32_t end) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
switch (hdr.type) {
|
||||
case ATOM_MVHD:
|
||||
parseMvhd(file, hdr.dataOffset, (uint32_t)hdr.dataSize);
|
||||
break;
|
||||
case ATOM_UDTA:
|
||||
parseUdta(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize);
|
||||
break;
|
||||
case ATOM_TRAK:
|
||||
break;
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse mvhd (movie header) for duration
|
||||
void parseMvhd(File& file, uint32_t offset, uint32_t size) {
|
||||
file.seek(offset);
|
||||
uint8_t version = file.read();
|
||||
|
||||
if (version == 0) {
|
||||
file.seek(offset + 4); // skip version(1) + flags(3)
|
||||
/* create_time */ readU32BE(file);
|
||||
/* modify_time */ readU32BE(file);
|
||||
uint32_t timescale = readU32BE(file);
|
||||
uint32_t duration = readU32BE(file);
|
||||
if (timescale > 0) {
|
||||
durationMs = (uint32_t)((uint64_t)duration * 1000 / timescale);
|
||||
}
|
||||
} else if (version == 1) {
|
||||
file.seek(offset + 4);
|
||||
/* create_time */ readU64BE(file);
|
||||
/* modify_time */ readU64BE(file);
|
||||
uint32_t timescale = readU32BE(file);
|
||||
uint64_t duration = readU64BE(file);
|
||||
if (timescale > 0) {
|
||||
durationMs = (uint32_t)(duration * 1000 / timescale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse udta container — contains meta and/or chpl
|
||||
void parseUdta(File& file, uint32_t start, uint32_t end) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
if (hdr.type == ATOM_META) {
|
||||
parseMeta(file, hdr.dataOffset + 4,
|
||||
hdr.dataOffset + (uint32_t)hdr.dataSize);
|
||||
} else if (hdr.type == ATOM_CHPL) {
|
||||
parseChpl(file, hdr.dataOffset, (uint32_t)hdr.dataSize);
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse meta container — contains hdlr + ilst
|
||||
void parseMeta(File& file, uint32_t start, uint32_t end) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
if (hdr.type == ATOM_ILST) {
|
||||
parseIlst(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize);
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ilst (iTunes metadata list) — contains ©nam, ©ART, covr etc.
|
||||
void parseIlst(File& file, uint32_t start, uint32_t end) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
switch (hdr.type) {
|
||||
case ATOM_NAM:
|
||||
extractTextData(file, hdr.dataOffset,
|
||||
hdr.dataOffset + (uint32_t)hdr.dataSize,
|
||||
title, M4B_MAX_TITLE);
|
||||
break;
|
||||
case ATOM_ART:
|
||||
extractTextData(file, hdr.dataOffset,
|
||||
hdr.dataOffset + (uint32_t)hdr.dataSize,
|
||||
author, M4B_MAX_AUTHOR);
|
||||
break;
|
||||
case ATOM_COVR:
|
||||
extractCoverData(file, hdr.dataOffset,
|
||||
hdr.dataOffset + (uint32_t)hdr.dataSize);
|
||||
break;
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract text from a 'data' sub-atom within an ilst entry.
|
||||
void extractTextData(File& file, uint32_t start, uint32_t end,
|
||||
char* dest, int maxLen) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
if (hdr.type == ATOM_DATA && hdr.dataSize > 8) {
|
||||
uint32_t textOffset = hdr.dataOffset + 8;
|
||||
uint32_t textLen = (uint32_t)hdr.dataSize - 8;
|
||||
if (textLen > (uint32_t)(maxLen - 1)) textLen = maxLen - 1;
|
||||
|
||||
file.seek(textOffset);
|
||||
file.read((uint8_t*)dest, textLen);
|
||||
dest[textLen] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract cover art location from 'data' sub-atom within covr.
|
||||
void extractCoverData(File& file, uint32_t start, uint32_t end) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
if (hdr.type == ATOM_DATA && hdr.dataSize > 8) {
|
||||
file.seek(hdr.dataOffset);
|
||||
uint32_t typeIndicator = readU32BE(file);
|
||||
uint8_t wellKnownType = typeIndicator & 0xFF;
|
||||
|
||||
coverOffset = hdr.dataOffset + 8;
|
||||
coverSize = (uint32_t)hdr.dataSize - 8;
|
||||
coverFormat = wellKnownType; // 13=JPEG, 14=PNG
|
||||
hasCoverArt = (coverSize > 0);
|
||||
|
||||
Serial.printf("M4B: Cover art found - %s, %u bytes at offset %u\n",
|
||||
wellKnownType == 13 ? "JPEG" :
|
||||
wellKnownType == 14 ? "PNG" : "unknown",
|
||||
coverSize, coverOffset);
|
||||
return;
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ID3v2 Parser for MP3 files
|
||||
// =====================================================================
|
||||
public:
|
||||
// Parse ID3v2 tags from an MP3 file. Extracts title (TIT2), artist
|
||||
// (TPE1), and cover art (APIC). Fills the same metadata fields as
|
||||
// the M4B parser so decodeCoverArt() works unchanged.
|
||||
bool parseID3v2(File& file) {
|
||||
clear();
|
||||
if (!file || file.size() < 10) return false;
|
||||
|
||||
file.seek(0);
|
||||
uint8_t hdr[10];
|
||||
if (file.read(hdr, 10) != 10) return false;
|
||||
|
||||
// Verify "ID3" magic
|
||||
if (hdr[0] != 'I' || hdr[1] != 'D' || hdr[2] != '3') {
|
||||
Serial.println("ID3: No ID3v2 header found");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t versionMajor = hdr[3]; // 3 = ID3v2.3, 4 = ID3v2.4
|
||||
bool v24 = (versionMajor == 4);
|
||||
bool hasExtHeader = (hdr[5] & 0x40) != 0;
|
||||
|
||||
// Tag size is syncsafe integer (4 x 7-bit bytes)
|
||||
uint32_t tagSize = ((uint32_t)(hdr[6] & 0x7F) << 21) |
|
||||
((uint32_t)(hdr[7] & 0x7F) << 14) |
|
||||
((uint32_t)(hdr[8] & 0x7F) << 7) |
|
||||
(hdr[9] & 0x7F);
|
||||
|
||||
uint32_t tagEnd = 10 + tagSize;
|
||||
if (tagEnd > file.size()) tagEnd = file.size();
|
||||
|
||||
Serial.printf("ID3: v2.%d, %u bytes\n", versionMajor, tagSize);
|
||||
|
||||
// Skip extended header if present
|
||||
uint32_t pos = 10;
|
||||
if (hasExtHeader && pos + 4 < tagEnd) {
|
||||
file.seek(pos);
|
||||
uint32_t extSize;
|
||||
if (v24) {
|
||||
uint8_t eb[4];
|
||||
file.read(eb, 4);
|
||||
extSize = ((uint32_t)(eb[0] & 0x7F) << 21) |
|
||||
((uint32_t)(eb[1] & 0x7F) << 14) |
|
||||
((uint32_t)(eb[2] & 0x7F) << 7) |
|
||||
(eb[3] & 0x7F);
|
||||
} else {
|
||||
extSize = readU32BE(file) + 4;
|
||||
}
|
||||
pos += extSize;
|
||||
}
|
||||
|
||||
// Walk ID3v2 frames
|
||||
bool foundTitle = false, foundArtist = false, foundCover = false;
|
||||
|
||||
while (pos + 10 < tagEnd) {
|
||||
file.seek(pos);
|
||||
uint8_t fhdr[10];
|
||||
if (file.read(fhdr, 10) != 10) break;
|
||||
|
||||
if (fhdr[0] == 0) break;
|
||||
|
||||
char frameId[5] = { (char)fhdr[0], (char)fhdr[1],
|
||||
(char)fhdr[2], (char)fhdr[3], '\0' };
|
||||
|
||||
uint32_t frameSize;
|
||||
if (v24) {
|
||||
frameSize = ((uint32_t)(fhdr[4] & 0x7F) << 21) |
|
||||
((uint32_t)(fhdr[5] & 0x7F) << 14) |
|
||||
((uint32_t)(fhdr[6] & 0x7F) << 7) |
|
||||
(fhdr[7] & 0x7F);
|
||||
} else {
|
||||
frameSize = ((uint32_t)fhdr[4] << 24) | ((uint32_t)fhdr[5] << 16) |
|
||||
((uint32_t)fhdr[6] << 8) | fhdr[7];
|
||||
}
|
||||
|
||||
if (frameSize == 0 || pos + 10 + frameSize > tagEnd) break;
|
||||
|
||||
uint32_t dataStart = pos + 10;
|
||||
|
||||
// --- TIT2 (Title) ---
|
||||
if (!foundTitle && strcmp(frameId, "TIT2") == 0 && frameSize > 1) {
|
||||
id3ExtractText(file, dataStart, frameSize, title, M4B_MAX_TITLE);
|
||||
foundTitle = (title[0] != '\0');
|
||||
}
|
||||
// --- TPE1 (Artist/Author) ---
|
||||
if (!foundArtist && strcmp(frameId, "TPE1") == 0 && frameSize > 1) {
|
||||
id3ExtractText(file, dataStart, frameSize, author, M4B_MAX_AUTHOR);
|
||||
foundArtist = (author[0] != '\0');
|
||||
}
|
||||
// --- APIC (Attached Picture) ---
|
||||
if (!foundCover && strcmp(frameId, "APIC") == 0 && frameSize > 20) {
|
||||
id3ExtractAPIC(file, dataStart, frameSize);
|
||||
foundCover = hasCoverArt;
|
||||
}
|
||||
|
||||
pos = dataStart + frameSize;
|
||||
|
||||
// Early exit once we have everything
|
||||
if (foundTitle && foundArtist && foundCover) break;
|
||||
}
|
||||
|
||||
if (foundTitle) Serial.printf("ID3: Title: %s\n", title);
|
||||
if (foundArtist) Serial.printf("ID3: Author: %s\n", author);
|
||||
return (foundTitle || foundCover);
|
||||
}
|
||||
|
||||
private:
|
||||
// Extract text from a TIT2/TPE1 frame.
|
||||
// Format: encoding(1) + text data
|
||||
void id3ExtractText(File& file, uint32_t offset, uint32_t size,
|
||||
char* dest, int maxLen) {
|
||||
file.seek(offset);
|
||||
uint8_t encoding = file.read();
|
||||
uint32_t textLen = size - 1;
|
||||
if (textLen == 0) return;
|
||||
|
||||
if (encoding == 0 || encoding == 3) {
|
||||
// ISO-8859-1 or UTF-8 — read directly
|
||||
uint32_t readLen = (textLen < (uint32_t)(maxLen - 1))
|
||||
? textLen : (uint32_t)(maxLen - 1);
|
||||
file.read((uint8_t*)dest, readLen);
|
||||
dest[readLen] = '\0';
|
||||
// Strip trailing nulls
|
||||
while (readLen > 0 && dest[readLen - 1] == '\0') readLen--;
|
||||
dest[readLen] = '\0';
|
||||
}
|
||||
else if (encoding == 1 || encoding == 2) {
|
||||
// UTF-16 (with or without BOM) — crude ASCII extraction
|
||||
// Static buffer to avoid stack overflow (loopTask has limited stack)
|
||||
static uint8_t u16buf[128];
|
||||
uint32_t readLen = (textLen > sizeof(u16buf)) ? sizeof(u16buf) : textLen;
|
||||
file.read(u16buf, readLen);
|
||||
|
||||
uint32_t srcStart = 0;
|
||||
// Skip BOM if present
|
||||
if (readLen >= 2 && ((u16buf[0] == 0xFF && u16buf[1] == 0xFE) ||
|
||||
(u16buf[0] == 0xFE && u16buf[1] == 0xFF))) {
|
||||
srcStart = 2;
|
||||
}
|
||||
bool littleEndian = (srcStart >= 2 && u16buf[0] == 0xFF);
|
||||
|
||||
int dstIdx = 0;
|
||||
for (uint32_t i = srcStart; i + 1 < readLen && dstIdx < maxLen - 1; i += 2) {
|
||||
uint8_t lo = littleEndian ? u16buf[i] : u16buf[i + 1];
|
||||
uint8_t hi = littleEndian ? u16buf[i + 1] : u16buf[i];
|
||||
if (lo == 0 && hi == 0) break; // null terminator
|
||||
if (hi == 0 && lo >= 0x20 && lo < 0x7F) {
|
||||
dest[dstIdx++] = (char)lo;
|
||||
} else {
|
||||
dest[dstIdx++] = '?';
|
||||
}
|
||||
}
|
||||
dest[dstIdx] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// Extract APIC (cover art) frame.
|
||||
// Format: encoding(1) + MIME(null-term) + picType(1) + desc(null-term) + imageData
|
||||
void id3ExtractAPIC(File& file, uint32_t offset, uint32_t frameSize) {
|
||||
file.seek(offset);
|
||||
uint8_t encoding = file.read();
|
||||
|
||||
// Read MIME type (null-terminated ASCII)
|
||||
char mime[32] = {0};
|
||||
int mimeLen = 0;
|
||||
while (mimeLen < 31) {
|
||||
int b = file.read();
|
||||
if (b < 0) return; // Read error
|
||||
if (b == 0) break; // Null terminator = end of MIME string
|
||||
mime[mimeLen++] = (char)b;
|
||||
}
|
||||
mime[mimeLen] = '\0';
|
||||
|
||||
// Picture type (1 byte)
|
||||
uint8_t picType = file.read();
|
||||
(void)picType;
|
||||
|
||||
// Skip description (null-terminated, encoding-dependent)
|
||||
if (encoding == 0 || encoding == 3) {
|
||||
// Single-byte null terminator
|
||||
while (true) {
|
||||
int b = file.read();
|
||||
if (b < 0) return; // Read error
|
||||
if (b == 0) break; // Null terminator
|
||||
}
|
||||
} else {
|
||||
// UTF-16: double-null terminator
|
||||
while (true) {
|
||||
int b1 = file.read();
|
||||
int b2 = file.read();
|
||||
if (b1 < 0 || b2 < 0) return; // Read error
|
||||
if (b1 == 0 && b2 == 0) break; // Double-null terminator
|
||||
}
|
||||
}
|
||||
|
||||
// Everything from here to end of frame is image data
|
||||
uint32_t imgOffset = file.position();
|
||||
uint32_t imgEnd = offset + frameSize;
|
||||
if (imgOffset >= imgEnd) return;
|
||||
|
||||
uint32_t imgSize = imgEnd - imgOffset;
|
||||
|
||||
// Determine format from MIME type
|
||||
bool isJpeg = (strstr(mime, "jpeg") || strstr(mime, "jpg"));
|
||||
bool isPng = (strstr(mime, "png") != nullptr);
|
||||
|
||||
// Also detect by magic bytes if MIME is generic
|
||||
if (!isJpeg && !isPng && imgSize > 4) {
|
||||
file.seek(imgOffset);
|
||||
uint8_t magic[4];
|
||||
file.read(magic, 4);
|
||||
if (magic[0] == 0xFF && magic[1] == 0xD8) isJpeg = true;
|
||||
else if (magic[0] == 0x89 && magic[1] == 'P' &&
|
||||
magic[2] == 'N' && magic[3] == 'G') isPng = true;
|
||||
}
|
||||
|
||||
coverOffset = imgOffset;
|
||||
coverSize = imgSize;
|
||||
coverFormat = isJpeg ? 13 : (isPng ? 14 : 0);
|
||||
hasCoverArt = (imgSize > 100 && (isJpeg || isPng));
|
||||
|
||||
if (hasCoverArt) {
|
||||
Serial.printf("ID3: Cover %s, %u bytes\n",
|
||||
isJpeg ? "JPEG" : "PNG", imgSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Nero-style chapter list (chpl atom).
|
||||
void parseChpl(File& file, uint32_t offset, uint32_t size) {
|
||||
if (size < 9) return;
|
||||
|
||||
file.seek(offset);
|
||||
uint8_t version = file.read();
|
||||
file.read(); // flags byte 1
|
||||
file.read(); // flags byte 2
|
||||
file.read(); // flags byte 3
|
||||
|
||||
file.read(); // reserved
|
||||
|
||||
uint32_t count;
|
||||
if (version == 1) {
|
||||
count = readU32BE(file);
|
||||
} else {
|
||||
count = file.read();
|
||||
}
|
||||
|
||||
if (count > M4B_MAX_CHAPTERS) count = M4B_MAX_CHAPTERS;
|
||||
|
||||
chapterCount = 0;
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
if (!file.available()) break;
|
||||
|
||||
uint64_t timestamp = readU64BE(file);
|
||||
uint32_t startMs = (uint32_t)(timestamp / 10000); // 100ns -> ms
|
||||
|
||||
uint8_t nameLen = file.read();
|
||||
if (nameLen == 0 || !file.available()) break;
|
||||
|
||||
M4BChapter& ch = chapters[chapterCount];
|
||||
ch.startMs = startMs;
|
||||
|
||||
uint8_t readLen = (nameLen < sizeof(ch.name) - 1) ? nameLen : sizeof(ch.name) - 1;
|
||||
file.read((uint8_t*)ch.name, readLen);
|
||||
ch.name[readLen] = '\0';
|
||||
|
||||
if (nameLen > readLen) {
|
||||
file.seek(file.position() + (nameLen - readLen));
|
||||
}
|
||||
|
||||
chapterCount++;
|
||||
}
|
||||
|
||||
Serial.printf("M4B: Found %d chapters\n", chapterCount);
|
||||
}
|
||||
};
|
||||
@@ -468,7 +468,7 @@ public:
|
||||
break;
|
||||
|
||||
case ROW_MSG_NOTIFY:
|
||||
snprintf(tmp, sizeof(tmp), "Msg Rcvd LED Light Pulse: %s",
|
||||
snprintf(tmp, sizeof(tmp), "Msg Flash: %s",
|
||||
_prefs->kb_flash_notify ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
@@ -182,10 +182,8 @@ 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;
|
||||
@@ -393,8 +391,8 @@ private:
|
||||
idxFile.read(&fullyFlag, 1);
|
||||
idxFile.read((uint8_t*)&lastRead, 4);
|
||||
|
||||
// Verify file hasn't changed - try current path first, then epub cache
|
||||
String fullPath = _currentPath + "/" + filename;
|
||||
// Verify file hasn't changed - try BOOKS_FOLDER first, then epub cache
|
||||
String fullPath = String(BOOKS_FOLDER) + "/" + filename;
|
||||
File txtFile = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (!txtFile) {
|
||||
// Fallback: check epub cache directory
|
||||
@@ -484,94 +482,33 @@ 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(_currentPath.c_str());
|
||||
File root = SD.open(BOOKS_FOLDER);
|
||||
if (!root || !root.isDirectory()) return;
|
||||
|
||||
File f = root.openNextFile();
|
||||
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);
|
||||
while (f && _fileList.size() < READER_MAX_FILES) {
|
||||
if (!f.isDirectory()) {
|
||||
String name = String(f.name());
|
||||
int slash = name.lastIndexOf('/');
|
||||
if (slash >= 0) name = name.substring(slash + 1);
|
||||
|
||||
// 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);
|
||||
if (!name.startsWith(".") &&
|
||||
(name.endsWith(".txt") || name.endsWith(".TXT") ||
|
||||
name.endsWith(".epub") || name.endsWith(".EPUB"))) {
|
||||
_fileList.push_back(name);
|
||||
}
|
||||
}
|
||||
f = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
Serial.printf("TextReader: %s — %d dirs, %d files\n",
|
||||
_currentPath.c_str(), (int)_dirList.size(), (int)_fileList.size());
|
||||
Serial.printf("TextReader: Found %d files\n", _fileList.size());
|
||||
}
|
||||
|
||||
// ---- Book Open/Close ----
|
||||
@@ -581,7 +518,7 @@ private:
|
||||
|
||||
// ---- EPUB auto-conversion ----
|
||||
String actualFilename = filename;
|
||||
String actualFullPath = _currentPath + "/" + filename;
|
||||
String actualFullPath = String(BOOKS_FOLDER) + "/" + filename;
|
||||
bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB");
|
||||
|
||||
if (isEpub) {
|
||||
@@ -818,26 +755,15 @@ 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);
|
||||
}
|
||||
display.print("Text Reader");
|
||||
|
||||
int totalItems = totalListItems();
|
||||
sprintf(tmp, "[%d]", totalItems);
|
||||
sprintf(tmp, "[%d]", (int)_fileList.size());
|
||||
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
||||
display.print(tmp);
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
if (totalItems == 0) {
|
||||
if (_fileList.size() == 0) {
|
||||
display.setCursor(0, 18);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("No files found");
|
||||
@@ -854,8 +780,8 @@ private:
|
||||
if (maxVisible > 15) maxVisible = 15;
|
||||
|
||||
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
|
||||
totalItems - maxVisible));
|
||||
int endIdx = min(totalItems, startIdx + maxVisible);
|
||||
(int)_fileList.size() - maxVisible));
|
||||
int endIdx = min((int)_fileList.size(), startIdx + maxVisible);
|
||||
|
||||
int y = startY;
|
||||
for (int i = startIdx; i < endIdx; i++) {
|
||||
@@ -874,41 +800,27 @@ private:
|
||||
// Set cursor AFTER fillRect so text draws on top of highlight
|
||||
display.setCursor(0, y);
|
||||
|
||||
int type = itemTypeAt(i);
|
||||
// Build display string: "> filename.txt *" (asterisk if has bookmark)
|
||||
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) + "...";
|
||||
// Check for resume indicator
|
||||
String suffix = "";
|
||||
for (int j = 0; j < (int)_fileCache.size(); j++) {
|
||||
if (_fileCache[j].filename == name && _fileCache[j].lastReadPage > 0) {
|
||||
suffix = " *";
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// File
|
||||
int fi = fileIndexAt(i);
|
||||
String name = _fileList[fi];
|
||||
|
||||
// Check for resume indicator
|
||||
String suffix = "";
|
||||
if (fi < (int)_fileCache.size()) {
|
||||
if (_fileCache[fi].filename == name && _fileCache[fi].lastReadPage > 0) {
|
||||
suffix = " *";
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
int maxLen = _charsPerLine - 4 - suffix.length();
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name + suffix;
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
int maxLen = _charsPerLine - 4 - suffix.length();
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name + suffix;
|
||||
display.print(line.c_str());
|
||||
|
||||
y += listLineH;
|
||||
}
|
||||
display.setTextSize(1); // Restore
|
||||
@@ -1016,8 +928,7 @@ public:
|
||||
_bootIndexed(false), _display(nullptr),
|
||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
|
||||
_headerHeight(14), _footerHeight(14),
|
||||
_selectedFile(0), _currentPath(BOOKS_FOLDER),
|
||||
_fileOpen(false), _currentPage(0), _totalPages(0),
|
||||
_selectedFile(0), _fileOpen(false), _currentPage(0), _totalPages(0),
|
||||
_pageBufLen(0), _contentDirty(true) {
|
||||
}
|
||||
|
||||
@@ -1157,8 +1068,8 @@ public:
|
||||
indexProgress++;
|
||||
drawBootSplash(indexProgress, needsIndexCount, _fileList[i]);
|
||||
|
||||
// Try current path first, then epub cache fallback
|
||||
String fullPath = _currentPath + "/" + _fileList[i];
|
||||
// Try BOOKS_FOLDER first, then epub cache fallback
|
||||
String fullPath = String(BOOKS_FOLDER) + "/" + _fileList[i];
|
||||
File file = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (!file) {
|
||||
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
|
||||
@@ -1255,8 +1166,6 @@ public:
|
||||
}
|
||||
|
||||
bool handleFileListInput(char c) {
|
||||
int total = totalListItems();
|
||||
|
||||
// W - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_selectedFile > 0) {
|
||||
@@ -1268,36 +1177,18 @@ public:
|
||||
|
||||
// S - scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_selectedFile < total - 1) {
|
||||
if (_selectedFile < (int)_fileList.size() - 1) {
|
||||
_selectedFile++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter - open selected item (directory or file)
|
||||
// Enter - open selected file
|
||||
if (c == '\r' || c == 13) {
|
||||
if (total == 0 || _selectedFile >= total) return false;
|
||||
|
||||
int type = itemTypeAt(_selectedFile);
|
||||
|
||||
if (type == 0) {
|
||||
// ".." — navigate to parent
|
||||
navigateToParent();
|
||||
rescanAndIndex();
|
||||
if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) {
|
||||
openBook(_fileList[_selectedFile]);
|
||||
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;
|
||||
}
|
||||
@@ -1305,53 +1196,6 @@ 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) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include "../MyMesh.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "target.h"
|
||||
#include "GPSDutyCycle.h"
|
||||
#ifdef WIFI_SSID
|
||||
@@ -37,9 +36,7 @@
|
||||
#include "ContactsScreen.h"
|
||||
#include "TextReaderScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#include "AudiobookPlayerScreen.h"
|
||||
#endif
|
||||
#include "RepeaterAdminScreen.h"
|
||||
|
||||
class SplashScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
@@ -120,8 +117,12 @@ class HomeScreen : public UIScreen {
|
||||
|
||||
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
|
||||
// Use voltage-based estimation to match BLE app readings
|
||||
uint8_t batteryPercentage = 0;
|
||||
#if HAS_BQ27220
|
||||
// Use fuel gauge SOC directly — accurate across the full discharge curve
|
||||
batteryPercentage = board.getBatteryPercent();
|
||||
#else
|
||||
// Fallback: voltage-based linear estimation for boards without fuel gauge
|
||||
if (batteryMilliVolts > 0) {
|
||||
const int minMilliVolts = 3000;
|
||||
const int maxMilliVolts = 4200;
|
||||
@@ -130,6 +131,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
if (pct > 100) pct = 100;
|
||||
batteryPercentage = (uint8_t)pct;
|
||||
}
|
||||
#endif
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
|
||||
@@ -148,8 +150,6 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
int iconX = display.width() - totalWidth;
|
||||
int iconY = 0; // vertically align with node name text
|
||||
|
||||
if (outIconX) *outIconX = iconX;
|
||||
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
|
||||
@@ -169,24 +169,6 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
display.setTextSize(1); // restore default text size
|
||||
}
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
// ---- Audio background playback indicator ----
|
||||
// Shows a small play symbol to the left of the battery icon when an
|
||||
// audiobook is actively playing in the background.
|
||||
// Uses the font renderer (not manual pixel drawing) since it handles
|
||||
// the e-ink coordinate scaling correctly.
|
||||
void renderAudioIndicator(DisplayDriver& display, int batteryLeftX) {
|
||||
if (!_task->isAudioPlayingInBackground()) return;
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0); // tiny font (same as clock & battery %)
|
||||
int x = batteryLeftX - display.getTextWidth(">>") - 2;
|
||||
display.setCursor(x, -3); // align vertically with battery text
|
||||
display.print(">>");
|
||||
display.setTextSize(1); // restore
|
||||
}
|
||||
#endif
|
||||
|
||||
CayenneLPP sensors_lpp;
|
||||
int sensors_nb = 0;
|
||||
bool sensors_scroll = false;
|
||||
@@ -244,15 +226,7 @@ public:
|
||||
display.print(filtered_name);
|
||||
|
||||
// battery voltage
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
int battLeftX = display.width(); // default if battery doesn't render
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts(), &battLeftX);
|
||||
|
||||
// audio background playback indicator (>> icon next to battery)
|
||||
renderAudioIndicator(display, battLeftX);
|
||||
#else
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts());
|
||||
#endif
|
||||
|
||||
// centered clock (tinyfont) - only show when time is valid
|
||||
{
|
||||
@@ -326,15 +300,11 @@ public:
|
||||
display.setTextSize(0); // tinyfont 6x8 monospaced
|
||||
display.drawTextCentered(display.width() / 2, y, "Press:");
|
||||
y += 12;
|
||||
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
|
||||
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts");
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings");
|
||||
y += 10;
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [P] Audiobooks");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
|
||||
#endif
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
|
||||
y += 14;
|
||||
|
||||
// Nav hint
|
||||
@@ -443,7 +413,7 @@ public:
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
|
||||
// NMEA sentence counter  confirms baud rate and data flow
|
||||
// NMEA sentence counter  confirms baud rate and data flow
|
||||
display.drawTextLeftAlign(0, y, "sentences");
|
||||
if (gpsDuty.isHardwareOn()) {
|
||||
uint16_t sps = gpsStream.getSentencesPerSec();
|
||||
@@ -618,13 +588,6 @@ public:
|
||||
display.drawTextLeftAlign(0, y, "voltage");
|
||||
sprintf(buf, "%d.%03d V", mv / 1000, mv % 1000);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Remaining capacity
|
||||
uint16_t remCap = board.getRemainingCapacity();
|
||||
display.drawTextLeftAlign(0, y, "remaining cap");
|
||||
sprintf(buf, "%d mAh", remCap);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
#endif
|
||||
} else if (_page == HomePage::SHUTDOWN) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -879,7 +842,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
text_reader = new TextReaderScreen(this);
|
||||
notes_screen = new NotesScreen(this);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
|
||||
repeater_admin = new RepeaterAdminScreen(this, &rtc_clock);
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
@@ -922,13 +885,12 @@ switch(t){
|
||||
|
||||
void UITask::msgRead(int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
if (msgcount == 0 && curr == msg_preview) {
|
||||
if (msgcount == 0) {
|
||||
gotoHomeScreen();
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
|
||||
const uint8_t* path) {
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
|
||||
// Add to preview screen (for notifications on non-keyboard devices)
|
||||
@@ -946,18 +908,15 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
}
|
||||
}
|
||||
|
||||
// Add to channel history screen with channel index and path data
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path);
|
||||
// Add to channel history screen with channel index
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text);
|
||||
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
|
||||
// Messages are stored in channel history, accessible via 'M' key
|
||||
// Suppress alert entirely on admin screen - it needs focused interaction
|
||||
if (!isOnRepeaterAdmin()) {
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
}
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
#else
|
||||
// Other devices: Show full preview screen (legacy behavior)
|
||||
setCurrScreen(msg_preview);
|
||||
@@ -1233,13 +1192,13 @@ void UITask::toggleGPS() {
|
||||
|
||||
if (_sensors != NULL) {
|
||||
if (_node_prefs->gps_enabled) {
|
||||
// Disable GPS  cut hardware power
|
||||
// Disable GPS  cut hardware power
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
gpsDuty.disable();
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
// Enable GPS  start duty cycle
|
||||
// Enable GPS  start duty cycle
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
gpsDuty.enable();
|
||||
@@ -1371,40 +1330,16 @@ void UITask::gotoOnboarding() {
|
||||
}
|
||||
|
||||
void UITask::gotoAudiobookPlayer() {
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
if (audiobook_screen == nullptr) return; // No audio hardware
|
||||
AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)audiobook_screen;
|
||||
if (_display != NULL) {
|
||||
abPlayer->enter(*_display);
|
||||
}
|
||||
setCurrScreen(audiobook_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint8_t UITask::getChannelScreenViewIdx() const {
|
||||
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
|
||||
}
|
||||
|
||||
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
|
||||
// Format the message as "Sender: message"
|
||||
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
|
||||
snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text);
|
||||
|
||||
// Add to channel history with path_len=0 (local message)
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
|
||||
}
|
||||
|
||||
void UITask::gotoRepeaterAdmin(int contactIdx) {
|
||||
// Lazy-initialize on first use (same pattern as audiobook player)
|
||||
if (repeater_admin == nullptr) {
|
||||
repeater_admin = new RepeaterAdminScreen(this, &rtc_clock);
|
||||
}
|
||||
|
||||
// Get contact name for the screen header
|
||||
ContactInfo contact;
|
||||
char name[32] = "Unknown";
|
||||
@@ -1424,30 +1359,29 @@ void UITask::gotoRepeaterAdmin(int contactIdx) {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
uint8_t UITask::getChannelScreenViewIdx() const {
|
||||
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
|
||||
}
|
||||
|
||||
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
|
||||
// Format the message as "Sender: message"
|
||||
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
|
||||
snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text);
|
||||
|
||||
// Add to channel history with path_len=0 (local message)
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
|
||||
}
|
||||
|
||||
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
if (isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::onAdminCliResponse(const char* from_name, const char* text) {
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
if (isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
bool UITask::isAudioPlayingInBackground() const {
|
||||
if (!audiobook_screen) return false;
|
||||
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
|
||||
return player->isAudioActive();
|
||||
}
|
||||
|
||||
bool UITask::isAudioPausedInBackground() const {
|
||||
if (!audiobook_screen) return false;
|
||||
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
|
||||
return player->isBookOpen() && !player->isAudioActive();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -57,8 +57,8 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* text_reader; // *** NEW: Text reader screen ***
|
||||
UIScreen* notes_screen; // Notes editor screen
|
||||
UIScreen* settings_screen; // Settings/onboarding screen
|
||||
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
|
||||
UIScreen* repeater_admin; // Repeater admin screen
|
||||
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
|
||||
UIScreen* curr;
|
||||
|
||||
void userLedHandler();
|
||||
@@ -89,7 +89,7 @@ public:
|
||||
void gotoSettingsScreen(); // Navigate to settings
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||
void showAlert(const char* text, int duration_millis) override;
|
||||
void forceRefresh() override { _next_refresh = 100; }
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
@@ -102,12 +102,6 @@ public:
|
||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
||||
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
// Check if audio is playing/paused in the background (for status indicators)
|
||||
bool isAudioPlayingInBackground() const;
|
||||
bool isAudioPausedInBackground() const;
|
||||
#endif
|
||||
uint8_t getChannelScreenViewIdx() const;
|
||||
|
||||
void toggleBuzzer();
|
||||
@@ -122,10 +116,6 @@ public:
|
||||
|
||||
// Add a sent message to the channel screen history
|
||||
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
|
||||
|
||||
// Repeater admin callbacks
|
||||
void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override;
|
||||
void onAdminCliResponse(const char* from_name, const char* text) override;
|
||||
|
||||
// Get current screen for checking state
|
||||
UIScreen* getCurrentScreen() const { return curr; }
|
||||
@@ -136,15 +126,18 @@ public:
|
||||
UIScreen* getChannelScreen() const { return channel_screen; }
|
||||
UIScreen* getSettingsScreen() const { return settings_screen; }
|
||||
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
|
||||
const uint8_t* path = nullptr) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void loop() override;
|
||||
|
||||
// Repeater admin callbacks (from MyMesh via AbstractUITask)
|
||||
void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override;
|
||||
void onAdminCliResponse(const char* from_name, const char* text) override;
|
||||
|
||||
void shutdown(bool restart = false);
|
||||
};
|
||||
@@ -80,8 +80,7 @@ build_flags =
|
||||
-D PIN_DISPLAY_BL=45
|
||||
-D PIN_USER_BTN=0
|
||||
-D CST328_PIN_RST=38
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.1A"'
|
||||
-D ARDUINO_LOOP_STACK_SIZE=32768
|
||||
-D FIRMWARE_VERSION='"Meck v0.8.9"'
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/LilyGo_TDeck_Pro>
|
||||
+<helpers/sensors/*.cpp>
|
||||
@@ -92,35 +91,7 @@ lib_deps =
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
bakercp/CRC32@^2.0.0
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Meck unified builds — one codebase, three variants via build flags
|
||||
; ---------------------------------------------------------------------------
|
||||
|
||||
; Audio + BLE companion (audio-player hardware with BLE phone bridging)
|
||||
[env:meck_audio_ble]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=400
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
|
||||
[env:meck_audio_standalone]
|
||||
[env:LilyGo_TDeck_Pro_companion_radio_usb]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
@@ -128,7 +99,6 @@ build_flags =
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -138,11 +108,8 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
; 4G + BLE companion (4G modem hardware, no audio — GPIO conflict with PCM5102A)
|
||||
[env:meck_4g_ble]
|
||||
[env:LilyGo_TDeck_Pro_companion_radio_ble]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
@@ -160,3 +127,21 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:LilyGo_TDeck_Pro_repeater]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-D ADVERT_NAME='"TDeck Pro Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D NO_OTA=1
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<../examples/simple_repeater>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
me-no-dev/AsyncTCP @ ^1.1.1
|
||||
me-no-dev/ESPAsyncWebServer @ ^1.2.3
|
||||
Reference in New Issue
Block a user