be9229b80d
- 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
394 lines
14 KiB
C
394 lines
14 KiB
C
#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);
|
||
}
|