refactor(DiscoveryMountain): split monolithic main.c into modular files

- Extracted app_context (global AppCtx G definition)
- storage: path helpers, LRU, sort, fmt_time, per-episode resume map, state persistence
- network: resolve_host, normalize URL, http_get_raw with growing PSRAM buffer, fetch_data
- download: dl_ep_raw with progress tracking, cancel, overlay UI (show/hide/update)
- player: audio device, stream, play_task with resume seek, start/select/next/prev with position save/restore
- ui: build_ui, timers, season picker, button callbacks, periodic save
- main: minimal glue, fetch_task, onShow/onHide, app registration
- Preserves all previous fixes: download progress overlay, per-episode resume, robust download handling
- Build verified: 111 undef, all exported
This commit is contained in:
Adolfo
2026-07-22 17:11:59 -04:00
parent 6bfe514ff1
commit be9229b80d
13 changed files with 1662 additions and 1519 deletions
@@ -0,0 +1,393 @@
#include "download.h"
#include "storage.h"
#include "network.h"
#include "player.h"
#include <tt_lvgl.h>
#include <tactility/lvgl_fonts.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <lwip/sockets.h>
#include <lwip/inet.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include <unistd.h>
#include <errno.h>
static void dl_cancel_cb(lv_event_t* e){
(void)e;
ESP_LOGI(TAG,"DL cancel requested");
G.dl_cancel_req=true;
if(G.dl_lbl_detail) lv_label_set_text(G.dl_lbl_detail,"Canceling...");
}
void hide_dl_overlay(){
if(G.dl_overlay){
lv_obj_delete(G.dl_overlay);
G.dl_overlay=NULL;
G.dl_bar=NULL;
G.dl_lbl_pct=NULL;
G.dl_lbl_detail=NULL;
G.dl_lbl_title=NULL;
G.dl_lbl_sub=NULL;
}
G.downloading=false;
}
void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num){
if(G.dl_overlay) hide_dl_overlay();
strncpy(G.dl_ep_title, ep_title ? ep_title : "Episode", sizeof(G.dl_ep_title)-1);
strncpy(G.dl_slug, slug ? slug : "", sizeof(G.dl_slug)-1);
G.dl_season=season;
G.dl_ep_num=ep_num;
G.dl_total=0;
G.dl_expected=-1;
G.dl_last_pct=-1;
G.dl_cancel_req=false;
G.downloading=true;
G.dl_overlay = lv_obj_create(lv_scr_act());
lv_obj_set_size(G.dl_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(G.dl_overlay, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(G.dl_overlay, LV_OPA_70, 0);
lv_obj_set_style_border_width(G.dl_overlay, 0, 0);
lv_obj_set_style_pad_all(G.dl_overlay, 0, 0);
lv_obj_set_style_radius(G.dl_overlay, 0, 0);
lv_obj_remove_flag(G.dl_overlay, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* card = lv_obj_create(G.dl_overlay);
lv_obj_set_size(card, 220, 210);
lv_obj_center(card);
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
lv_obj_set_style_border_width(card, 1, 0);
lv_obj_set_style_radius(card, 12, 0);
lv_obj_set_style_pad_all(card, 12, 0);
lv_obj_set_style_pad_row(card, 6, 0);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* lbl_head = lv_label_create(card);
lv_label_set_text(lbl_head, "Downloading");
lv_obj_set_style_text_color(lbl_head, lv_color_hex(0xCDD6F4), 0);
lv_obj_set_style_text_font(lbl_head, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
G.dl_lbl_title = lv_label_create(card);
lv_label_set_text(G.dl_lbl_title, G.dl_ep_title);
lv_obj_set_width(G.dl_lbl_title, 190);
lv_obj_set_style_text_color(G.dl_lbl_title, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_align(G.dl_lbl_title, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(G.dl_lbl_title, LV_LABEL_LONG_WRAP);
G.dl_lbl_sub = lv_label_create(card);
char sub[64];
snprintf(sub, sizeof(sub), "S%02d E%02d • %s", season, ep_num, slug);
lv_label_set_text(G.dl_lbl_sub, sub);
lv_obj_set_style_text_color(G.dl_lbl_sub, lv_color_hex(0xA6ADC8), 0);
lv_obj_set_style_text_font(G.dl_lbl_sub, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_align(G.dl_lbl_sub, LV_TEXT_ALIGN_CENTER, 0);
G.dl_bar = lv_bar_create(card);
lv_obj_set_size(G.dl_bar, 190, 14);
lv_bar_set_range(G.dl_bar, 0, 100);
lv_bar_set_value(G.dl_bar, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(G.dl_bar, lv_color_hex(0x45475A), LV_PART_MAIN);
lv_obj_set_style_bg_color(G.dl_bar, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
lv_obj_set_style_radius(G.dl_bar, 7, 0);
G.dl_lbl_pct = lv_label_create(card);
lv_label_set_text(G.dl_lbl_pct, "0%");
lv_obj_set_style_text_color(G.dl_lbl_pct, lv_color_hex(0xCDD6F4), 0);
lv_obj_set_style_text_font(G.dl_lbl_pct, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
G.dl_lbl_detail = lv_label_create(card);
lv_label_set_text(G.dl_lbl_detail, "Starting…");
lv_obj_set_style_text_color(G.dl_lbl_detail, lv_color_hex(0xA6ADC8), 0);
lv_obj_set_style_text_font(G.dl_lbl_detail, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_align(G.dl_lbl_detail, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_width(G.dl_lbl_detail, 190);
lv_label_set_long_mode(G.dl_lbl_detail, LV_LABEL_LONG_WRAP);
lv_obj_t* btn_cancel = lv_button_create(card);
lv_obj_set_size(btn_cancel, 90, 32);
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_c = lv_label_create(btn_cancel);
lv_label_set_text(lbl_c, "Cancel");
lv_obj_center(lbl_c);
lv_obj_add_event_cb(btn_cancel, dl_cancel_cb, LV_EVENT_CLICKED, NULL);
}
void update_dl_overlay_ui(){
if(!G.downloading || !G.dl_overlay) return;
if(G.dl_bar){
int pct=0;
if(G.dl_expected>0){
pct = (int)((int64_t)G.dl_total * 100 / G.dl_expected);
if(pct<0) pct=0;
if(pct>100) pct=100;
} else {
pct = G.dl_last_pct;
if(pct<0) pct=0;
}
if(pct!=G.dl_last_pct){
G.dl_last_pct=pct;
lv_bar_set_value(G.dl_bar, pct, LV_ANIM_OFF);
}
}
if(G.dl_lbl_pct){
char b[16];
if(G.dl_expected>0){
int pct = (int)((int64_t)G.dl_total * 100 / G.dl_expected);
if(pct<0) pct=0;
if(pct>100) pct=100;
snprintf(b,sizeof(b),"%d%%",pct);
} else {
snprintf(b,sizeof(b),"%d KB",G.dl_total/1024);
}
lv_label_set_text(G.dl_lbl_pct,b);
}
if(G.dl_lbl_detail){
char d[64];
if(G.dl_expected>0){
int total_kb = G.dl_total/1024;
int exp_kb = G.dl_expected/1024;
if(exp_kb>1024){
snprintf(d,sizeof(d),"%d KB / %d KB (%d.%01d MB)", total_kb, exp_kb, exp_kb/1024, (exp_kb%1024)*10/1024);
} else {
snprintf(d,sizeof(d),"%d / %d KB", total_kb, exp_kb);
}
} else {
snprintf(d,sizeof(d),"%d KB downloaded", G.dl_total/1024);
}
lv_label_set_text(G.dl_lbl_detail,d);
}
}
bool dl_ep_raw(EpInfo* ep){
if(!ep) return false;
if(strlen(ep->audio_url)==0){ ESP_LOGE(TAG,"dl no url for %s",ep->slug); return false; }
char url_norm[512];
normalize_audio_url(ep->audio_url, url_norm, sizeof(url_norm));
ESP_LOGI(TAG,"dl url normalized: %s -> %s", ep->audio_url, url_norm);
const char* url=url_norm;
if(strncmp(url,"http://",7)!=0){
ESP_LOGE(TAG,"dl url not http %s",url);
return false;
}
const char* p=url+7;
const char* slash=strchr(p,'/');
if(!slash){ ESP_LOGE(TAG,"dl no slash"); return false; }
char hostport[128]={0};
size_t hlen=slash-p;
if(hlen>=sizeof(hostport)){ ESP_LOGE(TAG,"dl hostport too long"); return false; }
memcpy(hostport,p,hlen);
hostport[hlen]=0;
const char* path=slash;
char host[96]={0};
int port=80;
char* colon=strchr(hostport,':');
if(colon){
*colon=0;
strncpy(host,hostport,sizeof(host)-1);
port=atoi(colon+1);
if(port<=0) port=80;
} else {
strncpy(host,hostport,sizeof(host)-1);
}
uint32_t ip=0;
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"dl ip fail %s",host); return false; }
char final_path[300];
char tmp_path[320];
make_sd_path(final_path,sizeof(final_path),ep->slug);
snprintf(tmp_path,sizeof(tmp_path),"%s.tmp",final_path);
unlink(tmp_path);
FILE* f=fopen(tmp_path,"wb");
if(!f){ ESP_LOGE(TAG,"dl fopen fail %s",tmp_path); return false; }
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
if(fd<0){ ESP_LOGE(TAG,"dl socket fail"); fclose(f); unlink(tmp_path); return false; }
struct sockaddr_in sa;
memset(&sa,0,sizeof(sa));
sa.sin_family=AF_INET;
sa.sin_port=my_htons(port);
sa.sin_addr.s_addr=ip;
struct timeval tv={15,0};
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
ESP_LOGI(TAG,"dl connecting %s:%d",host,port);
if(lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa))<0){
ESP_LOGE(TAG,"dl connect fail %s:%d errno=%d",host,port,errno);
fclose(f); close(fd); unlink(tmp_path); return false;
}
ESP_LOGI(TAG,"dl connected");
char req[512];
int reqlen=snprintf(req,sizeof(req),"GET %s HTTP/1.0\r\nHost: %s:%d\r\nConnection: close\r\nUser-Agent: TactilityDM/0.1\r\nAccept: */*\r\n\r\n",path,host,port);
if(lwip_send(fd,req,reqlen,0)<0){
ESP_LOGE(TAG,"dl send fail errno=%d",errno);
fclose(f); close(fd); unlink(tmp_path); return false;
}
ESP_LOGI(TAG,"dl request sent");
char* hdr=heap_caps_malloc(8192, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!hdr){ fclose(f); close(fd); unlink(tmp_path); return false; }
int hdr_len=0;
bool header_done=false;
int content_len=-1;
int total=0;
uint8_t* recv_buf=heap_caps_malloc(8192, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!recv_buf){ heap_caps_free(hdr); fclose(f); close(fd); unlink(tmp_path); return false; }
G.dl_total=0;
G.dl_expected=-1;
G.dl_last_pct=-1;
while(!header_done){
if(G.dl_cancel_req){ ESP_LOGI(TAG,"dl canceled during header"); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; }
int r=lwip_recv(fd,(char*)recv_buf,4096,0);
if(r<=0){
ESP_LOGE(TAG,"dl recv header fail r=%d errno=%d",r,errno);
heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false;
}
if(hdr_len+r >= 8191){ ESP_LOGE(TAG,"dl hdr overflow"); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; }
memcpy(hdr+hdr_len, recv_buf, r);
hdr_len+=r;
hdr[hdr_len]=0;
char* he=strstr(hdr,"\r\n\r\n");
if(he){
header_done=true;
if(strncmp(hdr,"HTTP/1.1 200",12)!=0 && strncmp(hdr,"HTTP/1.0 200",12)!=0){
ESP_LOGE(TAG,"dl not 200: %.120s",hdr);
heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false;
}
char* cl=strstr(hdr,"Content-Length:");
if(!cl) cl=strstr(hdr,"content-length:");
if(cl){
cl=strchr(cl,':');
if(cl){ cl++; while(*cl==' ') cl++; content_len=atoi(cl); }
}
ESP_LOGI(TAG,"dl header done cl=%d",content_len);
G.dl_expected=content_len;
int body_start = (he - hdr) + 4;
int body_in_buf = hdr_len - body_start;
if(body_in_buf>0){
fwrite(hdr+body_start,1,body_in_buf,f);
total+=body_in_buf;
G.dl_total=total;
ESP_LOGI(TAG,"dl wrote initial %d",body_in_buf);
}
break;
}
}
ESP_LOGI(TAG,"dl streaming body cl=%d",content_len);
int consecutive_timeouts=0;
const int max_timeouts=12;
while(1){
if(G.dl_cancel_req){
ESP_LOGI(TAG,"dl canceled during body total=%d",total);
heap_caps_free(hdr);
heap_caps_free(recv_buf);
close(fd);
fclose(f);
unlink(tmp_path);
return false;
}
if(content_len>=0 && total>=content_len) break;
int r=lwip_recv(fd,(char*)recv_buf,8192,0);
if(r<0){
if(errno==EAGAIN || errno==EWOULDBLOCK || errno==ETIMEDOUT || errno==0){
consecutive_timeouts++;
ESP_LOGW(TAG,"dl timeout %d/%d total=%d/%d",consecutive_timeouts,max_timeouts,total,content_len);
if(consecutive_timeouts>=max_timeouts){
ESP_LOGE(TAG,"dl too many timeouts, aborting");
break;
}
vTaskDelay(pdMS_TO_TICKS(200));
continue;
} else {
ESP_LOGE(TAG,"dl recv error r=%d errno=%d",r,errno);
break;
}
}
if(r==0){
ESP_LOGI(TAG,"dl EOF total=%d expected=%d",total,content_len);
break;
}
consecutive_timeouts=0;
fwrite(recv_buf,1,r,f);
total+=r;
G.dl_total=total;
}
heap_caps_free(hdr);
heap_caps_free(recv_buf);
close(fd);
fclose(f);
ESP_LOGI(TAG,"DL done total %d expected %d",total,content_len);
if(G.dl_cancel_req){
ESP_LOGI(TAG,"DL canceled, removing tmp");
unlink(tmp_path);
return false;
}
if(total<1024){ ESP_LOGE(TAG,"dl too small %d",total); unlink(tmp_path); return false; }
if(content_len>=0 && total < content_len){
if(total < content_len * 95 / 100){
ESP_LOGE(TAG,"dl incomplete %d < %d (95%% threshold)",total,content_len);
unlink(tmp_path);
return false;
} else {
ESP_LOGW(TAG,"dl size slightly short %d vs %d, accepting",total,content_len);
}
}
if(rename(tmp_path,final_path)!=0){
ESP_LOGE(TAG,"rename failed %s -> %s errno=%d",tmp_path,final_path,errno);
unlink(tmp_path);
return false;
}
ep->seen=true;
return true;
}
bool dl_ep(EpInfo* ep){
if(!ep) return false;
if(has_slug(ep->slug)) return true;
ensure_dir();
lru_check();
return dl_ep_raw(ep);
}
void dl_task(void* arg){
int idx = (int)(intptr_t)arg;
G.downloading=true;
G.dl_cancel_req=false;
if(idx<0||idx>=G.ep_cnt){ G.downloading=false; G.dl_handle=NULL; vTaskDelete(NULL); return; }
EpInfo* ep=&G.eps[idx];
ESP_LOGI(TAG,"DL start %s url %s",ep->slug,ep->audio_url);
bool ok=dl_ep(ep);
ESP_LOGI(TAG,"DL end %s ok %d",ep->slug,ok);
tt_lvgl_lock(portMAX_DELAY);
hide_dl_overlay();
if(G.lbl_status){
if(ok) lv_label_set_text(G.lbl_status,"DL done, playing...");
else {
if(G.dl_cancel_req) lv_label_set_text(G.lbl_status,"DL canceled");
else lv_label_set_text(G.lbl_status,"DL failed – tap Play to retry");
}
}
tt_lvgl_unlock();
G.downloading=false;
G.dl_handle=NULL;
if(ok){
start_idx_internal(idx);
}
vTaskDelete(NULL);
}