Files
tactility_apps/Apps/SdDownloader/main/Source/main.c
T
Adolfo 3778cb3399 feat(DM): library UI with episode list, SD status, cache, close button, stable single-file DL
- DiscoveryMountain: new UI optimized for episode list (not player anymore)
  - Shows episodes for current season, indicates if on SD card (green=On SD, gray=Not DL)
  - Per-episode action: download single via SdDownloader or play via Mp3Player
  - DL Missing button for missing eps in season (currently single first missing, batch reverted)
  - Season selector screen with cached/total counts
  - Top-right X close button
  - Seasons/episodes cached to cache.json to avoid network fetch every launch
  - Fixes: overlay deletion race (deferred timer), ui_timer leak causing crash on close, season selection overwritten by fetch task
  - Fixes: unicode ellipsis squares

- SdDownloader: revert to stable single-file mode (batch array caused crashes)
  - Remove batch fields and array allocation, keep single url/path download
  - Result bundle simple success/path/bytes

- Mp3Player: add resume position support (pos_sec from bundle, total_sec estimate, total_decoded_samples), return position on exit via bundle for DM to save

Tested on 192.168.68.133: opens, shows S10 2/6, Play/DL buttons, close works, no crash on open/close cycles, cache shows 'Loaded from cache, refreshing...'
2026-07-25 22:06:43 -04:00

