From 7ef8e272fccf3a3c3f796e7b3f388999ca9fbb7d Mon Sep 17 00:00:00 2001 From: Adolfo Date: Thu, 30 Jul 2026 15:00:34 -0400 Subject: [PATCH] feat(mp3,voice): fix scroll anim audio glitch, add seek/volume/history UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Apps/Mp3Player/main/CMakeLists.txt | 1 + Apps/Mp3Player/main/Source/main.c | 871 ++++++++++++++++++++++---- Apps/VoiceRecorder/main/Source/main.c | 137 +++- 3 files changed, 883 insertions(+), 126 deletions(-) diff --git a/Apps/Mp3Player/main/CMakeLists.txt b/Apps/Mp3Player/main/CMakeLists.txt index f97fa64..304538e 100644 --- a/Apps/Mp3Player/main/CMakeLists.txt +++ b/Apps/Mp3Player/main/CMakeLists.txt @@ -4,3 +4,4 @@ idf_component_register( SRCS ${SOURCE_FILES} REQUIRES TactilitySDK esp_http_client ) +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-deprecated-declarations -Wno-unused-but-set-variable -Wno-format-truncation) diff --git a/Apps/Mp3Player/main/Source/main.c b/Apps/Mp3Player/main/Source/main.c index 466167e..44b3d3a 100644 --- a/Apps/Mp3Player/main/Source/main.c +++ b/Apps/Mp3Player/main/Source/main.c @@ -12,6 +12,7 @@ #include #include #include +#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -24,12 +25,27 @@ #define TAG "Mp3Player" #define MP3_INPUT_BUFFER_SIZE 16384 +// ─── Scroll animation flag ─── +#define ENABLE_TITLE_SCROLL_ANIM_WHEN_IDLE 1 + +#define MAX_HISTORY 20 +#define SEEK_SECONDS 10 + typedef enum { STATE_IDLE, STATE_PLAYING, STATE_PAUSED } PlaybackState; +#define MAX_HISTORY 20 + +typedef struct { + char filepath[512]; + int pos_sec; + int total_sec; + time_t last_played; +} HistoryEntry; + typedef struct { struct Device* stream_dev; AudioStreamHandle stream_handle; @@ -41,8 +57,17 @@ typedef struct { lv_obj_t* lbl_title; lv_obj_t* lbl_status; lv_obj_t* bar_progress; + lv_obj_t* lbl_time; lv_obj_t* btn_play_pause; lv_obj_t* btn_stop; + lv_obj_t* btn_seek_back; + lv_obj_t* btn_seek_fwd; + lv_obj_t* slider_volume; + lv_obj_t* list_history; + lv_obj_t* history_container; + lv_obj_t* center_box; + lv_obj_t* bottom_box; + lv_obj_t* btn_history; // Playback state variables FILE* file; @@ -62,47 +87,431 @@ typedef struct { int pos_sec; int total_sec; long total_decoded_samples; - int start_pos_sec; // from bundle + int start_pos_sec; bool finished_naturally; - + int last_progress_pct; + + // Seek support + long id3_skip; + long file_data_size; + volatile int seek_request_sec; // -1 = none + + // History - in RAM (10KB), stored in PSRAM via g_ctx + HistoryEntry history[MAX_HISTORY]; + int history_count; + TaskHandle_t playback_task_handle; } AppCtx; static AppCtx g_ctx; +/* ─── Safe persistence helpers ─── */ +static void ensure_user_data_dir(AppHandle app) { + char dir_path[256]; + size_t len = sizeof(dir_path); + tt_app_get_user_data_path(app, dir_path, &len); + // mkdir -p recursively + char tmp[256]; + strncpy(tmp, dir_path, sizeof(tmp)-1); + tmp[sizeof(tmp)-1]='\0'; + for (char* p = tmp+1; *p; p++) { + if (*p == '/') { + *p = '\0'; + mkdir(tmp, 0755); + *p = '/'; + } + } + mkdir(tmp, 0755); + ESP_LOGI(TAG, "Ensured user data dir %s", dir_path); +} + +static void get_history_path(AppHandle app, char* buf, size_t buf_len) { + size_t len = buf_len; + tt_app_get_user_data_child_path(app, "play_history.txt", buf, &len); +} + +static void get_volume_path(AppHandle app, char* buf, size_t buf_len) { + size_t len = buf_len; + tt_app_get_user_data_child_path(app, "volume.txt", buf, &len); +} + +static void load_volume(AppCtx* ctx) { + char path[256]; + get_volume_path(ctx->app_handle, path, sizeof(path)); + FILE* f = fopen(path, "r"); + if (!f) { + ctx->volume = 80; + return; + } + int vol = 80; + if (fscanf(f, "%d", &vol) == 1) { + if (vol < 0) vol = 0; + if (vol > 100) vol = 100; + ctx->volume = vol; + } + fclose(f); + ESP_LOGI(TAG, "Loaded volume %d", ctx->volume); +} + +static void save_volume(AppCtx* ctx) { + char path[256]; + get_volume_path(ctx->app_handle, path, sizeof(path)); + // Ensure dir exists + ensure_user_data_dir(ctx->app_handle); + FILE* f = fopen(path, "w"); + if (!f) { + ESP_LOGW(TAG, "Failed to save volume to %s", path); + return; + } + fprintf(f, "%d\n", ctx->volume); + fclose(f); +} + +static void load_history(AppCtx* ctx) { + ctx->history_count = 0; + char path[256]; + get_history_path(ctx->app_handle, path, sizeof(path)); + FILE* f = fopen(path, "r"); + if (!f) { + ESP_LOGI(TAG, "No history file"); + return; + } + // Use heap buffer to avoid large stack allocation in LVGL task + char* line = (char*)malloc(1024); + if (!line) { + fclose(f); + return; + } + while (fgets(line, 1024, f) && ctx->history_count < MAX_HISTORY) { + char* p1 = strchr(line, '|'); + if (!p1) continue; + *p1 = '\0'; + char* p2 = strchr(p1+1, '|'); + if (!p2) continue; + *p2 = '\0'; + char* p3 = strchr(p2+1, '|'); + if (!p3) continue; + *p3 = '\0'; + long ts = atol(line); + int pos = atoi(p1+1); + int total = atoi(p2+1); + char filepath[512]; + strncpy(filepath, p3+1, sizeof(filepath)-1); + filepath[sizeof(filepath)-1] = '\0'; + size_t len = strlen(filepath); + while (len > 0 && (filepath[len-1]=='\n' || filepath[len-1]=='\r')) { + filepath[len-1]='\0'; len--; + } + if (filepath[0]=='\0') continue; + HistoryEntry* e = &ctx->history[ctx->history_count++]; + strncpy(e->filepath, filepath, sizeof(e->filepath)-1); + e->filepath[sizeof(e->filepath)-1]='\0'; + e->pos_sec = pos; + e->total_sec = total; + e->last_played = (time_t)ts; + } + free(line); + fclose(f); + ESP_LOGI(TAG, "Loaded %d history entries", ctx->history_count); + // Sort by last_played descending + for (int i=0;ihistory_count-1;i++) { + for (int j=0;jhistory_count-i-1;j++) { + if (ctx->history[j].last_played < ctx->history[j+1].last_played) { + HistoryEntry tmp = ctx->history[j]; + ctx->history[j] = ctx->history[j+1]; + ctx->history[j+1] = tmp; + } + } + } +} + +static void save_history(AppCtx* ctx) { + char path[256]; + get_history_path(ctx->app_handle, path, sizeof(path)); + ensure_user_data_dir(ctx->app_handle); + FILE* f = fopen(path, "w"); + if (!f) { + ESP_LOGW(TAG, "Failed to save history to %s", path); + return; + } + for (int i=0;ihistory_count;i++) { + HistoryEntry* e = &ctx->history[i]; + fprintf(f, "%ld|%d|%d|%s\n", (long)e->last_played, e->pos_sec, e->total_sec, e->filepath); + } + fclose(f); + ESP_LOGI(TAG, "Saved %d history entries", ctx->history_count); +} + +static void update_history_entry(AppCtx* ctx, const char* filepath, int pos_sec, int total_sec) { + if (!filepath || filepath[0]=='\0') return; + // Only track audios >=10min (600 sec) as requested + if (total_sec > 0 && total_sec < 600) { + ESP_LOGI(TAG, "Skip history: audio too short (%d sec < 600)", total_sec); + return; + } + time_t now = time(NULL); + int idx = -1; + for (int i=0;ihistory_count;i++) { + if (strcmp(ctx->history[i].filepath, filepath)==0) { idx=i; break; } + } + if (idx>=0) { + ctx->history[idx].pos_sec = pos_sec; + ctx->history[idx].total_sec = total_sec; + ctx->history[idx].last_played = now; + } else { + if (ctx->history_count >= MAX_HISTORY) ctx->history_count = MAX_HISTORY-1; + for (int i=ctx->history_count;i>0;i--) ctx->history[i]=ctx->history[i-1]; + HistoryEntry* e=&ctx->history[0]; + strncpy(e->filepath, filepath, sizeof(e->filepath)-1); + e->filepath[sizeof(e->filepath)-1]='\0'; + e->pos_sec=pos_sec; e->total_sec=total_sec; e->last_played=now; + ctx->history_count++; + } + // Re-sort + for (int i=0;ihistory_count-1;i++) { + for (int j=0;jhistory_count-i-1;j++) { + if (ctx->history[j].last_played < ctx->history[j+1].last_played) { + HistoryEntry tmp=ctx->history[j]; + ctx->history[j]=ctx->history[j+1]; + ctx->history[j+1]=tmp; + } + } + } +} + +static bool get_last_played(AppCtx* ctx, char* out_path, size_t out_len, int* out_pos) { + if (ctx->history_count==0) return false; + for (int i=0;ihistory_count;i++) { + HistoryEntry* e=&ctx->history[i]; + struct stat st; + if (stat(e->filepath,&st)!=0) continue; + if (e->total_sec>0 && e->pos_sec >= e->total_sec-10) continue; + if (e->pos_sec <=5) continue; + strncpy(out_path, e->filepath, out_len-1); + out_path[out_len-1]='\0'; + if (out_pos) *out_pos=e->pos_sec; + return true; + } + // Fallback to most recent + HistoryEntry* e=&ctx->history[0]; + struct stat st; + if (stat(e->filepath,&st)!=0) return false; + strncpy(out_path, e->filepath, out_len-1); + out_path[out_len-1]='\0'; + if (out_pos) *out_pos=e->pos_sec; + return true; +} + +/* ─── Forward decls ─── */ +static void update_ui(AppCtx* ctx); +static void mp3_playback_task(void* arg); +static void refresh_history_list(AppCtx* ctx); +static void show_history_screen(AppCtx* ctx); +static void hide_history_screen(AppCtx* ctx); + +/* ─── History UI ─── */ +static void load_and_play_file(AppCtx* ctx, const char* filepath, int start_pos); + +static void on_history_selected(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + int idx = (int)(intptr_t)lv_event_get_user_data(e); + // Actually user_data is ctx, we need index via event param? Use lv_event_get_user_data returns ctx, but we need index. + // We'll store index in btn's user_data via lv_obj_set_user_data? Simpler: use event param as index via lv_event_get_param? + // For now, get index from button's index in list_history + // The callback is set with (void*)index, not ctx, so get it: + int index = (int)(intptr_t)lv_event_get_user_data(e); + // But we also need ctx global - use g_ctx + AppCtx* c = &g_ctx; + if (index < 0 || index >= c->history_count) return; + HistoryEntry* he = &c->history[index]; + int resume_pos = 0; + if (he->total_sec >= 600) { + resume_pos = he->pos_sec; + if (resume_pos <= 5) resume_pos = 0; + if (he->total_sec >0 && resume_pos >= he->total_sec - 10) resume_pos = 0; + } else { + resume_pos = 0; // short files start from beginning + } + ESP_LOGI(TAG, "History tap %d: %s resume %d (total %d)", index, he->filepath, resume_pos, he->total_sec); + // Hide history full screen and go back to player + hide_history_screen(c); + load_and_play_file(c, he->filepath, resume_pos); +} + +static void refresh_history_list(AppCtx* ctx) { + if (!ctx->list_history) return; + lv_obj_clean(ctx->list_history); + if (ctx->history_count == 0) { + lv_list_add_text(ctx->list_history, "No history yet"); + return; + } + for (int i=0;ihistory_count;i++) { + HistoryEntry* he = &ctx->history[i]; + const char* slash = strrchr(he->filepath, '/'); + const char* fname = slash ? slash+1 : he->filepath; + char label[128]; + if (he->total_sec >= 600) { + int pos_m = he->pos_sec / 60; + int pos_s = he->pos_sec % 60; + int tot_m = he->total_sec / 60; + int tot_s = he->total_sec % 60; + snprintf(label, sizeof(label), "%s\n%02d:%02d / %02d:%02d", fname, pos_m, pos_s, tot_m, tot_s); + } else { + snprintf(label, sizeof(label), "%s", fname); + } + lv_obj_t* btn = lv_list_add_btn(ctx->list_history, LV_SYMBOL_AUDIO, label); + // Force DOT to avoid scroll anim glitch (safe: last child is label) + uint32_t cnt = lv_obj_get_child_cnt(btn); + if (cnt > 0) { + lv_obj_t* lbl = lv_obj_get_child(btn, cnt-1); + if (lbl) lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); + } + // Highlight currently playing file + if (ctx->filepath[0] != '\0' && strcmp(ctx->filepath, he->filepath) == 0) { + lv_obj_add_state(btn, LV_STATE_CHECKED); + } + lv_obj_add_event_cb(btn, on_history_selected, LV_EVENT_CLICKED, (void*)(intptr_t)i); + } +} + +static void show_history_screen(AppCtx* ctx) { + if (!ctx->history_container || !ctx->center_box || !ctx->bottom_box) return; + // Refresh to show latest and highlight + refresh_history_list(ctx); + // Expand history to full screen + lv_obj_set_size(ctx->history_container, lv_pct(100), lv_pct(100)); + lv_obj_align(ctx->history_container, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_bg_color(ctx->history_container, lv_color_hex(0x11111B), 0); + lv_obj_set_style_bg_opa(ctx->history_container, LV_OPA_COVER, 0); + lv_obj_remove_flag(ctx->history_container, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(ctx->history_container); + // Hide player UI + lv_obj_add_flag(ctx->center_box, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(ctx->bottom_box, LV_OBJ_FLAG_HIDDEN); +} + +static void hide_history_screen(AppCtx* ctx) { + if (!ctx->history_container || !ctx->center_box || !ctx->bottom_box) return; + // Restore small history container hidden state (or keep as small below center) + // For full-screen mode, we hide history container and show player + lv_obj_add_flag(ctx->history_container, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(ctx->center_box, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(ctx->bottom_box, LV_OBJ_FLAG_HIDDEN); + // Restore small size for when it's shown inline (if needed) + lv_obj_set_size(ctx->history_container, lv_pct(92), lv_pct(28)); + lv_obj_align(ctx->history_container, LV_ALIGN_CENTER, 0, 35); +} + +static void load_and_play_file(AppCtx* ctx, const char* filepath, int start_pos) { + if (!filepath || filepath[0]=='\0') return; + // Stop current playback if any + if (ctx->state != STATE_IDLE) { + ctx->state = STATE_IDLE; + int tries=0; + while (ctx->playback_task_handle != NULL && tries<50) { + vTaskDelay(pdMS_TO_TICKS(50)); + tries++; + } + if (ctx->playback_task_handle) { + vTaskDelete(ctx->playback_task_handle); + ctx->playback_task_handle = NULL; + } + if (ctx->stream_handle) { + audio_stream_close(ctx->stream_handle); + ctx->stream_handle = NULL; + } + } + strncpy(ctx->filepath, filepath, sizeof(ctx->filepath)-1); + ctx->filepath[sizeof(ctx->filepath)-1]='\0'; + ctx->start_pos_sec = start_pos; + const char* slash = strrchr(filepath, '/'); + const char* fname = slash ? slash+1 : filepath; + if (ctx->lbl_title) { + lv_label_set_text(ctx->lbl_title, fname); + } + ctx->state = STATE_PLAYING; + update_ui(ctx); + xTaskCreate(mp3_playback_task, "mp3_play", 6144, ctx, 6, &ctx->playback_task_handle); +} + /* ─── Update UI ─── */ +static void set_title_scroll_enabled(AppCtx* ctx, bool enable) { + if (!ctx->lbl_title) return; + if (enable) { +#if ENABLE_TITLE_SCROLL_ANIM_WHEN_IDLE + lv_label_set_long_mode(ctx->lbl_title, LV_LABEL_LONG_SCROLL_CIRCULAR); +#else + lv_label_set_long_mode(ctx->lbl_title, LV_LABEL_LONG_DOT); +#endif + } else { + lv_label_set_long_mode(ctx->lbl_title, LV_LABEL_LONG_DOT); + } +} + static void update_ui(AppCtx* ctx) { if (!ctx->lbl_status) return; switch (ctx->state) { case STATE_PLAYING: + set_title_scroll_enabled(ctx, false); lv_label_set_text(ctx->lbl_status, "Playing..."); - lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PAUSE " Pause"); - lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); - lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED); + if (ctx->btn_play_pause) { + lv_obj_t* lbl = lv_obj_get_child(ctx->btn_play_pause, 0); + if (lbl) lv_label_set_text(lbl, LV_SYMBOL_PAUSE); + } + if (ctx->btn_play_pause) lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); + if (ctx->btn_stop) lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED); + if (ctx->btn_seek_back) lv_obj_clear_state(ctx->btn_seek_back, LV_STATE_DISABLED); + if (ctx->btn_seek_fwd) lv_obj_clear_state(ctx->btn_seek_fwd, LV_STATE_DISABLED); break; case STATE_PAUSED: + set_title_scroll_enabled(ctx, false); lv_label_set_text(ctx->lbl_status, "Paused"); - lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY " Play"); - lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); - lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED); + if (ctx->btn_play_pause) { + lv_obj_t* lbl = lv_obj_get_child(ctx->btn_play_pause, 0); + if (lbl) lv_label_set_text(lbl, LV_SYMBOL_PLAY); + } + if (ctx->btn_play_pause) lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); + if (ctx->btn_stop) lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED); + if (ctx->btn_seek_back) lv_obj_clear_state(ctx->btn_seek_back, LV_STATE_DISABLED); + if (ctx->btn_seek_fwd) lv_obj_clear_state(ctx->btn_seek_fwd, LV_STATE_DISABLED); break; case STATE_IDLE: default: + set_title_scroll_enabled(ctx, true); if (ctx->filepath[0] != '\0') { lv_label_set_text(ctx->lbl_status, "Ready"); - lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); + if (ctx->btn_play_pause) lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); } else { lv_label_set_text(ctx->lbl_status, "No file loaded"); - lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED); + if (ctx->btn_play_pause) lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED); } - lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY " Play"); - lv_obj_add_state(ctx->btn_stop, LV_STATE_DISABLED); + if (ctx->btn_play_pause) { + lv_obj_t* lbl = lv_obj_get_child(ctx->btn_play_pause, 0); + if (lbl) lv_label_set_text(lbl, LV_SYMBOL_PLAY); + } + if (ctx->btn_stop) lv_obj_add_state(ctx->btn_stop, LV_STATE_DISABLED); + if (ctx->btn_seek_back) lv_obj_add_state(ctx->btn_seek_back, LV_STATE_DISABLED); + if (ctx->btn_seek_fwd) lv_obj_add_state(ctx->btn_seek_fwd, LV_STATE_DISABLED); lv_bar_set_value(ctx->bar_progress, 0, LV_ANIM_OFF); + if (ctx->lbl_time) { + lv_label_set_text(ctx->lbl_time, "00:00 / 00:00"); + } break; } } +/* ─── Seek handling ─── */ +static void request_seek(AppCtx* ctx, int delta_sec) { + if (ctx->filepath[0] == '\0') return; + if (ctx->state == STATE_IDLE) return; // no task to seek + int target = ctx->pos_sec + delta_sec; + if (target < 0) target = 0; + if (ctx->total_sec > 0 && target > ctx->total_sec) target = ctx->total_sec - 1; + ctx->seek_request_sec = target; + ESP_LOGI(TAG, "Seek requested: %d -> %d (delta %d)", ctx->pos_sec, target, delta_sec); +} + /* ─── Playback Task ─── */ static void mp3_playback_task(void* arg) { AppCtx* ctx = (AppCtx*)arg; @@ -128,27 +537,40 @@ static void mp3_playback_task(void* arg) { ctx->total_sec = 0; ctx->total_decoded_samples = 0; ctx->finished_naturally = false; + ctx->last_progress_pct = -1; + ctx->seek_request_sec = -1; - // Handle ID3 and resume seek (like DiscoveryMountain) + // Handle ID3 and resume seek uint8_t id3hdr[10]; - long id3_skip = 0; + ctx->id3_skip = 0; if (fread(id3hdr,1,10,ctx->file)==10 && memcmp(id3hdr,"ID3",3)==0) { int id3size = (id3hdr[6]&0x7F)<<21 | (id3hdr[7]&0x7F)<<14 | (id3hdr[8]&0x7F)<<7 | (id3hdr[9]&0x7F); - id3_skip = id3size + 10; - fseek(ctx->file, id3_skip, SEEK_SET); + ctx->id3_skip = id3size + 10; + fseek(ctx->file, ctx->id3_skip, SEEK_SET); } else { fseek(ctx->file, 0, SEEK_SET); } - long file_data_size = ctx->file_size - id3_skip; - if (file_data_size>0) ctx->total_sec = (int)(file_data_size / 16000); // rough estimate + ctx->file_data_size = ctx->file_size - ctx->id3_skip; + if (ctx->file_data_size>0) ctx->total_sec = (int)(ctx->file_data_size / 16000); if (ctx->start_pos_sec > 5) { - long seek_bytes = (long)ctx->start_pos_sec * 16000; - if (seek_bytes < file_data_size) { - fseek(ctx->file, id3_skip + seek_bytes, SEEK_SET); - ctx->total_decoded_samples = (long)ctx->start_pos_sec * 16000; + long seek_bytes; + if (ctx->total_sec > 0 && ctx->file_data_size > 0) { + // Proportional seek based on estimated duration + seek_bytes = (long)((int64_t)ctx->start_pos_sec * ctx->file_data_size / ctx->total_sec); + } else { + seek_bytes = (long)ctx->start_pos_sec * 16000; + } + if (seek_bytes < 0) seek_bytes = 0; + if (seek_bytes >= ctx->file_data_size) seek_bytes = ctx->file_data_size - 1024; + if (seek_bytes < ctx->file_data_size) { + fseek(ctx->file, ctx->id3_skip + seek_bytes, SEEK_SET); + ctx->bytes_read_total = ctx->id3_skip + seek_bytes; + // total_decoded_samples is in samples, not bytes - use estimated sample rate + // 44100 Hz is common, use that for initial estimate; will be corrected after first frame + ctx->total_decoded_samples = (long)ctx->start_pos_sec * 44100; ctx->pos_sec = ctx->start_pos_sec; - ESP_LOGI(TAG, "Resuming %s from %d sec", ctx->filepath, ctx->start_pos_sec); + ESP_LOGI(TAG, "Resuming %s from %d sec (seek_bytes %ld, file_data %ld, total_est %d)", ctx->filepath, ctx->start_pos_sec, seek_bytes, ctx->file_data_size, ctx->total_sec); } } @@ -162,12 +584,47 @@ static void mp3_playback_task(void* arg) { ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes) resume=%d", ctx->filepath, ctx->file_size, ctx->start_pos_sec); while (ctx->state != STATE_IDLE) { + // ─── Handle seek request ─── + if (ctx->seek_request_sec >= 0) { + int target = ctx->seek_request_sec; + ctx->seek_request_sec = -1; + long seek_bytes; + if (ctx->total_sec > 0 && ctx->file_data_size > 0) { + seek_bytes = (long)((int64_t)target * ctx->file_data_size / ctx->total_sec); + } else { + seek_bytes = (long)target * 16000; + } + if (seek_bytes < 0) seek_bytes = 0; + if (seek_bytes >= ctx->file_data_size) seek_bytes = ctx->file_data_size - 1024; + fseek(ctx->file, ctx->id3_skip + seek_bytes, SEEK_SET); + ctx->buffered_bytes = 0; + ctx->eof = false; + ctx->bytes_read_total = ctx->id3_skip + seek_bytes; + // Reset decoder for clean seek + mp3dec_init(&ctx->decoder); + if (ctx->sample_rate > 0) { + ctx->total_decoded_samples = (long)target * ctx->sample_rate; + } else { + ctx->total_decoded_samples = (long)target * 44100; + } + ctx->pos_sec = target; + ctx->last_progress_pct = -1; + ESP_LOGI(TAG, "Seeked to %d sec (bytes %ld)", target, seek_bytes); + // Update UI progress + if (ctx->file_size > 0) { + int pct = (int)(ctx->bytes_read_total * 100 / ctx->file_size); + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + tt_lvgl_lock(portMAX_DELAY); + lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF); + tt_lvgl_unlock(); + } + } + if (ctx->state == STATE_PAUSED) { if (ctx->stream_handle != NULL) { - // Close stream immediately on pause to stop DMA audio_stream_close(ctx->stream_handle); ctx->stream_handle = NULL; - // Clear cached format to force re-open on resume ctx->sample_rate = 0; ctx->channels = 0; } @@ -175,15 +632,12 @@ static void mp3_playback_task(void* arg) { continue; } - // Fill input buffer if (!ctx->eof && ctx->buffered_bytes < MP3_INPUT_BUFFER_SIZE) { size_t to_read = MP3_INPUT_BUFFER_SIZE - ctx->buffered_bytes; size_t read_bytes = fread(ctx->input_buf + ctx->buffered_bytes, 1, to_read, ctx->file); ctx->buffered_bytes += read_bytes; ctx->bytes_read_total += read_bytes; - if (read_bytes == 0) { - ctx->eof = true; - } + if (read_bytes == 0) ctx->eof = true; } if (ctx->buffered_bytes == 0 && ctx->eof) { @@ -191,16 +645,12 @@ static void mp3_playback_task(void* arg) { break; } - // Decode one frame mp3dec_frame_info_t info; memset(&info, 0, sizeof(info)); int samples = mp3dec_decode_frame(&ctx->decoder, ctx->input_buf, (int)ctx->buffered_bytes, ctx->pcm_buf, &info); if (info.frame_bytes <= 0) { - if (ctx->eof) { - break; - } - // Move 1 byte forward to resync + if (ctx->eof) break; memmove(ctx->input_buf, ctx->input_buf + 1, --ctx->buffered_bytes); continue; } @@ -213,25 +663,18 @@ static void mp3_playback_task(void* arg) { ctx->total_decoded_samples += samples; if (info.hz > 0) { ctx->pos_sec = ctx->total_decoded_samples / info.hz; - // Update total_sec estimate if needed - if (ctx->total_sec == 0 && ctx->file_size > 0) { - // rough estimate already done, but keep - } } - // Configure audio-stream if format changed if (ctx->sample_rate != info.hz || ctx->channels != info.channels) { if (ctx->stream_handle != NULL) { audio_stream_close(ctx->stream_handle); ctx->stream_handle = NULL; } - struct AudioStreamConfig config = { .sample_rate = (uint32_t)info.hz, .bits_per_sample = 16, .channels = (uint8_t)info.channels }; - error_t err = audio_stream_open_output(ctx->stream_dev, &config, &ctx->stream_handle); if (err != ERROR_NONE) { ESP_LOGE(TAG, "Failed to open audio stream: %d", err); @@ -242,7 +685,6 @@ static void mp3_playback_task(void* arg) { ESP_LOGI(TAG, "Audio stream opened: %d Hz, %d channels", info.hz, info.channels); } - // Adjust volume (after resampler) int vol = ctx->volume; int16_t* samples_ptr = (int16_t*)ctx->pcm_buf; size_t sample_count = (size_t)samples * info.channels; @@ -251,7 +693,6 @@ static void mp3_playback_task(void* arg) { samples_ptr[i] = (int16_t)scaled; } - // Play audio via audio-stream (resampled to native 44100) size_t offset = 0; size_t data_size = sample_count * sizeof(int16_t); bool write_err = false; @@ -265,18 +706,40 @@ static void mp3_playback_task(void* arg) { } offset += written; } - if (write_err) { - break; - } + if (write_err) break; } - // Update progress UI + // Progress bar: update when % changes (throttled) + int pct = -1; if (ctx->file_size > 0) { - int pct = (int)((ctx->bytes_read_total - ctx->buffered_bytes) * 100 / ctx->file_size); + pct = (int)((ctx->bytes_read_total - ctx->buffered_bytes) * 100 / ctx->file_size); if (pct < 0) pct = 0; if (pct > 100) pct = 100; + } + // Time label: update every second (fast) rather than only on % change + // This fixes user report: label updated every 10-15s for long files + static int last_time_ui_sec = -1; + bool need_time_update = (ctx->pos_sec != last_time_ui_sec); + bool need_bar_update = (pct >=0 && pct != ctx->last_progress_pct); + + if (need_bar_update || need_time_update) { + char time_buf[32]; + int cur_m = ctx->pos_sec / 60; + int cur_s = ctx->pos_sec % 60; + int tot_m = ctx->total_sec / 60; + int tot_s = ctx->total_sec % 60; + if (tot_m > 99) { tot_m = 99; tot_s = 59; } + snprintf(time_buf, sizeof(time_buf), "%02d:%02d / %02d:%02d", cur_m, cur_s, tot_m, tot_s); + tt_lvgl_lock(portMAX_DELAY); - lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF); + if (need_bar_update && pct >=0) { + lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF); + ctx->last_progress_pct = pct; + } + if (need_time_update) { + if (ctx->lbl_time) lv_label_set_text(ctx->lbl_time, time_buf); + last_time_ui_sec = ctx->pos_sec; + } tt_lvgl_unlock(); } } @@ -285,14 +748,12 @@ static void mp3_playback_task(void* arg) { ctx->file = NULL; if (ctx->pos_sec > 0 && ctx->total_sec >0 && ctx->pos_sec >= ctx->total_sec - 10) { - // Finished naturally, clear resume ctx->pos_sec = 0; ctx->finished_naturally = true; } else { ctx->finished_naturally = false; } - // Close audio stream to clean up DMA / resampler task if (ctx->stream_handle != NULL) { audio_stream_close(ctx->stream_handle); ctx->stream_handle = NULL; @@ -321,6 +782,14 @@ static void on_play_pause_click(lv_event_t* e) { } else if (ctx->state == STATE_PLAYING) { ctx->state = STATE_PAUSED; update_ui(ctx); + // Update history on pause, only for 10min+ audios + bool is_long = (ctx->total_sec >= 600) || (ctx->file_data_size >= 600*16000L); + if (is_long) { + update_history_entry(ctx, ctx->filepath, ctx->pos_sec, ctx->total_sec); + save_history(ctx); + refresh_history_list(ctx); + ESP_LOGI(TAG, "History updated on pause: %s pos %d total %d", ctx->filepath, ctx->pos_sec, ctx->total_sec); + } } else if (ctx->state == STATE_PAUSED) { ctx->state = STATE_PLAYING; update_ui(ctx); @@ -330,22 +799,61 @@ static void on_play_pause_click(lv_event_t* e) { static void on_stop_click(lv_event_t* e) { AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); if (ctx->state == STATE_IDLE) return; - + // Update history on stop for 10min+ audios + bool is_long = (ctx->total_sec >= 600) || (ctx->file_data_size >= 600*16000L); + if (is_long) { + update_history_entry(ctx, ctx->filepath, ctx->pos_sec, ctx->total_sec); + save_history(ctx); + refresh_history_list(ctx); + ESP_LOGI(TAG, "History updated on stop: %s pos %d total %d", ctx->filepath, ctx->pos_sec, ctx->total_sec); + } ctx->state = STATE_IDLE; update_ui(ctx); } +static void on_seek_back_click(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + request_seek(ctx, -SEEK_SECONDS); +} + +static void on_seek_fwd_click(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + request_seek(ctx, SEEK_SECONDS); +} + +static void on_volume_slider_changed(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + lv_obj_t* slider = lv_event_get_target(e); + int vol = lv_slider_get_value(slider); + ctx->volume = vol; + save_volume(ctx); +} + +static void on_close_click(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + tt_app_stop(ctx->app_handle); +} + +static void on_history_open_click(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + show_history_screen(ctx); +} + +static void on_history_cancel_click(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + hide_history_screen(ctx); +} + /* ─── App Lifecycle ─── */ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { memset(&g_ctx, 0, sizeof(g_ctx)); g_ctx.volume = 80; g_ctx.app_handle = app; + g_ctx.seek_request_sec = -1; - // Allocate memory buffers g_ctx.input_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE); g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t)); - // Find audio-stream device with retry (seen "not found" when launched from DM) for (int tries=0; tries<5; tries++) { struct Device* dev = device_find_by_name("audio-stream"); if (dev) { g_ctx.stream_dev = dev; break; } @@ -356,11 +864,12 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { } if (!g_ctx.stream_dev) { ESP_LOGE(TAG, "Audio-stream device not found after retries!"); - } else { - ESP_LOGI(TAG, "Found audio-stream device %p", g_ctx.stream_dev); } + + // Load persisted volume and history (safe with heap buffer) + load_volume(&g_ctx); + load_history(&g_ctx); - // Parse launch parameters BundleHandle bundle = tt_app_get_parameters(app); bool has_file_param = false; if (bundle) { @@ -368,10 +877,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { if (tt_bundle_opt_string(bundle, "file", file_param, sizeof(file_param)) || tt_bundle_opt_string(bundle, "path", file_param, sizeof(file_param)) || tt_bundle_opt_string(bundle, "filepath", file_param, sizeof(file_param))) { - // Safely resolve SD card path if (strncmp(file_param, "/sdcard", 7) == 0) { strncpy(g_ctx.filepath, file_param, sizeof(g_ctx.filepath) - 1); - g_ctx.filepath[sizeof(g_ctx.filepath)-1]=0; } else { if (file_param[0] == '/') { size_t needed = 7 + strlen(file_param) + 1; @@ -380,7 +887,6 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { strcat(g_ctx.filepath, file_param); } else { strncpy(g_ctx.filepath, file_param, sizeof(g_ctx.filepath)-1); - g_ctx.filepath[sizeof(g_ctx.filepath)-1]=0; } } else { size_t needed = 8 + strlen(file_param) + 1; @@ -389,10 +895,10 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { strcat(g_ctx.filepath, file_param); } else { strncpy(g_ctx.filepath, file_param, sizeof(g_ctx.filepath)-1); - g_ctx.filepath[sizeof(g_ctx.filepath)-1]=0; } } } + g_ctx.filepath[sizeof(g_ctx.filepath)-1]=0; has_file_param = true; } int32_t pos = 0; @@ -407,11 +913,20 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { } } - // For direct testing from Launcher without bundle (user request: run mp3 player with episode on sd) - // Use a known existing file on SD card if no file param provided + // Try resume from history (only 10min+ entries) + if (!has_file_param) { + char last_path[512]; + int last_pos = 0; + if (get_last_played(&g_ctx, last_path, sizeof(last_path), &last_pos)) { + strncpy(g_ctx.filepath, last_path, sizeof(g_ctx.filepath)-1); + g_ctx.filepath[sizeof(g_ctx.filepath)-1]=0; + g_ctx.start_pos_sec = last_pos; + ESP_LOGI(TAG, "Resuming last 10min+ play %s at %d sec from history", g_ctx.filepath, last_pos); + has_file_param = true; + } + } + // Fallback test files if still no param if (!has_file_param) { - // Try to find first mp3 in /sdcard/dm/ - // For quick test, use a hardcoded existing file from logs const char *test_files[] = { "/sdcard/dm/462_Gadgets_Escape_S01E02.mp3", "/sdcard/dm/463_Discovering_Discovery_Mountain_S01E01.mp3", @@ -430,30 +945,49 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { } } - // ─── UI Layout ─── - lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + // ─── UI Layout (redesigned per request) ─── + // No toolbar, no card container, volume + controls at bottom + lv_obj_set_style_bg_color(parent, lv_color_hex(0x11111B), 0); + lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0); + + // Close button top-left since toolbar removed + lv_obj_t* btn_close = lv_btn_create(parent); + lv_obj_set_size(btn_close, 36, 28); + lv_obj_align(btn_close, LV_ALIGN_TOP_LEFT, 6, 6); + lv_obj_set_style_radius(btn_close, 8, 0); + lv_obj_set_style_bg_color(btn_close, lv_color_hex(0x313244), 0); + lv_obj_t* lbl_close = lv_label_create(btn_close); + lv_label_set_text(lbl_close, LV_SYMBOL_CLOSE); + lv_obj_center(lbl_close); + lv_obj_add_event_cb(btn_close, on_close_click, LV_EVENT_CLICKED, &g_ctx); + + // History button top-right + lv_obj_t* btn_hist = lv_btn_create(parent); + lv_obj_set_size(btn_hist, 36, 28); + lv_obj_align(btn_hist, LV_ALIGN_TOP_RIGHT, -6, 6); + lv_obj_set_style_radius(btn_hist, 8, 0); + lv_obj_set_style_bg_color(btn_hist, lv_color_hex(0x313244), 0); + lv_obj_t* lbl_hist_btn = lv_label_create(btn_hist); + lv_label_set_text(lbl_hist_btn, LV_SYMBOL_LIST); + lv_obj_center(lbl_hist_btn); + lv_obj_add_event_cb(btn_hist, on_history_open_click, LV_EVENT_CLICKED, &g_ctx); + g_ctx.btn_history = btn_hist; + + // Center content vertically (title, status, progress, time) + lv_obj_t* center_box = lv_obj_create(parent); + lv_obj_remove_style_all(center_box); + lv_obj_set_size(center_box, lv_pct(90), lv_pct(45)); + lv_obj_align(center_box, LV_ALIGN_CENTER, 0, -60); + lv_obj_set_flex_flow(center_box, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(center_box, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_gap(center_box, 12, 0); + g_ctx.center_box = center_box; - // Player Card (rounded box) - lv_obj_t* card = lv_obj_create(parent); - lv_obj_set_size(card, lv_pct(90), lv_pct(70)); - lv_obj_align(card, LV_ALIGN_CENTER, 0, 15); - lv_obj_set_style_radius(card, 15, 0); - lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0); - lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0); - lv_obj_set_style_border_width(card, 2, 0); - lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(card, 15, 0); - lv_obj_set_style_pad_gap(card, 15, 0); - - // Audio Icon - lv_obj_t* icon = lv_label_create(card); + lv_obj_t* icon = lv_label_create(center_box); lv_label_set_text(icon, LV_SYMBOL_AUDIO); lv_obj_set_style_text_color(icon, lv_color_hex(0x89B4FA), 0); - // Track Title - g_ctx.lbl_title = lv_label_create(card); + g_ctx.lbl_title = lv_label_create(center_box); lv_obj_set_width(g_ctx.lbl_title, lv_pct(95)); lv_obj_set_style_text_align(g_ctx.lbl_title, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_color(g_ctx.lbl_title, lv_color_hex(0xCDD6F4), 0); @@ -461,65 +995,175 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { const char* last_slash = strrchr(g_ctx.filepath, '/'); const char* filename = last_slash ? last_slash + 1 : g_ctx.filepath; lv_label_set_text(g_ctx.lbl_title, filename); +#if ENABLE_TITLE_SCROLL_ANIM_WHEN_IDLE lv_label_set_long_mode(g_ctx.lbl_title, LV_LABEL_LONG_SCROLL_CIRCULAR); +#else + lv_label_set_long_mode(g_ctx.lbl_title, LV_LABEL_LONG_DOT); +#endif } else { lv_label_set_text(g_ctx.lbl_title, "No File Parameter"); + lv_label_set_long_mode(g_ctx.lbl_title, LV_LABEL_LONG_DOT); } - // Playback Status - g_ctx.lbl_status = lv_label_create(card); + g_ctx.lbl_status = lv_label_create(center_box); + lv_label_set_long_mode(g_ctx.lbl_status, LV_LABEL_LONG_DOT); lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xA6ADC8), 0); - // Progress Bar - g_ctx.bar_progress = lv_bar_create(card); - lv_obj_set_size(g_ctx.bar_progress, lv_pct(85), 8); + g_ctx.bar_progress = lv_bar_create(center_box); + lv_obj_set_size(g_ctx.bar_progress, lv_pct(88), 8); lv_bar_set_range(g_ctx.bar_progress, 0, 100); lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF); lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN); lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR); + + g_ctx.lbl_time = lv_label_create(center_box); + lv_obj_set_style_text_color(g_ctx.lbl_time, lv_color_hex(0xA6ADC8), 0); + lv_obj_set_style_text_font(g_ctx.lbl_time, lv_theme_get_font_small(center_box), 0); + lv_label_set_text(g_ctx.lbl_time, "00:00 / 00:00"); + + // History full-screen container (hidden by default) + lv_obj_t* history_cont = lv_obj_create(parent); + lv_obj_remove_style_all(history_cont); + lv_obj_set_size(history_cont, lv_pct(100), lv_pct(100)); + lv_obj_align(history_cont, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_bg_color(history_cont, lv_color_hex(0x11111B), 0); + lv_obj_set_style_bg_opa(history_cont, LV_OPA_COVER, 0); + lv_obj_set_flex_flow(history_cont, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(history_cont, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_all(history_cont, 10, 0); + lv_obj_set_style_pad_gap(history_cont, 8, 0); + lv_obj_add_flag(history_cont, LV_OBJ_FLAG_HIDDEN); + g_ctx.history_container = history_cont; + + // History header with title + cancel button + lv_obj_t* hist_header = lv_obj_create(history_cont); + lv_obj_remove_style_all(hist_header); + lv_obj_set_size(hist_header, lv_pct(95), 32); + lv_obj_set_flex_flow(hist_header, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(hist_header, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t* lbl_hist = lv_label_create(hist_header); + lv_label_set_text(lbl_hist, "History"); + lv_obj_set_style_text_color(lbl_hist, lv_color_hex(0xCDD6F4), 0); + lv_obj_set_style_text_font(lbl_hist, lv_theme_get_font_small(hist_header), 0); + + lv_obj_t* btn_cancel = lv_btn_create(hist_header); + lv_obj_set_size(btn_cancel, 60, 28); + lv_obj_set_style_radius(btn_cancel, 8, 0); + lv_obj_set_style_bg_color(btn_cancel, lv_color_hex(0x313244), 0); + lv_obj_t* lbl_cancel = lv_label_create(btn_cancel); + lv_label_set_text(lbl_cancel, "Back"); + lv_obj_center(lbl_cancel); + lv_obj_add_event_cb(btn_cancel, on_history_cancel_click, LV_EVENT_CLICKED, &g_ctx); + + g_ctx.list_history = lv_list_create(history_cont); + lv_obj_set_size(g_ctx.list_history, lv_pct(95), lv_pct(85)); + lv_obj_set_style_bg_color(g_ctx.list_history, lv_color_hex(0x1E1E2E), 0); + lv_obj_set_style_border_color(g_ctx.list_history, lv_color_hex(0x313244), 0); + lv_obj_set_style_border_width(g_ctx.list_history, 1, 0); + lv_obj_set_style_radius(g_ctx.list_history, 8, 0); + lv_obj_set_style_pad_all(g_ctx.list_history, 2, 0); + + // Populate history list + refresh_history_list(&g_ctx); + + // Bottom controls container (volume + playback) + lv_obj_t* bottom_box = lv_obj_create(parent); + lv_obj_remove_style_all(bottom_box); + lv_obj_set_size(bottom_box, lv_pct(100), 90); + lv_obj_align(bottom_box, LV_ALIGN_BOTTOM_MID, 0, -10); + g_ctx.bottom_box = bottom_box; + lv_obj_set_flex_flow(bottom_box, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(bottom_box, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_gap(bottom_box, 10, 0); + lv_obj_set_style_pad_all(bottom_box, 5, 0); + + // Volume slider at very bottom as requested + lv_obj_t* vol_box = lv_obj_create(bottom_box); + lv_obj_remove_style_all(vol_box); + lv_obj_set_size(vol_box, lv_pct(92), 20); + lv_obj_set_flex_flow(vol_box, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(vol_box, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t* lbl_vol_icon = lv_label_create(vol_box); + lv_label_set_text(lbl_vol_icon, LV_SYMBOL_VOLUME_MAX); + lv_obj_set_style_text_color(lbl_vol_icon, lv_color_hex(0xA6ADC8), 0); + + g_ctx.slider_volume = lv_slider_create(vol_box); + lv_obj_set_size(g_ctx.slider_volume, lv_pct(82), 6); + lv_slider_set_range(g_ctx.slider_volume, 0, 100); + lv_slider_set_value(g_ctx.slider_volume, g_ctx.volume, LV_ANIM_OFF); + lv_obj_set_style_bg_color(g_ctx.slider_volume, lv_color_hex(0x45475A), LV_PART_MAIN); + lv_obj_set_style_bg_color(g_ctx.slider_volume, lv_color_hex(0x89B4FA), LV_PART_INDICATOR); + lv_obj_set_style_bg_color(g_ctx.slider_volume, lv_color_hex(0xCDD6F4), LV_PART_KNOB); + lv_obj_add_event_cb(g_ctx.slider_volume, on_volume_slider_changed, LV_EVENT_VALUE_CHANGED, &g_ctx); - // Controls Container - lv_obj_t* ctrl_box = lv_obj_create(card); + // Single line controls: -10s, Play/Pause, Stop, +10s + lv_obj_t* ctrl_box = lv_obj_create(bottom_box); lv_obj_remove_style_all(ctrl_box); - lv_obj_set_size(ctrl_box, lv_pct(90), 45); + lv_obj_set_size(ctrl_box, lv_pct(96), 40); lv_obj_set_flex_flow(ctrl_box, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(ctrl_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - // Play/Pause Button + + // -10s + g_ctx.btn_seek_back = lv_btn_create(ctrl_box); + lv_obj_set_size(g_ctx.btn_seek_back, 58, 36); + lv_obj_set_style_radius(g_ctx.btn_seek_back, 12, 0); + lv_obj_set_style_bg_color(g_ctx.btn_seek_back, lv_color_hex(0x313244), 0); + lv_obj_set_style_text_color(g_ctx.btn_seek_back, lv_color_hex(0xCDD6F4), 0); + lv_obj_t* lbl_back = lv_label_create(g_ctx.btn_seek_back); + lv_label_set_text(lbl_back, "-10s"); + lv_obj_center(lbl_back); + lv_obj_add_event_cb(g_ctx.btn_seek_back, on_seek_back_click, LV_EVENT_CLICKED, &g_ctx); + + // Play/Pause icon only g_ctx.btn_play_pause = lv_btn_create(ctrl_box); - lv_obj_set_size(g_ctx.btn_play_pause, 100, 36); + lv_obj_set_size(g_ctx.btn_play_pause, 56, 36); lv_obj_set_style_radius(g_ctx.btn_play_pause, 18, 0); lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0); lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0x11111B), 0); lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause); - lv_label_set_text(lbl_play, LV_SYMBOL_PLAY " Play"); + lv_label_set_text(lbl_play, LV_SYMBOL_PLAY); lv_obj_center(lbl_play); lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx); - // Stop Button + // Stop icon only g_ctx.btn_stop = lv_btn_create(ctrl_box); - lv_obj_set_size(g_ctx.btn_stop, 100, 36); - lv_obj_set_style_radius(g_ctx.btn_stop, 18, 0); + lv_obj_set_size(g_ctx.btn_stop, 50, 36); + lv_obj_set_style_radius(g_ctx.btn_stop, 12, 0); lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xF38BA8), 0); lv_obj_set_style_text_color(g_ctx.btn_stop, lv_color_hex(0x11111B), 0); lv_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop); - lv_label_set_text(lbl_stop, LV_SYMBOL_STOP " Stop"); + lv_label_set_text(lbl_stop, LV_SYMBOL_STOP); lv_obj_center(lbl_stop); lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx); + + // +10s + g_ctx.btn_seek_fwd = lv_btn_create(ctrl_box); + lv_obj_set_size(g_ctx.btn_seek_fwd, 58, 36); + lv_obj_set_style_radius(g_ctx.btn_seek_fwd, 12, 0); + lv_obj_set_style_bg_color(g_ctx.btn_seek_fwd, lv_color_hex(0x313244), 0); + lv_obj_set_style_text_color(g_ctx.btn_seek_fwd, lv_color_hex(0xCDD6F4), 0); + lv_obj_t* lbl_fwd = lv_label_create(g_ctx.btn_seek_fwd); + lv_label_set_text(lbl_fwd, "+10s"); + lv_obj_center(lbl_fwd); + lv_obj_add_event_cb(g_ctx.btn_seek_fwd, on_seek_fwd_click, LV_EVENT_CLICKED, &g_ctx); if (!g_ctx.stream_dev) { lv_label_set_text(g_ctx.lbl_status, "Error: Audio Not Found"); lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED); lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED); + lv_obj_add_state(g_ctx.btn_seek_back, LV_STATE_DISABLED); + lv_obj_add_state(g_ctx.btn_seek_fwd, LV_STATE_DISABLED); } else if (!g_ctx.input_buf || !g_ctx.pcm_buf) { lv_label_set_text(g_ctx.lbl_status, "Error: Out of Memory"); lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED); lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED); + lv_obj_add_state(g_ctx.btn_seek_back, LV_STATE_DISABLED); + lv_obj_add_state(g_ctx.btn_seek_fwd, LV_STATE_DISABLED); } else { g_ctx.state = STATE_IDLE; update_ui(&g_ctx); - - // Auto-play if a file parameter was provided if (g_ctx.filepath[0] != '\0') { g_ctx.state = STATE_PLAYING; update_ui(&g_ctx); @@ -540,9 +1184,6 @@ static void onHideApp(AppHandle app, void* data) { while (g_ctx.playback_task_handle != NULL && tries<50) { vTaskDelay(pdMS_TO_TICKS(50)); tries++; - if (tries%10==0) { - ESP_LOGI(TAG, "onHide waiting for playback task %d/50", tries); - } } if (g_ctx.playback_task_handle != NULL) { ESP_LOGW(TAG, "Playback task stuck, force deleting"); @@ -552,11 +1193,21 @@ static void onHideApp(AppHandle app, void* data) { ESP_LOGI(TAG, "Playback task stopped, closing audio stream"); - // Close audio stream BEFORE allocating result bundle to free internal heap (seen crash on close) if (g_ctx.stream_handle != NULL) { audio_stream_close(g_ctx.stream_handle); g_ctx.stream_handle = NULL; - vTaskDelay(pdMS_TO_TICKS(100)); // let DMA free + vTaskDelay(pdMS_TO_TICKS(100)); + } + + // Save volume and history (only for 10min+ audios as requested) + save_volume(&g_ctx); + bool is_long = (g_ctx.total_sec >= 600) || (g_ctx.file_data_size >= 600*16000L); + if (is_long) { + update_history_entry(&g_ctx, g_ctx.filepath, g_ctx.pos_sec, g_ctx.total_sec); + save_history(&g_ctx); + ESP_LOGI(TAG, "History saved on hide: %s pos %d total %d (is_long=%d)", g_ctx.filepath, g_ctx.pos_sec, g_ctx.total_sec, is_long); + } else { + ESP_LOGI(TAG, "Skip history save on hide: not long audio total=%d data=%ld", g_ctx.total_sec, g_ctx.file_data_size); } if (g_ctx.input_buf) { @@ -571,8 +1222,6 @@ static void onHideApp(AppHandle app, void* data) { size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); ESP_LOGI(TAG, "Heap after free buffers: internal=%d", free_internal); - // Return timestamp result - minimal to avoid OOM and timeout - // Only allocate bundle if heap > 8KB, and only put 1-2 ints if (free_internal > 6000) { BundleHandle result = tt_bundle_alloc(); if (result) { @@ -581,11 +1230,9 @@ static void onHideApp(AppHandle app, void* data) { tt_app_set_result(g_ctx.app_handle, APP_RESULT_OK, result); ESP_LOGI(TAG, "Set result bundle pos_sec=%d", g_ctx.pos_sec); } else { - ESP_LOGW(TAG, "Bundle alloc failed, returning OK without data"); tt_app_set_result(g_ctx.app_handle, APP_RESULT_OK, NULL); } } else { - ESP_LOGW(TAG, "Low heap %d, returning OK without bundle", free_internal); tt_app_set_result(g_ctx.app_handle, APP_RESULT_OK, NULL); } diff --git a/Apps/VoiceRecorder/main/Source/main.c b/Apps/VoiceRecorder/main/Source/main.c index 5d71e4a..2878c20 100644 --- a/Apps/VoiceRecorder/main/Source/main.c +++ b/Apps/VoiceRecorder/main/Source/main.c @@ -30,6 +30,18 @@ #define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8)) // 1024 bytes #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_FILENAME_LEN 64 @@ -208,6 +220,8 @@ static void record_task(void* arg) { size_t total_written = 0; uint32_t start_time = xTaskGetTickCount(); + uint32_t last_ui_sec = 0; + bool first_ui = true; while (ctx->state == STATE_RECORDING) { 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; - char status_buf[64]; - snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60)); + // Throttle UI updates to once per second to reduce LVGL invalidations during recording + 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); - 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); - tt_lvgl_unlock(); + tt_lvgl_lock(portMAX_DELAY); + 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); + tt_lvgl_unlock(); + } } audio_stream_close(ctx->input_handle); @@ -349,6 +368,9 @@ static void play_task(void* arg) { } 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) { 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 total_sec = data_size / bytes_per_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)); + // Throttle UI updates – only when % or second changes, to reduce LVGL work during audio + if (first_ui || pct != last_pct || elapsed_sec != last_elapsed_sec) { + first_ui = false; + 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); - lv_label_set_text(ctx->lbl_status, status_buf); - lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF); - tt_lvgl_unlock(); + tt_lvgl_lock(portMAX_DELAY); + lv_label_set_text(ctx->lbl_status, status_buf); + lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF); + tt_lvgl_unlock(); + } } fclose(f); @@ -549,17 +577,91 @@ static void refresh_memo_list(AppCtx* ctx) { 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_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) { 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) { if (ctx->lbl_status == NULL) return; switch (ctx->state) { 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_play_pause, LV_STATE_DISABLED); lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED); @@ -567,6 +669,8 @@ static void update_ui(AppCtx* ctx) { break; 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_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); @@ -575,6 +679,8 @@ static void update_ui(AppCtx* ctx) { break; 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_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); @@ -584,6 +690,8 @@ static void update_ui(AppCtx* ctx) { case STATE_IDLE: default: + set_status_scroll_enabled(ctx, true); + set_memo_list_scroll_enabled(ctx, true); lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED); 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_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_label_set_long_mode(g_ctx.lbl_status, LV_LABEL_LONG_DOT); lv_label_set_text(g_ctx.lbl_status, "Scanning memos..."); g_ctx.bar_progress = lv_bar_create(card);