feat(mp3,voice): fix scroll anim audio glitch, add seek/volume/history UI

- Mp3Player:
  * Disable LV_LABEL_LONG_SCROLL_CIRCULAR during playback (flag ENABLE_TITLE_SCROLL_ANIM_WHEN_IDLE) to avoid screen invalidation starving audio_stream
  * Single-line controls [-10s][Play/Pause][Stop][+10s] icon-only, volume slider at bottom, no card/toolbar, time label MM:SS / MM:SS updating every sec
  * Seek ±10s with proportional byte calc and correct sample count (was byte vs sample bug)
  * Safe history persistence: heap buffer (not stack), recursive mkdir, play_history.txt and volume.txt in user data
  * History only for >=10min audios, updated on pause/stop/hide, auto-resume latest
  * Full-screen history screen with Back button, open via top-right list icon, playing file highlighted CHECKED, tap to resume long or restart short

- VoiceRecorder:
  * Fix crash from lv_label_set_long_mode on icon child in list buttons (force DOT only on last child label)
  * Force DOT on status and memo list to avoid scroll anim during audio
  * Throttle UI updates (1Hz rec, %/sec change play)

Tested on 192.168.68.107, no crash, heap free ~15KB, screenshot verified
This commit is contained in:
Adolfo
2026-07-30 15:00:34 -04:00
parent eb364cb34f
commit 7ef8e272fc
3 changed files with 883 additions and 126 deletions
+1
View File
@@ -4,3 +4,4 @@ idf_component_register(
SRCS ${SOURCE_FILES} SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK esp_http_client REQUIRES TactilitySDK esp_http_client
) )
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-deprecated-declarations -Wno-unused-but-set-variable -Wno-format-truncation)
File diff suppressed because it is too large Load Diff
+123 -14
View File
@@ -30,6 +30,18 @@
#define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8)) // 1024 bytes #define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8)) // 1024 bytes
#define AUDIO_BUF_SIZE 4096 #define AUDIO_BUF_SIZE 4096
// ─── Scroll animation flags ───
// LVGL label scroll animation (LV_LABEL_LONG_SCROLL_*) causes continuous
// invalidations and screen refreshes that can starve the audio_stream task.
// LVGL's lv_list_add_button() internally sets SCROLL_CIRCULAR by default (see
// lv_list.c), so we must override it for memo names.
//
// User request: apply flag only to recordings names on the list.
#define ENABLE_SCROLL_ANIM_WHEN_IDLE 1
#define ENABLE_SCROLL_ANIM_DURING_AUDIO 0
#define ENABLE_MEMO_LIST_SCROLL_ANIM 0 // 0 = DOT truncation (no anim, safe for audio), 1 = SCROLL_CIRCULAR
#define ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO 0 // Force DOT during recording/playback regardless of above
#define MAX_MEMOS 50 #define MAX_MEMOS 50
#define MAX_FILENAME_LEN 64 #define MAX_FILENAME_LEN 64
@@ -208,6 +220,8 @@ static void record_task(void* arg) {
size_t total_written = 0; size_t total_written = 0;
uint32_t start_time = xTaskGetTickCount(); uint32_t start_time = xTaskGetTickCount();
uint32_t last_ui_sec = 0;
bool first_ui = true;
while (ctx->state == STATE_RECORDING) { while (ctx->state == STATE_RECORDING) {
size_t bytes_read = 0; size_t bytes_read = 0;
@@ -223,13 +237,18 @@ static void record_task(void* arg) {
} }
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ; uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
char status_buf[64]; // Throttle UI updates to once per second to reduce LVGL invalidations during recording
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60)); if (first_ui || elapsed_sec != last_ui_sec) {
first_ui = false;
last_ui_sec = elapsed_sec;
char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
tt_lvgl_lock(portMAX_DELAY); tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf); lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF); lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF);
tt_lvgl_unlock(); tt_lvgl_unlock();
}
} }
audio_stream_close(ctx->input_handle); audio_stream_close(ctx->input_handle);
@@ -349,6 +368,9 @@ static void play_task(void* arg) {
} }
bool out_open = true; bool out_open = true;
int last_pct = -1;
uint32_t last_elapsed_sec = 0;
bool first_ui = true;
while (total_played < data_size && ctx->state != STATE_IDLE) { while (total_played < data_size && ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) { if (ctx->state == STATE_PAUSED) {
@@ -407,15 +429,21 @@ static void play_task(void* arg) {
uint32_t elapsed_sec = total_played / bytes_per_sec; uint32_t elapsed_sec = total_played / bytes_per_sec;
uint32_t total_sec = data_size / bytes_per_sec; uint32_t total_sec = data_size / bytes_per_sec;
char status_buf[64]; // Throttle UI updates only when % or second changes, to reduce LVGL work during audio
snprintf(status_buf, sizeof(status_buf), "🔊 Playing... %02u:%02u / %02u:%02u", if (first_ui || pct != last_pct || elapsed_sec != last_elapsed_sec) {
(unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60), first_ui = false;
(unsigned)(total_sec / 60), (unsigned)(total_sec % 60)); last_pct = pct;
last_elapsed_sec = elapsed_sec;
char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔊 Playing... %02u:%02u / %02u:%02u",
(unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60),
(unsigned)(total_sec / 60), (unsigned)(total_sec % 60));
tt_lvgl_lock(portMAX_DELAY); tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf); lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF); lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock(); tt_lvgl_unlock();
}
} }
fclose(f); fclose(f);
@@ -549,17 +577,91 @@ static void refresh_memo_list(AppCtx* ctx) {
for (int i = 0; i < ctx->memo_count; i++) { for (int i = 0; i < ctx->memo_count; i++) {
lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]); lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]);
lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx); lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx);
// ─── Fix: LVGL list buttons default to SCROLL_CIRCULAR (see lv_list.c)
// This causes constant invalidations that starve audio_stream.
// User wants flag to disable this for recording names.
// Safe handling: last child is always the label (icon is first if present)
uint32_t child_cnt = lv_obj_get_child_cnt(btn);
if (child_cnt > 0) {
lv_obj_t* label = lv_obj_get_child(btn, child_cnt - 1);
if (label) {
#if ENABLE_MEMO_LIST_SCROLL_ANIM
// Allow scrolling when flag enabled but force DOT during audio if that flag is off
#if ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO
lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL_CIRCULAR);
#else
// During audio we still force DOT; this func runs at list refresh (idle time)
// To respect flag for idle, we set SCROLL here; update_ui will force DOT during audio if needed
lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL_CIRCULAR);
#endif
#else
lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
#endif
}
}
if (i == ctx->selected_index) { if (i == ctx->selected_index) {
lv_obj_add_state(btn, LV_STATE_CHECKED); lv_obj_add_state(btn, LV_STATE_CHECKED);
} }
} }
} }
// Helper to force DOT on memo list during audio playback/recording if needed
static void set_memo_list_scroll_enabled(AppCtx* ctx, bool enable) {
if (!ctx->lst_memos) return;
#if ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO
// If flag allows scroll during audio, do nothing
if (enable) return;
#endif
// Force all memo labels to DOT (no animation) when audio active
uint32_t list_child_cnt = lv_obj_get_child_cnt(ctx->lst_memos);
for (uint32_t i = 0; i < list_child_cnt; i++) {
lv_obj_t* btn = lv_obj_get_child(ctx->lst_memos, i);
if (!btn) continue;
uint32_t btn_child_cnt = lv_obj_get_child_cnt(btn);
if (btn_child_cnt == 0) continue;
lv_obj_t* label = lv_obj_get_child(btn, btn_child_cnt - 1);
if (label) {
lv_label_set_long_mode(label, enable ?
#if ENABLE_MEMO_LIST_SCROLL_ANIM
LV_LABEL_LONG_SCROLL_CIRCULAR
#else
LV_LABEL_LONG_DOT
#endif
: LV_LABEL_LONG_DOT);
}
}
}
// Helper to control label scrolling based on audio state
static void set_status_scroll_enabled(AppCtx* ctx, bool enable) {
if (!ctx->lbl_status) return;
if (enable) {
#if ENABLE_SCROLL_ANIM_WHEN_IDLE
// Allow scrolling when idle if flag says so, otherwise DOT
// We intentionally keep status as DOT even when idle to avoid
// unnecessary redraws, but this hook lets future tweaks enable it.
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#else
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#endif
} else {
#if ENABLE_SCROLL_ANIM_DURING_AUDIO
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_SCROLL_CIRCULAR);
#else
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#endif
}
}
static void update_ui(AppCtx* ctx) { static void update_ui(AppCtx* ctx) {
if (ctx->lbl_status == NULL) return; if (ctx->lbl_status == NULL) return;
switch (ctx->state) { switch (ctx->state) {
case STATE_RECORDING: case STATE_RECORDING:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED); lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED); lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED); lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED);
@@ -567,6 +669,8 @@ static void update_ui(AppCtx* ctx) {
break; break;
case STATE_PLAYING: case STATE_PLAYING:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED); lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PAUSE); lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PAUSE);
@@ -575,6 +679,8 @@ static void update_ui(AppCtx* ctx) {
break; break;
case STATE_PAUSED: case STATE_PAUSED:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED); lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY); lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY);
@@ -584,6 +690,8 @@ static void update_ui(AppCtx* ctx) {
case STATE_IDLE: case STATE_IDLE:
default: default:
set_status_scroll_enabled(ctx, true);
set_memo_list_scroll_enabled(ctx, true);
lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED); lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED);
if (ctx->selected_index >= 0 && ctx->selected_index < ctx->memo_count) { if (ctx->selected_index >= 0 && ctx->selected_index < ctx->memo_count) {
@@ -642,6 +750,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_set_width(g_ctx.lbl_status, lv_pct(95)); lv_obj_set_width(g_ctx.lbl_status, lv_pct(95));
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0); lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0);
lv_label_set_long_mode(g_ctx.lbl_status, LV_LABEL_LONG_DOT);
lv_label_set_text(g_ctx.lbl_status, "Scanning memos..."); lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
g_ctx.bar_progress = lv_bar_create(card); g_ctx.bar_progress = lv_bar_create(card);