7ef8e272fc
- 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
1249 lines
49 KiB
C
1249 lines
49 KiB
C
#include <tt_app.h>
|
|
#include <tt_lvgl.h>
|
|
#include <tt_lvgl_toolbar.h>
|
|
#include <tt_bundle.h>
|
|
|
|
#include <tactility/device.h>
|
|
#include <tactility/drivers/audio_stream.h>
|
|
#include <tactility/drivers/audio_codec.h>
|
|
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <stdio.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
#include <time.h>
|
|
|
|
#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;i<ctx->history_count-1;i++) {
|
|
for (int j=0;j<ctx->history_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;i<ctx->history_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;i<ctx->history_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;i<ctx->history_count-1;i++) {
|
|
for (int j=0;j<ctx->history_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;i<ctx->history_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;i<ctx->history_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;
|
|
}
|