diff --git a/Apps/VoiceRecorder/main/Source/main.c b/Apps/VoiceRecorder/main/Source/main.c index 2878c20..9e880db 100644 --- a/Apps/VoiceRecorder/main/Source/main.c +++ b/Apps/VoiceRecorder/main/Source/main.c @@ -6,7 +6,6 @@ */ #include #include -#include #include #include @@ -26,9 +25,13 @@ #define SAMPLE_RATE 16000 #define BITS_PER_SAMPLE 16 -#define CHUNK_SAMPLES 512 -#define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8)) // 1024 bytes -#define AUDIO_BUF_SIZE 4096 +#define CHUNK_SAMPLES 1024 +#define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8)) // 2048 bytes +#define AUDIO_BUF_SIZE 8192 + +// Software mic boost (digital gain) to compensate quiet MEMS mic even at 100% hw gain (24dB) +// 200 = 2x, 300 = 3x – clipping protection via int16 limits +#define MIC_BOOST_PERCENT 250 // ─── Scroll animation flags ─── // LVGL label scroll animation (LV_LABEL_LONG_SCROLL_*) causes continuous @@ -62,11 +65,12 @@ typedef struct { // Memo directory listing char memos[MAX_MEMOS][MAX_FILENAME_LEN]; + int memo_durations[MAX_MEMOS]; // seconds int memo_count; int selected_index; // UI elements - lv_obj_t* lbl_status; + lv_obj_t* lbl_status; // top idle status ("Selected: ...") lv_obj_t* bar_progress; lv_obj_t* lst_memos; lv_obj_t* btn_record; @@ -74,7 +78,20 @@ typedef struct { lv_obj_t* btn_stop; lv_obj_t* btn_delete; + // MP3-like player UI for PLAYING/PAUSED/RECORDING + lv_obj_t* player_box; + lv_obj_t* lbl_title; + lv_obj_t* lbl_time; + lv_obj_t* lbl_player_status; + lv_obj_t* top_box; + lv_obj_t* slider_volume; + lv_obj_t* vol_box; + lv_obj_t* bottom_box; + TaskHandle_t task_handle; + // Playback progress data for UI + int cur_sec; + int total_sec_play; } AppCtx; typedef struct __attribute__((packed)) { @@ -94,12 +111,62 @@ typedef struct __attribute__((packed)) { } WavHeader; static AppCtx g_ctx; +static AppHandle s_app_handle = NULL; /* ─── Forward Decls ─── */ static void update_ui(AppCtx* ctx); static void refresh_memo_list(AppCtx* ctx); static void on_memo_selected(lv_event_t* e); +/* ─── Volume persistence (like Mp3Player) ─── */ +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); + 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); +} + +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) { + if (!s_app_handle) { ctx->volume = 80; return; } + char path[256]; + get_volume_path(s_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); +} + +static void save_volume(AppCtx* ctx) { + if (!s_app_handle) return; + char path[256]; + get_volume_path(s_app_handle, path, sizeof(path)); + ensure_user_data_dir(s_app_handle); + FILE* f = fopen(path, "w"); + if (!f) return; + fprintf(f, "%d\n", ctx->volume); + fclose(f); +} + static bool find_audio_stream_device(AppCtx* ctx) { struct Device* dev = device_find_by_name("audio-stream"); if (dev) { @@ -227,7 +294,17 @@ static void record_task(void* arg) { size_t bytes_read = 0; err = audio_stream_read(ctx->input_handle, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(200)); if (err == ERROR_NONE && bytes_read > 0) { - // Data already 16k mono from audio-stream resampler, no downmix needed + // Software boost: 16-bit mono, apply MIC_BOOST_PERCENT with clipping +#if MIC_BOOST_PERCENT != 100 + int16_t* samples = (int16_t*)ctx->audio_buf; + size_t sample_cnt = bytes_read / sizeof(int16_t); + for (size_t i = 0; i < sample_cnt; i++) { + int32_t boosted = (int32_t)samples[i] * MIC_BOOST_PERCENT / 100; + if (boosted > 32767) boosted = 32767; + if (boosted < -32768) boosted = -32768; + samples[i] = (int16_t)boosted; + } +#endif size_t written = fwrite(ctx->audio_buf, 1, bytes_read, f); if (written != bytes_read) { ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)bytes_read); @@ -242,10 +319,15 @@ static void record_task(void* arg) { first_ui = false; last_ui_sec = elapsed_sec; char status_buf[64]; + char time_buf[32]; snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60)); + snprintf(time_buf, sizeof(time_buf), "%02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60)); tt_lvgl_lock(portMAX_DELAY); - lv_label_set_text(ctx->lbl_status, status_buf); + if (ctx->lbl_status) lv_label_set_text(ctx->lbl_status, status_buf); + if (ctx->lbl_title) lv_label_set_text(ctx->lbl_title, "Recording..."); + if (ctx->lbl_player_status) lv_label_set_text(ctx->lbl_player_status, status_buf); + if (ctx->lbl_time) lv_label_set_text(ctx->lbl_time, time_buf); lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF); tt_lvgl_unlock(); } @@ -434,13 +516,23 @@ static void play_task(void* arg) { 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", + ctx->cur_sec = elapsed_sec; + ctx->total_sec_play = total_sec; + + char time_buf[32]; + char status_buf[32]; + snprintf(time_buf, sizeof(time_buf), "%02u:%02u / %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60), (unsigned)(total_sec / 60), (unsigned)(total_sec % 60)); + snprintf(status_buf, sizeof(status_buf), "🔊 Playing..."); tt_lvgl_lock(portMAX_DELAY); - lv_label_set_text(ctx->lbl_status, status_buf); + if (ctx->lbl_player_status) lv_label_set_text(ctx->lbl_player_status, status_buf); + if (ctx->lbl_status) lv_label_set_text(ctx->lbl_status, status_buf); + if (ctx->lbl_title && ctx->selected_index >=0 && ctx->selected_index < ctx->memo_count) { + lv_label_set_text(ctx->lbl_title, ctx->memos[ctx->selected_index]); + } + if (ctx->lbl_time) lv_label_set_text(ctx->lbl_time, time_buf); lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF); tt_lvgl_unlock(); } @@ -516,6 +608,94 @@ static void on_delete_click(lv_event_t* e) { update_ui(ctx); } +static int get_wav_duration_sec(const char* filepath) { + FILE* f = fopen(filepath, "rb"); + if (!f) return 0; + WavHeader header; + size_t r = fread(&header, 1, sizeof(WavHeader), f); + if (r != sizeof(WavHeader)) { + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fclose(f); + if (sz <= (long)sizeof(WavHeader)) return 0; + // Fallback assume 16k mono 16bit + long data = sz - sizeof(WavHeader); + return (int)(data / (SAMPLE_RATE * 1 * (BITS_PER_SAMPLE/8))); + } + if (memcmp(header.riff, "RIFF", 4) != 0) { + fclose(f); + return 0; + } + size_t data_size = header.data_size; + if (data_size == 0) { + fseek(f, 0, SEEK_END); + long sz = ftell(f); + data_size = sz - sizeof(WavHeader); + } + fclose(f); + uint32_t bytes_per_sec = header.sample_rate * header.channels * (header.bits_per_sample / 8); + if (bytes_per_sec == 0) return 0; + return (int)(data_size / bytes_per_sec); +} + +static void format_memo_display(const char* filename, int duration_sec, char* out, size_t out_len) { + // Parse memo_YYYYMMDD_HHMMSS.wav + int y=0,m=0,d=0,hh=0,mm=0,ss=0; + if (strlen(filename) >= 21 && strncmp(filename, "memo_", 5)==0) { + // filename like memo_20260730_162300.wav + char date_part[9] = {0}; + char time_part[7] = {0}; + strncpy(date_part, filename+5, 8); + // date_part YYYYMMDD + if (strlen(filename) >= 20) { + strncpy(time_part, filename+14, 6); + if (sscanf(date_part, "%4d%2d%2d", &y,&m,&d)==3 && + sscanf(time_part, "%2d%2d%2d", &hh,&mm,&ss)==3) { + // Format: 2026-07-30 16:23 • 00:12 + int dur_m = duration_sec / 60; + int dur_s = duration_sec % 60; + snprintf(out, out_len, "%04d-%02d-%02d %02d:%02d\n%02d:%02d", y,m,d,hh,mm, dur_m, dur_s); + return; + } + } + } + // Fallback: filename + duration + int dur_m = duration_sec / 60; + int dur_s = duration_sec % 60; + // Trim .wav + char base[MAX_FILENAME_LEN]; + strncpy(base, filename, sizeof(base)-1); + base[sizeof(base)-1]=0; + size_t len = strlen(base); + if (len>4 && strcasecmp(base+len-4, ".wav")==0) base[len-4]=0; + snprintf(out, out_len, "%s\n%02d:%02d", base, dur_m, dur_s); +} + +static void set_memo_btn_highlight(lv_obj_t* btn, bool highlighted) { + if (!btn) return; + if (highlighted) { + lv_obj_set_style_bg_color(btn, lv_color_hex(0x3A5A40), LV_PART_MAIN); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_color(btn, lv_color_hex(0xA6E3A1), LV_PART_MAIN); + lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); + } else { + lv_obj_set_style_bg_color(btn, lv_color_hex(0x1E1E2E), LV_PART_MAIN); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_color(btn, lv_color_hex(0x1E1E2E), LV_PART_MAIN); + lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); + } + uint32_t cnt = lv_obj_get_child_cnt(btn); + for (uint32_t i=0;ilst_memos, i); lv_obj_clear_state(child, LV_STATE_CHECKED); + set_memo_btn_highlight(child, false); } - lv_obj_add_state(btn, LV_STATE_CHECKED); + set_memo_btn_highlight(btn, true); - const char* text = lv_list_get_btn_text(ctx->lst_memos, btn); - for (int i = 0; i < ctx->memo_count; i++) { - if (strcmp(ctx->memos[i], text) == 0) { - ctx->selected_index = i; + for (uint32_t i = 0; i < child_cnt; i++) { + lv_obj_t* child = lv_obj_get_child(ctx->lst_memos, i); + if (child == btn) { + if ((int)i < ctx->memo_count) { + ctx->selected_index = (int)i; + } break; } } @@ -558,6 +741,10 @@ static void refresh_memo_list(AppCtx* ctx) { if (len > 4 && strcasecmp(entry->d_name + len - 4, ".wav") == 0) { strncpy(ctx->memos[ctx->memo_count], entry->d_name, MAX_FILENAME_LEN - 1); ctx->memos[ctx->memo_count][MAX_FILENAME_LEN - 1] = '\0'; + // Compute duration + char fpath[512]; + snprintf(fpath, sizeof(fpath), "/sdcard/memos/%s", entry->d_name); + ctx->memo_durations[ctx->memo_count] = get_wav_duration_sec(fpath); ctx->memo_count++; } } @@ -570,40 +757,32 @@ static void refresh_memo_list(AppCtx* ctx) { strcpy(temp, ctx->memos[j]); strcpy(ctx->memos[j], ctx->memos[j + 1]); strcpy(ctx->memos[j + 1], temp); + int dtmp = ctx->memo_durations[j]; + ctx->memo_durations[j] = ctx->memo_durations[j+1]; + ctx->memo_durations[j+1] = dtmp; } } } 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]); + char display[128]; + format_memo_display(ctx->memos[i], ctx->memo_durations[i], display, sizeof(display)); + lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, display); 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) { + bool is_sel = (i == ctx->selected_index); + if (is_sel) { lv_obj_add_state(btn, LV_STATE_CHECKED); } + set_memo_btn_highlight(btn, is_sel); } } @@ -662,6 +841,17 @@ static void update_ui(AppCtx* ctx) { case STATE_RECORDING: set_status_scroll_enabled(ctx, false); set_memo_list_scroll_enabled(ctx, false); + if (ctx->lst_memos) lv_obj_add_flag(ctx->lst_memos, LV_OBJ_FLAG_HIDDEN); + if (ctx->player_box) lv_obj_remove_flag(ctx->player_box, LV_OBJ_FLAG_HIDDEN); + if (ctx->top_box) lv_obj_add_flag(ctx->top_box, LV_OBJ_FLAG_HIDDEN); + if (ctx->vol_box) lv_obj_add_flag(ctx->vol_box, LV_OBJ_FLAG_HIDDEN); + if (ctx->lbl_title) { + lv_label_set_text(ctx->lbl_title, "Recording..."); + lv_label_set_long_mode(ctx->lbl_title, LV_LABEL_LONG_DOT); + } + if (ctx->lbl_player_status) lv_label_set_text(ctx->lbl_player_status, "🔴 Recording..."); + if (ctx->lbl_time) lv_label_set_text(ctx->lbl_time, "00:00"); + lv_bar_set_value(ctx->bar_progress, 0, LV_ANIM_OFF); 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); @@ -671,6 +861,16 @@ static void update_ui(AppCtx* ctx) { case STATE_PLAYING: set_status_scroll_enabled(ctx, false); set_memo_list_scroll_enabled(ctx, false); + if (ctx->lst_memos) lv_obj_add_flag(ctx->lst_memos, LV_OBJ_FLAG_HIDDEN); + if (ctx->player_box) lv_obj_remove_flag(ctx->player_box, LV_OBJ_FLAG_HIDDEN); + if (ctx->top_box) lv_obj_add_flag(ctx->top_box, LV_OBJ_FLAG_HIDDEN); + if (ctx->vol_box) lv_obj_remove_flag(ctx->vol_box, LV_OBJ_FLAG_HIDDEN); + if (ctx->lbl_title && ctx->selected_index >=0 && ctx->selected_index < ctx->memo_count) { + lv_label_set_text(ctx->lbl_title, ctx->memos[ctx->selected_index]); + lv_label_set_long_mode(ctx->lbl_title, LV_LABEL_LONG_DOT); + } + if (ctx->lbl_status) lv_label_set_text(ctx->lbl_status, "Playing..."); + if (ctx->lbl_player_status) lv_label_set_text(ctx->lbl_player_status, "Playing..."); 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); @@ -681,6 +881,12 @@ static void update_ui(AppCtx* ctx) { case STATE_PAUSED: set_status_scroll_enabled(ctx, false); set_memo_list_scroll_enabled(ctx, false); + if (ctx->lst_memos) lv_obj_add_flag(ctx->lst_memos, LV_OBJ_FLAG_HIDDEN); + if (ctx->player_box) lv_obj_remove_flag(ctx->player_box, LV_OBJ_FLAG_HIDDEN); + if (ctx->top_box) lv_obj_add_flag(ctx->top_box, LV_OBJ_FLAG_HIDDEN); + if (ctx->vol_box) lv_obj_remove_flag(ctx->vol_box, LV_OBJ_FLAG_HIDDEN); + if (ctx->lbl_status) lv_label_set_text(ctx->lbl_status, "Paused"); + if (ctx->lbl_player_status) lv_label_set_text(ctx->lbl_player_status, "Paused"); 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); @@ -692,33 +898,61 @@ static void update_ui(AppCtx* ctx) { default: set_status_scroll_enabled(ctx, true); set_memo_list_scroll_enabled(ctx, true); + if (ctx->lst_memos) lv_obj_remove_flag(ctx->lst_memos, LV_OBJ_FLAG_HIDDEN); + if (ctx->player_box) lv_obj_add_flag(ctx->player_box, LV_OBJ_FLAG_HIDDEN); + if (ctx->top_box) lv_obj_remove_flag(ctx->top_box, LV_OBJ_FLAG_HIDDEN); + if (ctx->vol_box) lv_obj_add_flag(ctx->vol_box, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED); if (ctx->selected_index >= 0 && ctx->selected_index < ctx->memo_count) { lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); lv_obj_clear_state(ctx->btn_delete, LV_STATE_DISABLED); - - char status_buf[128]; - snprintf(status_buf, sizeof(status_buf), "Selected: %s", ctx->memos[ctx->selected_index]); - lv_label_set_text(ctx->lbl_status, status_buf); } else { lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED); lv_obj_add_state(ctx->btn_delete, LV_STATE_DISABLED); - lv_label_set_text(ctx->lbl_status, "Select a memo or press Rec"); + } + + // Show count, not selected filename – highlight in list is enough + if (ctx->memo_count == 0) { + lv_label_set_text(ctx->lbl_status, "No recordings – press Rec"); + } else { + char status_buf[32]; + snprintf(status_buf, sizeof(status_buf), "%d recording%s", ctx->memo_count, ctx->memo_count==1?"":"s"); + lv_label_set_text(ctx->lbl_status, status_buf); } lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY); lv_obj_add_state(ctx->btn_stop, 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; } } +/* ─── Callbacks for volume ─── */ +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); +} + /* ─── App Lifecycle ─── */ +static void on_close_click(lv_event_t* e) { + (void)e; + if (s_app_handle) { + tt_app_stop(s_app_handle); + } +} + static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { memset(&g_ctx, 0, sizeof(g_ctx)); - g_ctx.volume = 80; g_ctx.selected_index = -1; + s_app_handle = app; + g_ctx.volume = 80; + load_volume(&g_ctx); mkdir("/sdcard/memos", 0755); @@ -731,51 +965,126 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { ESP_LOGE(TAG, "audio-stream device not found!"); } - lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + // ─── Full-screen layout, no toolbar, no card container (like Mp3Player) ─── + lv_obj_set_style_bg_color(parent, lv_color_hex(0x11111B), 0); + lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0); - lv_obj_t* card = lv_obj_create(parent); - lv_obj_set_size(card, lv_pct(95), lv_pct(88)); - lv_obj_align(card, LV_ALIGN_CENTER, 0, 12); - 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_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(card, 10, 0); - lv_obj_set_style_pad_gap(card, 8, 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, NULL); - g_ctx.lbl_status = lv_label_create(card); - lv_obj_set_width(g_ctx.lbl_status, lv_pct(95)); + // Top box: idle status – small, just above list, no dead space + lv_obj_t* top_box = lv_obj_create(parent); + lv_obj_remove_style_all(top_box); + lv_obj_set_size(top_box, lv_pct(88), 18); + lv_obj_align(top_box, LV_ALIGN_TOP_MID, 18, 4); + lv_obj_set_flex_flow(top_box, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(top_box, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + g_ctx.top_box = top_box; + + g_ctx.lbl_status = lv_label_create(top_box); + lv_obj_set_width(g_ctx.lbl_status, lv_pct(100)); 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(0xA6ADC8), 0); + lv_obj_set_style_text_font(g_ctx.lbl_status, lv_theme_get_font_small(top_box), 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..."); - g_ctx.bar_progress = lv_bar_create(card); - lv_obj_set_size(g_ctx.bar_progress, lv_pct(90), 8); + // Memo list – lowered 10px (38), height tuned to avoid overlap with bottom buttons + g_ctx.lst_memos = lv_list_create(parent); + lv_obj_set_size(g_ctx.lst_memos, lv_pct(92), lv_pct(70)); + lv_obj_align(g_ctx.lst_memos, LV_ALIGN_TOP_MID, 0, 38); + lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x1E1E2E), 0); + lv_obj_set_style_border_color(g_ctx.lst_memos, lv_color_hex(0x313244), 0); + lv_obj_set_style_border_width(g_ctx.lst_memos, 1, 0); + lv_obj_set_style_radius(g_ctx.lst_memos, 8, 0); + lv_obj_set_style_pad_all(g_ctx.lst_memos, 2, 0); + + // Player box – MP3-like UI, hidden in IDLE, shown when PLAYING/PAUSED/RECORDING + lv_obj_t* player_box = lv_obj_create(parent); + lv_obj_remove_style_all(player_box); + lv_obj_set_size(player_box, lv_pct(90), lv_pct(50)); + lv_obj_align(player_box, LV_ALIGN_CENTER, 0, -10); + lv_obj_set_flex_flow(player_box, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(player_box, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_gap(player_box, 12, 0); + lv_obj_add_flag(player_box, LV_OBJ_FLAG_HIDDEN); + g_ctx.player_box = player_box; + + lv_obj_t* icon = lv_label_create(player_box); + lv_label_set_text(icon, LV_SYMBOL_AUDIO); + lv_obj_set_style_text_color(icon, lv_color_hex(0x89B4FA), 0); + + g_ctx.lbl_title = lv_label_create(player_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); + lv_label_set_long_mode(g_ctx.lbl_title, LV_LABEL_LONG_DOT); + lv_label_set_text(g_ctx.lbl_title, ""); + + g_ctx.lbl_player_status = lv_label_create(player_box); + lv_obj_set_style_text_color(g_ctx.lbl_player_status, lv_color_hex(0xA6ADC8), 0); + lv_label_set_long_mode(g_ctx.lbl_player_status, LV_LABEL_LONG_DOT); + lv_label_set_text(g_ctx.lbl_player_status, ""); + + g_ctx.bar_progress = lv_bar_create(player_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.lst_memos = lv_list_create(card); - lv_obj_set_size(g_ctx.lst_memos, lv_pct(95), 85); - lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0); - lv_obj_set_style_border_color(g_ctx.lst_memos, lv_color_hex(0x313244), 0); - lv_obj_set_style_border_width(g_ctx.lst_memos, 1, 0); - lv_obj_set_style_radius(g_ctx.lst_memos, 8, 0); + g_ctx.lbl_time = lv_label_create(player_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(player_box), 0); + lv_label_set_text(g_ctx.lbl_time, "00:00 / 00:00"); - lv_obj_t* ctrl_box = lv_obj_create(card); - lv_obj_remove_style_all(ctrl_box); - lv_obj_set_size(ctrl_box, lv_pct(95), 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); + // Volume slider – separate object above buttons, shown only when playing (like Mp3Player) + lv_obj_t* vol_box = lv_obj_create(parent); + lv_obj_remove_style_all(vol_box); + lv_obj_set_size(vol_box, lv_pct(92), 20); + lv_obj_align(vol_box, LV_ALIGN_BOTTOM_MID, 0, -52); + 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_add_flag(vol_box, LV_OBJ_FLAG_HIDDEN); + g_ctx.vol_box = vol_box; + + 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); + + // Bottom controls – only buttons, pulled down to bottom edge (no dead space) + 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), 44); + lv_obj_align(bottom_box, LV_ALIGN_BOTTOM_MID, 0, -2); + lv_obj_set_flex_flow(bottom_box, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(bottom_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(bottom_box, 2, 0); + g_ctx.bottom_box = bottom_box; + + // Controls row (directly in bottom_box for minimal height) + lv_obj_t* ctrl_box = bottom_box; g_ctx.btn_record = lv_btn_create(ctrl_box); - lv_obj_set_size(g_ctx.btn_record, 45, 32); - lv_obj_set_style_radius(g_ctx.btn_record, 8, 0); + lv_obj_set_size(g_ctx.btn_record, 56, 36); + lv_obj_set_style_radius(g_ctx.btn_record, 12, 0); lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0); lv_obj_set_style_text_color(g_ctx.btn_record, lv_color_hex(0x11111B), 0); lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record); @@ -784,8 +1093,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx); g_ctx.btn_play_pause = lv_btn_create(ctrl_box); - lv_obj_set_size(g_ctx.btn_play_pause, 45, 32); - lv_obj_set_style_radius(g_ctx.btn_play_pause, 8, 0); + lv_obj_set_size(g_ctx.btn_play_pause, 56, 36); + lv_obj_set_style_radius(g_ctx.btn_play_pause, 12, 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); @@ -794,8 +1103,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx); g_ctx.btn_stop = lv_btn_create(ctrl_box); - lv_obj_set_size(g_ctx.btn_stop, 45, 32); - lv_obj_set_style_radius(g_ctx.btn_stop, 8, 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(0xBAC2DE), 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); @@ -804,8 +1113,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx); g_ctx.btn_delete = lv_btn_create(ctrl_box); - lv_obj_set_size(g_ctx.btn_delete, 45, 32); - lv_obj_set_style_radius(g_ctx.btn_delete, 8, 0); + lv_obj_set_size(g_ctx.btn_delete, 50, 36); + lv_obj_set_style_radius(g_ctx.btn_delete, 12, 0); lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0); lv_obj_set_style_text_color(g_ctx.btn_delete, lv_color_hex(0x11111B), 0); lv_obj_t* lbl_del = lv_label_create(g_ctx.btn_delete); @@ -828,6 +1137,17 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { } else { g_ctx.state = STATE_IDLE; refresh_memo_list(&g_ctx); + if (g_ctx.memo_count > 0 && g_ctx.selected_index < 0) { + g_ctx.selected_index = 0; + uint32_t cnt = lv_obj_get_child_cnt(g_ctx.lst_memos); + for (uint32_t i = 0; i < cnt; i++) { + lv_obj_t* child = lv_obj_get_child(g_ctx.lst_memos, i); + bool sel = ((int)i == g_ctx.selected_index); + if (sel) lv_obj_add_state(child, LV_STATE_CHECKED); + else lv_obj_clear_state(child, LV_STATE_CHECKED); + set_memo_btn_highlight(child, sel); + } + } update_ui(&g_ctx); } } @@ -854,6 +1174,8 @@ static void onHideApp(AppHandle app, void* data) { free(g_ctx.audio_buf); g_ctx.audio_buf = NULL; } + + save_volume(&g_ctx); } int main(int argc, char* argv[]) {