eaccebc95d
- DM: add dl_missing_* fields to AppCtx for sequential queue - DM player: count_missing_in_season, find_next_missing, download_episode, download_missing_season_start/next - DM main onResult: auto-continue queue when dl_missing_active, updates status DL X/Y, finishes with done ok/fail - SdDownloader: file exists returns true (Already on SD) with total=file size, not false - SdDownloader: set_result_and_close minimal bundle (only success bool) to avoid internal heap OOM crash on close - SdDownloader: Cancel button becomes Close after done, calls tt_app_stop() directly (manual close per request, auto-close unreliable) - Fixes crash on close observed in demo mode (file exists) and after download done Tested on 192.168.68.133: SdDownloader shows Already on SD + Close, DM shows S10 5/6 after sequential, no crash on open/close cycles
461 lines
15 KiB
C
461 lines
15 KiB
C
#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;
|
||
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
|
||
BundleHandle result_bundle = NULL;
|
||
if (free_internal > 8000) {
|
||
result_bundle = tt_bundle_alloc();
|
||
}
|
||
if (result_bundle) {
|
||
tt_bundle_put_bool(result_bundle, "success", success);
|
||
// Keep bundle minimal to avoid low heap crash (previous put_string path 512B caused OOM)
|
||
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, result_bundle);
|
||
} else {
|
||
// Low heap – return OK without bundle (DM will still refresh via has_slug)
|
||
if (free_internal <= 8000) {
|
||
ESP_LOGW(TAG, "Low heap %d, returning result without bundle", free_internal);
|
||
}
|
||
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, NULL);
|
||
}
|
||
// Change Cancel button to Close once done (manual close, auto-close unreliable)
|
||
tt_lvgl_lock(portMAX_DELAY);
|
||
if (G.btn_cancel) {
|
||
lv_obj_t* lbl = lv_obj_get_child(G.btn_cancel, 0);
|
||
if (lbl) lv_label_set_text(lbl, "Close");
|
||
}
|
||
tt_lvgl_unlock();
|
||
}
|
||
|
||
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 (returning true as cached)", path);
|
||
G.total = st.st_size;
|
||
update_ui_status("Already on SD");
|
||
return true;
|
||
} 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;
|
||
}
|