452 lines
15 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_bundle.h>
#include <tactility/lvgl_fonts.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_http_client.h"
#include "esp_heap_caps.h"
#define TAG "SDDL"
#define DEFAULT_URL "http://192.168.68.110:8098/463_Discovering_Discovery_Mountain_S01E01/discovery_mountain_s01e01_esp32_24k.mp3"
#define DEFAULT_PATH "/sdcard/download/test2.mp3"
typedef struct {
lv_obj_t *bar;
lv_obj_t *lbl_url;
lv_obj_t *lbl_path;
lv_obj_t *lbl_pct;
lv_obj_t *lbl_detail;
lv_obj_t *lbl_status;
lv_obj_t *btn_cancel;
char url[512];
char path[512];
bool override_existing;
AppHandle app_handle;
TaskHandle_t dl_handle;
volatile bool downloading;
volatile bool cancel_req;
volatile int total;
volatile int expected;
int last_pct;
bool result_success;
} AppCtx;
static AppCtx G;
static void ensure_parent_dir(const char *filepath) {
if (!filepath) return;
char path_copy[512];
strncpy(path_copy, filepath, sizeof(path_copy)-1);
path_copy[sizeof(path_copy)-1]=0;
char *last_slash = strrchr(path_copy, '/');
if (!last_slash) return;
*last_slash = 0;
if (strlen(path_copy)==0) return;
char cur[512] = "";
if (path_copy[0]=='/') strcpy(cur, "/");
char *start = path_copy[0]=='/' ? path_copy+1 : path_copy;
int len = strlen(start);
int i = 0;
while (i <= len) {
int j = i;
while (j < len && start[j] != '/') j++;
if (j > i) {
size_t cur_len = strlen(cur);
if (cur_len > 0 && cur[cur_len-1] != '/') {
if (cur_len + 1 < sizeof(cur)) {
cur[cur_len] = '/';
cur[cur_len+1] = 0;
}
}
char saved = start[j];
start[j] = 0;
strncat(cur, start+i, sizeof(cur)-strlen(cur)-1);
start[j] = saved;
mkdir(cur, 0777);
}
i = j+1;
}
}
static void update_ui_status(const char *txt) {
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, txt);
tt_lvgl_unlock();
}
static void set_result_and_close(bool success) {
G.result_success = success;
BundleHandle result_bundle = tt_bundle_alloc();
if (result_bundle) {
tt_bundle_put_bool(result_bundle, "success", success);
tt_bundle_put_string(result_bundle, "path", G.path);
tt_bundle_put_int32(result_bundle, "bytes", G.total);
if (!success) {
tt_lvgl_lock(portMAX_DELAY);
const char *status = G.lbl_status ? lv_label_get_text(G.lbl_status) : "failed";
char status_copy[128];
strncpy(status_copy, status, sizeof(status_copy)-1);
status_copy[sizeof(status_copy)-1]=0;
tt_lvgl_unlock();
tt_bundle_put_string(result_bundle, "error", status_copy);
}
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, result_bundle);
} else {
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, NULL);
}
}
static bool download_one_file(const char *url, const char *path) {
ESP_LOGI(TAG, "DL one file url=%s path=%s override=%d", url, path, G.override_existing);
struct stat st;
if (stat(path, &st)==0) {
if (!G.override_existing) {
ESP_LOGW(TAG, "File exists and override=false: %s", path);
update_ui_status("File exists");
return false;
} else {
ESP_LOGI(TAG, "File exists but override=true, overwriting %s", path);
unlink(path);
}
}
ensure_parent_dir(path);
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
ESP_LOGI(TAG, "Heap before DL: internal=%d", free_internal);
if (free_internal < 10000) {
vTaskDelay(pdMS_TO_TICKS(1000));
}
if (strlen(url) < 8 || strncmp(url, "http://", 7)!=0) {
update_ui_status("Invalid URL");
return false;
}
char tmp_path[520];
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path);
unlink(tmp_path);
FILE *f = fopen(tmp_path, "wb");
if (!f) {
ESP_LOGE(TAG, "fopen fail %s errno=%d", tmp_path, errno);
update_ui_status("Failed to open file");
return false;
}
esp_http_client_config_t cfg = {
.url = url,
.timeout_ms = 15000,
.buffer_size = 4096,
.buffer_size_tx = 1024,
.keep_alive_enable = false,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) {
fclose(f);
unlink(tmp_path);
update_ui_status("HTTP init failed");
return false;
}
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
esp_http_client_cleanup(client);
fclose(f);
unlink(tmp_path);
update_ui_status("HTTP open failed");
return false;
}
int content_len = esp_http_client_fetch_headers(client);
int status = esp_http_client_get_status_code(client);
ESP_LOGI(TAG, "HTTP status %d, content_len %d", status, content_len);
if (status != 200) {
char msg[64];
snprintf(msg, sizeof(msg), "HTTP %d", status);
update_ui_status(msg);
esp_http_client_close(client);
esp_http_client_cleanup(client);
fclose(f);
unlink(tmp_path);
return false;
}
G.expected = content_len;
uint8_t *buf = heap_caps_malloc(4096, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!buf) {
buf = malloc(4096);
if (!buf) {
esp_http_client_close(client);
esp_http_client_cleanup(client);
fclose(f);
unlink(tmp_path);
update_ui_status("No memory");
return false;
}
}
int total = 0;
int timeouts = 0;
bool ok = true;
while (1) {
if (G.cancel_req) { ok = false; break; }
if (content_len >=0 && total >= content_len) break;
int r = esp_http_client_read(client, (char*)buf, 4096);
if (r < 0) {
timeouts++;
if (timeouts >= 5) { ok = false; break; }
vTaskDelay(pdMS_TO_TICKS(200));
continue;
}
if (r == 0) break;
timeouts = 0;
if (fwrite(buf, 1, r, f) != (size_t)r) { ok = false; break; }
total += r;
G.total = total;
int pct = 0;
if (content_len > 0) {
pct = (int)((int64_t)total * 100 / content_len);
if (pct<0) pct=0;
if (pct>100) pct=100;
}
if (pct != G.last_pct) {
G.last_pct = pct;
tt_lvgl_lock(portMAX_DELAY);
if (G.bar) lv_bar_set_value(G.bar, pct, LV_ANIM_OFF);
if (G.lbl_detail) {
char d[64];
if (content_len>0) snprintf(d,sizeof(d),"%d / %d KB", total/1024, content_len/1024);
else snprintf(d,sizeof(d),"%d KB", total/1024);
lv_label_set_text(G.lbl_detail, d);
}
if (G.lbl_pct) {
char b[32];
if (content_len>0) snprintf(b,sizeof(b),"%d%%",pct);
else snprintf(b,sizeof(b),"%d KB", total/1024);
lv_label_set_text(G.lbl_pct, b);
}
tt_lvgl_unlock();
}
if ((total / 4096) % 8 == 0) {
fflush(f);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
heap_caps_free(buf);
esp_http_client_close(client);
esp_http_client_cleanup(client);
fclose(f);
if (G.cancel_req) { unlink(tmp_path); update_ui_status("Canceled"); return false; }
if (!ok || total < 512) { unlink(tmp_path); update_ui_status("Failed error"); return false; }
if (content_len >=0 && total < content_len * 95 / 100) { unlink(tmp_path); update_ui_status("Failed incomplete"); return false; }
if (rename(tmp_path, path) != 0) { unlink(tmp_path); update_ui_status("Rename failed"); return false; }
return true;
}
static void download_task(void *arg) {
(void)arg;
G.downloading = true;
G.cancel_req = false;
G.last_pct = -1;
char url[512];
char path[512];
strncpy(url, G.url, sizeof(url)-1);
strncpy(path, G.path, sizeof(path)-1);
url[sizeof(url)-1]=0;
path[sizeof(path)-1]=0;
bool ok = download_one_file(url, path);
if (ok) {
char msg[64];
snprintf(msg, sizeof(msg), "Done! %d KB", G.total/1024);
update_ui_status(msg);
}
G.downloading = false;
G.dl_handle = NULL;
set_result_and_close(ok);
vTaskDelete(NULL);
}
static void btn_cancel_cb(lv_event_t *e) {
(void)e;
if (!G.downloading) { tt_app_stop(); return; }
G.cancel_req = true;
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Canceling...");
tt_lvgl_unlock();
}
static void build_ui(void) {
tt_lvgl_lock(portMAX_DELAY);
lv_obj_t *root = lv_obj_create(lv_scr_act());
lv_obj_set_size(root, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_all(root, 8, 0);
lv_obj_set_style_bg_color(root, lv_color_hex(0x111111), 0);
lv_obj_set_style_border_width(root, 0, 0);
lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(root, 6, 0);
lv_obj_t *title = lv_label_create(root);
lv_label_set_text(title, "Downloading...");
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
G.lbl_url = lv_label_create(root);
lv_label_set_text(G.lbl_url, G.url);
lv_obj_set_width(G.lbl_url, LV_PCT(100));
lv_label_set_long_mode(G.lbl_url, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(G.lbl_url, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_color(G.lbl_url, lv_color_hex(0x888888), 0);
G.lbl_path = lv_label_create(root);
lv_label_set_text(G.lbl_path, G.path);
lv_obj_set_width(G.lbl_path, LV_PCT(100));
lv_label_set_long_mode(G.lbl_path, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(G.lbl_path, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_color(G.lbl_path, lv_color_hex(0xAAAAAA), 0);
G.bar = lv_bar_create(root);
lv_obj_set_size(G.bar, LV_PCT(100), 14);
lv_bar_set_range(G.bar, 0, 100);
lv_bar_set_value(G.bar, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(G.bar, lv_color_hex(0x333333), LV_PART_MAIN);
lv_obj_set_style_bg_color(G.bar, lv_color_hex(0x2E7D32), LV_PART_INDICATOR);
lv_obj_t *row = lv_obj_create(root);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
G.lbl_pct = lv_label_create(row);
lv_label_set_text(G.lbl_pct, "0%");
lv_obj_set_style_text_font(G.lbl_pct, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
G.lbl_detail = lv_label_create(row);
lv_label_set_text(G.lbl_detail, "-");
lv_obj_set_style_text_font(G.lbl_detail, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_color(G.lbl_detail, lv_color_hex(0x888888), 0);
G.lbl_status = lv_label_create(root);
lv_label_set_text(G.lbl_status, "Starting...");
lv_obj_set_width(G.lbl_status, LV_PCT(100));
lv_label_set_long_mode(G.lbl_status, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_color(G.lbl_status, lv_color_hex(0xCCCCCC), 0);
G.btn_cancel = lv_button_create(root);
lv_obj_set_size(G.btn_cancel, 100, 40);
lv_obj_set_style_bg_color(G.btn_cancel, lv_color_hex(0x444444), 0);
lv_obj_t *lbl = lv_label_create(G.btn_cancel);
lv_label_set_text(lbl, "Cancel");
lv_obj_center(lbl);
lv_obj_add_event_cb(G.btn_cancel, btn_cancel_cb, LV_EVENT_CLICKED, NULL);
tt_lvgl_unlock();
}
static void onShow(AppHandle app, void* data, lv_obj_t* parent) {
(void)data; (void)parent;
memset(&G, 0, sizeof(G));
G.app_handle = app;
G.last_pct = -1;
BundleHandle bundle = tt_app_get_parameters(app);
bool has_url = false;
bool has_path = false;
if (bundle) {
char url_buf[512] = {0};
char path_buf[512] = {0};
bool override_val = false;
if (tt_bundle_opt_string(bundle, "url", url_buf, sizeof(url_buf)) ||
tt_bundle_opt_string(bundle, "file", url_buf, sizeof(url_buf))) {
strncpy(G.url, url_buf, sizeof(G.url)-1);
has_url = true;
}
if (tt_bundle_opt_string(bundle, "path", path_buf, sizeof(path_buf)) ||
tt_bundle_opt_string(bundle, "dest", path_buf, sizeof(path_buf)) ||
tt_bundle_opt_string(bundle, "file_dest", path_buf, sizeof(path_buf))) {
strncpy(G.path, path_buf, sizeof(G.path)-1);
has_path = true;
}
if (tt_bundle_opt_bool(bundle, "override", &override_val)) {
G.override_existing = override_val;
} else {
bool ow2 = false;
if (tt_bundle_opt_bool(bundle, "overwrite", &ow2)) G.override_existing = ow2;
}
}
if (!has_url) strncpy(G.url, DEFAULT_URL, sizeof(G.url)-1);
if (!has_path) strncpy(G.path, DEFAULT_PATH, sizeof(G.path)-1);
mkdir("/sdcard", 0777);
mkdir("/sdcard/download", 0777);
mkdir("/sdcard/dm", 0777);
build_ui();
bool should_autostart = has_url && has_path;
if (!has_url && !has_path) {
should_autostart = true;
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Demo mode no args, using defaults");
tt_lvgl_unlock();
}
ESP_LOGI(TAG, "Autostart=%d url=%s path=%s override=%d", should_autostart, G.url, G.path, G.override_existing);
if (should_autostart) {
BaseType_t res = xTaskCreate(download_task, "sd_dl", 8192, NULL, 5, &G.dl_handle);
ESP_LOGI(TAG, "xTaskCreate res=%d handle=%p", res, G.dl_handle);
if (res != pdPASS) update_ui_status("Failed to create DL task");
} else {
update_ui_status("Missing url/path args");
}
}
static void onHide(AppHandle app, void* data) {
(void)app; (void)data;
if (G.dl_handle) {
G.cancel_req = true;
for (int i=0;i<20 && G.dl_handle!=NULL; i++) vTaskDelay(pdMS_TO_TICKS(100));
if (G.dl_handle) { vTaskDelete(G.dl_handle); G.dl_handle = NULL; G.downloading = false; }
}
}
int main(int argc, char* argv[]) {
(void)argc; (void)argv;
tt_app_register((AppRegistration){ .onShow = onShow, .onHide = onHide });
return 0;
}