#include #include #include #include #include #include #include #include #include #include #include #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_log.h" #define MINIMP3_IMPLEMENTATION #define MINIMP3_NO_SIMD #include "minimp3.h" #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; char filepath[512]; PlaybackState state; AppHandle app_handle; // UI elements 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; mp3dec_t decoder; uint8_t* input_buf; mp3d_sample_t* pcm_buf; size_t buffered_bytes; bool eof; int volume; int sample_rate; int channels; // Progress calculation size_t file_size; size_t bytes_read_total; int pos_sec; int total_sec; long total_decoded_samples; 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..."); 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"); 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"); 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"); if (ctx->btn_play_pause) lv_obj_add_state(ctx->btn_play_pause, 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; ctx->file = fopen(ctx->filepath, "rb"); if (!ctx->file) { ESP_LOGE(TAG, "Failed to open file: %s", ctx->filepath); tt_lvgl_lock(portMAX_DELAY); ctx->state = STATE_IDLE; lv_label_set_text(ctx->lbl_status, "Error: File not found"); update_ui(ctx); tt_lvgl_unlock(); ctx->playback_task_handle = NULL; vTaskDelete(NULL); return; } fseek(ctx->file, 0, SEEK_END); ctx->file_size = ftell(ctx->file); fseek(ctx->file, 0, SEEK_SET); ctx->bytes_read_total = 0; ctx->pos_sec = 0; 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 uint8_t id3hdr[10]; 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); ctx->id3_skip = id3size + 10; fseek(ctx->file, ctx->id3_skip, SEEK_SET); } else { fseek(ctx->file, 0, SEEK_SET); } 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; 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 (seek_bytes %ld, file_data %ld, total_est %d)", ctx->filepath, ctx->start_pos_sec, seek_bytes, ctx->file_data_size, ctx->total_sec); } } mp3dec_init(&ctx->decoder); ctx->buffered_bytes = 0; ctx->eof = false; ctx->sample_rate = 0; ctx->channels = 0; ctx->stream_handle = NULL; 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) { audio_stream_close(ctx->stream_handle); ctx->stream_handle = NULL; ctx->sample_rate = 0; ctx->channels = 0; } vTaskDelay(pdMS_TO_TICKS(50)); continue; } 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 (ctx->buffered_bytes == 0 && ctx->eof) { ESP_LOGI(TAG, "Reached EOF"); break; } 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; memmove(ctx->input_buf, ctx->input_buf + 1, --ctx->buffered_bytes); continue; } size_t consumed = (size_t)info.frame_bytes; ctx->buffered_bytes -= consumed; memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes); if (samples > 0) { ctx->total_decoded_samples += samples; if (info.hz > 0) { ctx->pos_sec = ctx->total_decoded_samples / info.hz; } 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); break; } ctx->sample_rate = info.hz; ctx->channels = info.channels; ESP_LOGI(TAG, "Audio stream opened: %d Hz, %d channels", info.hz, info.channels); } int vol = ctx->volume; int16_t* samples_ptr = (int16_t*)ctx->pcm_buf; size_t sample_count = (size_t)samples * info.channels; for (size_t i = 0; i < sample_count; ++i) { int32_t scaled = (int32_t)samples_ptr[i] * vol / 100; samples_ptr[i] = (int16_t)scaled; } size_t offset = 0; size_t data_size = sample_count * sizeof(int16_t); bool write_err = false; while (offset < data_size && ctx->state == STATE_PLAYING) { size_t written = 0; error_t err = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(500)); if (err != ERROR_NONE || written == 0) { ESP_LOGE(TAG, "Audio stream write failed: %d", err); write_err = true; break; } offset += written; } if (write_err) break; } // Progress bar: update when % changes (throttled) int pct = -1; if (ctx->file_size > 0) { 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); 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(); } } fclose(ctx->file); ctx->file = NULL; if (ctx->pos_sec > 0 && ctx->total_sec >0 && ctx->pos_sec >= ctx->total_sec - 10) { ctx->pos_sec = 0; ctx->finished_naturally = true; } else { ctx->finished_naturally = false; } if (ctx->stream_handle != NULL) { audio_stream_close(ctx->stream_handle); ctx->stream_handle = NULL; } ESP_LOGI(TAG, "Playback task finished pos=%d total=%d naturally=%d", ctx->pos_sec, ctx->total_sec, ctx->finished_naturally); tt_lvgl_lock(portMAX_DELAY); ctx->state = STATE_IDLE; update_ui(ctx); tt_lvgl_unlock(); ctx->playback_task_handle = NULL; vTaskDelete(NULL); } /* ─── Callbacks ─── */ static void on_play_pause_click(lv_event_t* e) { AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); if (ctx->filepath[0] == '\0') return; if (ctx->state == STATE_IDLE) { ctx->state = STATE_PLAYING; update_ui(ctx); xTaskCreate(mp3_playback_task, "mp3_play", 6144, ctx, 6, &ctx->playback_task_handle); } 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); } } 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; 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)); for (int tries=0; tries<5; tries++) { struct Device* dev = device_find_by_name("audio-stream"); if (dev) { g_ctx.stream_dev = dev; break; } dev = device_find_first_by_type(&AUDIO_STREAM_TYPE); if (dev) { g_ctx.stream_dev = dev; break; } ESP_LOGW(TAG, "Audio-stream not found, retry %d/5", tries); vTaskDelay(pdMS_TO_TICKS(200)); } if (!g_ctx.stream_dev) { ESP_LOGE(TAG, "Audio-stream device not found after retries!"); } // Load persisted volume and history (safe with heap buffer) load_volume(&g_ctx); load_history(&g_ctx); BundleHandle bundle = tt_app_get_parameters(app); bool has_file_param = false; if (bundle) { char file_param[512] = {0}; 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))) { if (strncmp(file_param, "/sdcard", 7) == 0) { strncpy(g_ctx.filepath, file_param, sizeof(g_ctx.filepath) - 1); } else { if (file_param[0] == '/') { size_t needed = 7 + strlen(file_param) + 1; if (needed <= sizeof(g_ctx.filepath)) { strcpy(g_ctx.filepath, "/sdcard"); strcat(g_ctx.filepath, file_param); } else { strncpy(g_ctx.filepath, file_param, sizeof(g_ctx.filepath)-1); } } else { size_t needed = 8 + strlen(file_param) + 1; if (needed <= sizeof(g_ctx.filepath)) { strcpy(g_ctx.filepath, "/sdcard/"); 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; has_file_param = true; } int32_t pos = 0; if (tt_bundle_opt_int32(bundle, "pos_sec", &pos) || tt_bundle_opt_int32(bundle, "position", &pos) || tt_bundle_opt_int32(bundle, "timestamp", &pos)) { g_ctx.start_pos_sec = pos; } int32_t vol = 0; if (tt_bundle_opt_int32(bundle, "volume", &vol)) { g_ctx.volume = vol; } } // 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) { const char *test_files[] = { "/sdcard/dm/462_Gadgets_Escape_S01E02.mp3", "/sdcard/dm/463_Discovering_Discovery_Mountain_S01E01.mp3", "/sdcard/download/test2.mp3", "/sdcard/download/test.mp3" }; for (int i=0; i<4; i++) { struct stat st; if (stat(test_files[i], &st)==0 && st.st_size>1024) { strncpy(g_ctx.filepath, test_files[i], sizeof(g_ctx.filepath)-1); g_ctx.filepath[sizeof(g_ctx.filepath)-1]=0; ESP_LOGI(TAG, "No file param, using test file %s", g_ctx.filepath); has_file_param = true; break; } } } // ─── 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; 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); 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); if (g_ctx.filepath[0] != '\0') { 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); } 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); 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); // 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(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); // -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, 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); 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 icon only g_ctx.btn_stop = lv_btn_create(ctrl_box); 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); 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); if (g_ctx.filepath[0] != '\0') { g_ctx.state = STATE_PLAYING; update_ui(&g_ctx); xTaskCreate(mp3_playback_task, "mp3_play", 6144, &g_ctx, 6, &g_ctx.playback_task_handle); } } } static void onHideApp(AppHandle app, void* data) { (void)app; (void)data; ESP_LOGI(TAG, "onHide start pos_sec=%d", g_ctx.pos_sec); if (g_ctx.state != STATE_IDLE) { g_ctx.state = STATE_IDLE; } int tries=0; while (g_ctx.playback_task_handle != NULL && tries<50) { vTaskDelay(pdMS_TO_TICKS(50)); tries++; } if (g_ctx.playback_task_handle != NULL) { ESP_LOGW(TAG, "Playback task stuck, force deleting"); vTaskDelete(g_ctx.playback_task_handle); g_ctx.playback_task_handle = NULL; } ESP_LOGI(TAG, "Playback task stopped, closing audio stream"); if (g_ctx.stream_handle != NULL) { audio_stream_close(g_ctx.stream_handle); g_ctx.stream_handle = NULL; 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) { free(g_ctx.input_buf); g_ctx.input_buf = NULL; } if (g_ctx.pcm_buf) { free(g_ctx.pcm_buf); g_ctx.pcm_buf = NULL; } size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); ESP_LOGI(TAG, "Heap after free buffers: internal=%d", free_internal); if (free_internal > 6000) { BundleHandle result = tt_bundle_alloc(); if (result) { tt_bundle_put_int32(result, "pos_sec", g_ctx.pos_sec); tt_bundle_put_int32(result, "timestamp", g_ctx.pos_sec); 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 { tt_app_set_result(g_ctx.app_handle, APP_RESULT_OK, NULL); } } else { tt_app_set_result(g_ctx.app_handle, APP_RESULT_OK, NULL); } ESP_LOGI(TAG, "onHide returning pos_sec=%d total_sec=%d finished=%d", g_ctx.pos_sec, g_ctx.total_sec, g_ctx.finished_naturally); } int main(int argc, char* argv[]) { tt_app_register((AppRegistration) { .onShow = onShowApp, .onHide = onHideApp }); return 0; }