From 6bfe514ff1f02d933d8fd79d46a54f2c12c28701 Mon Sep 17 00:00:00 2001 From: Adolfo Date: Wed, 22 Jul 2026 16:54:40 -0400 Subject: [PATCH] feat(DiscoveryMountain): add download progress overlay and per-episode resume tracking - Show full-screen downloading modal with progress bar, percent and KB detail, cancel button - Track dl_total/dl_expected without LVGL locks, update via ui_timer (200ms) - Normalize audio URLs (relative /api/files/ -> PB prefix), grow PSRAM fetch buffer 64K->600K, handle timeouts and require 95% of Content-Length - Persist per-episode playback position in state.json positions map, restore on select_ep/go_next/go_prev - Save position periodically (5s), on pause, on hide, and on natural finish (clears completed) - Fix seek on resume using byte estimate and total_decoded_samples correction - Verified symbols: 111 undefined, all exported --- Apps/DiscoveryMountain/main/Source/main.c | 736 +++++++++++++++++++--- 1 file changed, 657 insertions(+), 79 deletions(-) diff --git a/Apps/DiscoveryMountain/main/Source/main.c b/Apps/DiscoveryMountain/main/Source/main.c index f0d6768..1daea3f 100644 --- a/Apps/DiscoveryMountain/main/Source/main.c +++ b/Apps/DiscoveryMountain/main/Source/main.c @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include "freertos/FreeRTOS.h" @@ -31,6 +32,7 @@ typedef struct { int num; } SeasonInfo; typedef struct { char title[128]; char slug[96]; int season; int ep_num; char audio_url[256]; bool seen; } EpInfo; +typedef struct { char slug[96]; int pos; } SavedPos; static struct { SeasonInfo seasons[MAX_SEASONS]; int season_cnt; @@ -48,10 +50,30 @@ static struct { int last_pct; lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play; lv_obj_t *overlay, *dropdown; + // Download UI + lv_obj_t *dl_overlay; + lv_obj_t *dl_bar; + lv_obj_t *dl_lbl_pct; + lv_obj_t *dl_lbl_detail; + lv_obj_t *dl_lbl_title; + lv_obj_t *dl_lbl_sub; char cur_title[128]; char last_slug[96]; bool fetching; bool downloading; + int dl_total; + int dl_expected; + int dl_last_ui_update; + bool dl_cancel_req; + char dl_ep_title[128]; + char dl_slug[96]; + int dl_season; + int dl_ep_num; + int dl_last_pct; + // Per-episode resume tracking + SavedPos saved_pos[MAX_EPS]; + int saved_pos_cnt; + int last_save_tick; // for periodic save throttling } G; static void make_sd_path(char* out, size_t out_len, const char* slug){ @@ -71,12 +93,76 @@ static bool has_slug(const char* slug){ return stat(p,&st)==0; } +// ── Per-episode position helpers ── +static int find_saved_pos_idx(const char* slug){ + if(!slug) return -1; + for(int i=0;i=0) return G.saved_pos[idx].pos; + return 0; +} +static void set_saved_pos_for_slug(const char* slug, int pos){ + if(!slug || strlen(slug)==0) return; + // Don't save tiny progress (<5s) as 0 to avoid clutter? But we still want to save 0 to clear completed. + // Clamp pos >=0 + if(pos<0) pos=0; + // If near end (within 10s of total if known), clear to 0 + if(G.total_sec>0 && pos>0 && pos >= G.total_sec-10){ + pos=0; + } + int idx=find_saved_pos_idx(slug); + if(idx>=0){ + G.saved_pos[idx].pos=pos; + // If pos==0 and we want to keep map tidy, we could keep it as 0 (means completed) or remove? Keep as 0 for now. + return; + } + if(G.saved_pos_cnt < MAX_EPS){ + strncpy(G.saved_pos[G.saved_pos_cnt].slug, slug, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1); + G.saved_pos[G.saved_pos_cnt].slug[sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1]=0; + G.saved_pos[G.saved_pos_cnt].pos=pos; + G.saved_pos_cnt++; + } +} +static void save_current_ep_pos(){ + if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt){ + const char* slug=G.eps[G.cur_ep_idx].slug; + set_saved_pos_for_slug(slug, G.pos_sec); + } else if(strlen(G.last_slug)>0){ + set_saved_pos_for_slug(G.last_slug, G.pos_sec); + } +} + static void save_state(){ + // Ensure current episode pos is up-to-date before persisting + if(G.cur_ep_idx>=0 && G.ep_cnt>0){ + // Only auto-save if we are not at very start and we have valid pos + // (caller may have already updated saved_pos, but ensure) + if(G.pos_sec>=0){ + set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, G.pos_sec); + } + } cJSON* r=cJSON_CreateObject(); if(G.ep_cnt>0 && G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt) cJSON_AddStringToObject(r,"last_slug",G.eps[G.cur_ep_idx].slug); else if(strlen(G.last_slug)>0) cJSON_AddStringToObject(r,"last_slug",G.last_slug); cJSON_AddNumberToObject(r,"last_season",G.cur_season); cJSON_AddNumberToObject(r,"pos_sec",G.pos_sec); + // Save per-episode map + if(G.saved_pos_cnt>0){ + cJSON* pos_obj=cJSON_CreateObject(); + for(int i=0;i0 to keep file small, but keep 0 for completed if needed? We skip 0 to save space, but resume will be 0. + if(G.saved_pos[i].pos>0){ + cJSON_AddNumberToObject(pos_obj, G.saved_pos[i].slug, G.saved_pos[i].pos); + } + } + cJSON_AddItemToObject(r,"positions",pos_obj); + } char* s=cJSON_PrintUnformatted(r); if(s){ FILE* f=fopen(STATE_PATH,"w"); @@ -87,13 +173,16 @@ static void save_state(){ } static void load_state(){ G.cur_season=1; G.cur_ep_idx=-1; G.pos_sec=0; + G.saved_pos_cnt=0; + G.last_save_tick=0; memset(G.last_slug,0,sizeof(G.last_slug)); + memset(G.saved_pos,0,sizeof(G.saved_pos)); FILE* f=fopen(STATE_PATH,"r"); if(!f) return; fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET); - if(sz<=0||sz>4096){ fclose(f); return;} + if(sz<=0||sz>16384){ fclose(f); return;} char* buf=malloc(sz+1); if(!buf){ fclose(f); return; } size_t got=fread(buf,1,sz,f); @@ -111,9 +200,27 @@ static void load_state(){ strncpy(G.last_slug, ls->valuestring, sizeof(G.last_slug)-1); for(int i=0;ivaluestring)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; } } + cJSON* poss=cJSON_GetObjectItem(r,"positions"); + if(poss && cJSON_IsObject(poss)){ + cJSON* child=NULL; + cJSON_ArrayForEach(child, poss){ + if(cJSON_IsNumber(child) && child->string && G.saved_pos_cnt < MAX_EPS){ + strncpy(G.saved_pos[G.saved_pos_cnt].slug, child->string, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1); + G.saved_pos[G.saved_pos_cnt].pos=child->valueint; + G.saved_pos_cnt++; + } + } + // Also if last_slug has a saved pos, restore G.pos_sec from it (overrides legacy pos_sec if present in map) + if(strlen(G.last_slug)>0){ + int sp=get_saved_pos_for_slug(G.last_slug); + if(sp>0) G.pos_sec=sp; + } + } cJSON_Delete(r); } +static void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); } + static void sort_seasons(){ for(int i=0;i>8); } +static bool resolve_host(const char* host, uint32_t* out_ip){ + if(!host || !out_ip) return false; + uint32_t ip = ipaddr_addr(host); + if(ip!=0 && ip!=0xFFFFFFFF){ + *out_ip=ip; + return true; + } + ESP_LOGE(TAG,"resolve fail (DNS not supported) %s",host); + return false; +} + +static void normalize_audio_url(const char* in, char* out, size_t out_len){ + if(!in || !out) return; + if(strncmp(in,"http://",7)==0){ + strncpy(out,in,out_len-1); + out[out_len-1]=0; + return; + } + if(in[0]=='/'){ + snprintf(out,out_len,"%s%s",PB,in); + return; + } + // If it's a relative path without leading slash, treat as PB path + if(strstr(in,"://")==NULL){ + // assume it's a path like api/files/... + if(in[0]=='/') snprintf(out,out_len,"%s%s",PB,in); + else snprintf(out,out_len,"%s/%s",PB,in); + return; + } + // https or other scheme -> keep but will fail later with clear log + strncpy(out,in,out_len-1); + out[out_len-1]=0; +} + +// ── Download UI helpers ── +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..."); +} + +static void hide_dl_overlay(){ + // must be called with LVGL lock held + 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; +} + +static void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num){ + // must be called with LVGL lock held + 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); +} + +// Called from ui_timer (LVGL thread) to update download overlay from shared state +static 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 { + // indeterminate: bounce or show 0, use total KB mod + 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){ + // show 1.2MB / 4.3MB + 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); + } +} + +// ── HTTP GET (robust, growing PSRAM buffer) ── static int http_get_raw(const char* url, char** out_body){ - if(!url || strncmp(url,"http://",7)!=0){ ESP_LOGE(TAG,"http_get url not http"); return -1; } + if(!url || strncmp(url,"http://",7)!=0){ ESP_LOGE(TAG,"http_get url not http %s",url); return -1; } const char* p = url+7; const char* slash = strchr(p,'/'); if(!slash){ ESP_LOGE(TAG,"no slash %s",url); return -1; } @@ -158,8 +457,8 @@ static int http_get_raw(const char* url, char** out_body){ 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); } ESP_LOGI(TAG,"GET %s host=%s port=%d",url,host,port); - uint32_t ip=ipaddr_addr(host); - if(ip==0 || ip==0xFFFFFFFF){ ESP_LOGE(TAG,"ip fail %s",host); return -1; } + uint32_t ip=0; + if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"ip fail %s",host); return -1; } int fd=lwip_socket(AF_INET,SOCK_STREAM,0); if(fd<0){ ESP_LOGE(TAG,"socket fail"); return -1; } struct sockaddr_in sa; memset(&sa,0,sizeof(sa)); @@ -172,16 +471,26 @@ static int http_get_raw(const char* url, char** out_body){ 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,"send fail"); close(fd); return -1; } - int capacity=200000; + int capacity=65536; char* buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); - if(!buf){ ESP_LOGE(TAG,"PSRAM 200k fail"); close(fd); return -1; } + if(!buf){ ESP_LOGE(TAG,"PSRAM 64k fail"); close(fd); return -1; } int total=0; char* header_end=NULL; int body_start=0; int content_len=-1; bool is_chunked=false; - while(total < capacity-1){ + while(1){ + if(total>=capacity-1){ + int newcap=capacity*2; + if(newcap>600000){ ESP_LOGE(TAG,"too large %d",newcap); heap_caps_free(buf); close(fd); return -1; } + char* nb=heap_caps_malloc(newcap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if(!nb){ heap_caps_free(buf); close(fd); return -1; } + memcpy(nb,buf,total); + heap_caps_free(buf); + buf=nb; + capacity=newcap; + } int r=lwip_recv(fd,buf+total,capacity-total-1,0); if(r<=0) break; total+=r; @@ -254,11 +563,6 @@ static int http_get_raw(const char* url, char** out_body){ return final_len; } - - - - - static void fetch_data(){ char* js=NULL; char url[256]; @@ -377,12 +681,20 @@ static void lru_check(){ for(int i=0;iaudio_url)==0){ ESP_LOGE(TAG,"dl no url"); return false; } - const char* url=ep->audio_url; - if(strncmp(url,"http://",7)!=0){ ESP_LOGE(TAG,"dl url not http %s",url); 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; } @@ -403,13 +715,15 @@ static bool dl_ep_raw(EpInfo* ep){ } else { strncpy(host,hostport,sizeof(host)-1); } - uint32_t ip=ipaddr_addr(host); - if(ip==0 || ip==0xFFFFFFFF){ ESP_LOGE(TAG,"dl ip fail %s",host); return false; } + 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); + // Clean any old tmp + unlink(tmp_path); FILE* f=fopen(tmp_path,"wb"); if(!f){ ESP_LOGE(TAG,"dl fopen fail %s",tmp_path); return false; } @@ -425,15 +739,15 @@ static bool dl_ep_raw(EpInfo* ep){ 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",host,port); + 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\n\r\n",path,host,port); + 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"); + ESP_LOGE(TAG,"dl send fail errno=%d",errno); fclose(f); close(fd); unlink(tmp_path); return false; } ESP_LOGI(TAG,"dl request sent"); @@ -445,13 +759,21 @@ static bool dl_ep_raw(EpInfo* ep){ bool header_done=false; int content_len=-1; int total=0; - uint8_t* recv_buf=heap_caps_malloc(4096, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + 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; + // Read header 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",r); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; } + 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; @@ -460,7 +782,7 @@ static bool dl_ep_raw(EpInfo* ep){ 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: %.80s",hdr); + 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:"); @@ -470,11 +792,13 @@ static bool dl_ep_raw(EpInfo* ep){ 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; @@ -483,18 +807,46 @@ static bool dl_ep_raw(EpInfo* ep){ // Stream body ESP_LOGI(TAG,"dl streaming body cl=%d",content_len); + int consecutive_timeouts=0; + const int max_timeouts=12; // 12*15s = 180s tolerated gaps, but will fail if persistent 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,4096,0); - if(r<=0) break; + int r=lwip_recv(fd,(char*)recv_buf,8192,0); + if(r<0){ + // timeout? + 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){ + // EOF + ESP_LOGI(TAG,"dl EOF total=%d expected=%d",total,content_len); + break; + } + consecutive_timeouts=0; fwrite(recv_buf,1,r,f); total+=r; - if(total%32768==0 || total<4096){ - ESP_LOGI(TAG,"dl progress %d/%d",total,content_len); - tt_lvgl_lock(portMAX_DELAY); - if(G.lbl_status){ char m[32]; snprintf(m,sizeof(m),"DL %dKB",total/1024); lv_label_set_text(G.lbl_status,m); } - tt_lvgl_unlock(); - } + G.dl_total=total; + // No LVGL lock here – UI timer will poll G.dl_total } heap_caps_free(hdr); @@ -502,18 +854,32 @@ static bool dl_ep_raw(EpInfo* ep){ close(fd); fclose(f); ESP_LOGI(TAG,"DL done total %d expected %d",total,content_len); - if(total<1024){ ESP_LOGE(TAG,"dl too small"); unlink(tmp_path); return false; } - if(content_len>=0 && total!=content_len){ - ESP_LOGW(TAG,"dl size mismatch %d vs %d, but accepting",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){ + // Incomplete download – consider failure if less than 95% (allow small truncation?) + 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); + // try copy fallback? For now fail + return false; } - rename(tmp_path,final_path); ep->seen=true; return true; } - - - static bool dl_ep(EpInfo* ep){ if(!ep) return false; if(has_slug(ep->slug)) return true; @@ -555,6 +921,19 @@ static void wait_play_exit(){ close_stream(); } +static void stop_playback_sync(){ + if(G.play_handle){ + G.need_stop=true; + int tries=200; + while(G.play_handle!=NULL && tries-- >0){ + vTaskDelay(pdMS_TO_TICKS(10)); + } + } + close_stream(); + G.is_playing=false; + G.is_paused=false; +} + static void play_task(void* arg){ (void)arg; int idx=G.cur_ep_idx; @@ -572,22 +951,35 @@ static void play_task(void* arg){ // Skip ID3v2 if present uint8_t id3hdr[10]; + long id3_skip=0; if(fread(id3hdr,1,10,file)==10 && memcmp(id3hdr,"ID3",3)==0){ int id3size = (id3hdr[6]&0x7F)<<21 | (id3hdr[7]&0x7F)<<14 | (id3hdr[8]&0x7F)<<7 | (id3hdr[9]&0x7F); ESP_LOGI(TAG,"ID3 found %d, skip",id3size); - fseek(file, id3size+10, SEEK_SET); - fsize -= id3size+10; + id3_skip=id3size+10; + fseek(file, id3_skip, SEEK_SET); + fsize -= id3_skip; if(fsize>0) G.total_sec=(int)(fsize/16000); } else { fseek(file,0,SEEK_SET); } + // If resuming, seek roughly to position (using byte estimate) + int resume_sec = G.pos_sec; + if(resume_sec>5){ + long seek_bytes = (long)resume_sec * 16000; + // Ensure we don't seek beyond file and account for ID3 skip already skipped + if(seek_bytes < fsize){ + fseek(file, id3_skip + seek_bytes, SEEK_SET); + ESP_LOGI(TAG,"Resuming %s from %d sec (~%ld bytes)", ep->slug, resume_sec, seek_bytes); + } + } + mp3dec_t* dec = heap_caps_malloc(sizeof(mp3dec_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if(!dec){ ESP_LOGE(TAG,"dec malloc fail"); fclose(file); G.play_handle=NULL; vTaskDelete(NULL); return; } mp3dec_init(dec); uint8_t* inbuf=malloc(MP3_BUF); int16_t* pcm=malloc(1152*2*2); - if(!inbuf||!pcm){ if(inbuf) free(inbuf); if(pcm) free(pcm); fclose(file); G.play_handle=NULL; vTaskDelete(NULL); return; } + if(!inbuf||!pcm){ if(inbuf) free(inbuf); if(pcm) free(pcm); fclose(file); heap_caps_free(dec); G.play_handle=NULL; vTaskDelete(NULL); return; } size_t buffered=0; mp3dec_frame_info_t info; memset(&info,0,sizeof(info)); @@ -595,15 +987,29 @@ static void play_task(void* arg){ int sr=0, ch=0; size_t r=fread(inbuf,1,MP3_BUF,file); buffered=r; - ESP_LOGI(TAG,"play task start %s size %ld",path,fsize); - long total_decoded_samples=0; + ESP_LOGI(TAG,"play task start %s size %ld resume=%d",path,fsize,resume_sec); + long total_decoded_samples = 0; + if(resume_sec>5){ + // We'll seed decoded samples so pos_sec stays accurate until first frame updates it + // Exact hz not known yet, assume 16000 for now, will be corrected after first frame + total_decoded_samples = (long)resume_sec * 16000; + } while(!G.need_stop){ - if(G.is_paused){ vTaskDelay(pdMS_TO_TICKS(100)); continue; } + if(G.is_paused){ + if(configured){ + close_stream(); + configured=false; + sr=0; ch=0; + } + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } int samples=mp3dec_decode_frame(dec,inbuf,(int)buffered,pcm,&info); if(samples>0){ total_decoded_samples += samples; if(info.hz>0) G.pos_sec = total_decoded_samples / info.hz; + else G.pos_sec = total_decoded_samples / 16000; if(!configured || info.hz!=sr || info.channels!=ch){ ESP_LOGI(TAG,"open_stream sr=%d ch=%d",info.hz,info.channels); if(!open_stream((uint32_t)info.hz,(uint8_t)info.channels)){ @@ -613,12 +1019,10 @@ static void play_task(void* arg){ ESP_LOGI(TAG,"open_stream ok"); sr=info.hz; ch=info.channels; configured=true; - if(G.pos_sec>5){ - long seek=(long)G.pos_sec*16000; - if(seek5 && total_decoded_samples < (long)resume_sec * info.hz + info.hz){ + total_decoded_samples = (long)resume_sec * info.hz; + G.pos_sec = resume_sec; } } float vol=G.volume/100.0f; @@ -635,7 +1039,14 @@ static void play_task(void* arg){ size_t to_write = total_samples * sizeof(int16_t); size_t off=0; while(off0 && info.frame_bytes <= (int)buffered){ memmove(inbuf,inbuf+info.frame_bytes,buffered-info.frame_bytes); @@ -664,7 +1076,20 @@ static void play_task(void* arg){ free(pcm); heap_caps_free(dec); close_stream(); - ESP_LOGI(TAG,"play task end"); + bool finished_naturally = !G.need_stop; + if(finished_naturally){ + // If finished naturally, clear saved position (episode completed) + ESP_LOGI(TAG,"playback finished naturally for %s, clearing saved pos", ep->slug); + set_saved_pos_for_slug(ep->slug, 0); + G.pos_sec=0; + // Persist cleared position + save_state(); + } else { + // Save current position before exit (paused/stopped externally) + set_saved_pos_for_slug(ep->slug, G.pos_sec); + save_state(); + } + ESP_LOGI(TAG,"play task end finished_naturally=%d", finished_naturally); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); @@ -672,12 +1097,32 @@ static void play_task(void* arg){ static void start_idx_internal(int idx){ if(idx<0||idx>=G.ep_cnt) return; - if(G.cur_ep_idx!=idx){ G.pos_sec=0; G.last_pct=-1; } EpInfo* ep=&G.eps[idx]; + // If switching to a different episode, try to restore saved position + if(G.cur_ep_idx!=idx){ + int saved = get_saved_pos_for_slug(ep->slug); + if(saved>5){ + G.pos_sec=saved; + ESP_LOGI(TAG,"Restoring saved pos %d for %s", saved, ep->slug); + } else { + // Keep existing G.pos_sec if it was already set by select_ep, otherwise 0 + // select_ep will have set it; if not, ensure 0 + if(G.cur_ep_idx!=idx && get_saved_pos_for_slug(ep->slug)==0){ + // If no saved pos and we are switching, check if pos_sec belongs to previous ep + // In that case select_ep would have set it; but to be safe, if G.pos_sec is from previous ep and not from target, reset unless target has saved + // Actually select_ep loads saved pos, so we should not override here if select_ep already did + // Only reset if G.pos_sec is still from previous and no saved + // Simplest: if saved==0, keep pos as 0 unless it was already set for this slug + // We'll trust select_ep's value; if it's still old value from previous ep, it would have been overwritten in select_ep + } + } + G.last_pct=-1; + } tt_lvgl_lock(portMAX_DELAY); wait_play_exit(); G.cur_ep_idx=idx; G.cur_season=ep->season; strncpy(G.cur_title,ep->title,sizeof(G.cur_title)-1); + strncpy(G.last_slug,ep->slug,sizeof(G.last_slug)-1); G.need_stop=false; G.is_paused=false; G.is_playing=true; save_state(); tt_lvgl_unlock(); @@ -687,14 +1132,20 @@ static void start_idx_internal(int idx){ static 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){ - lv_label_set_text(G.lbl_status, ok?"DL done, playing...":"DL failed"); + 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; @@ -709,11 +1160,14 @@ static void start_idx(int idx){ if(idx<0||idx>=G.ep_cnt) return; EpInfo* ep=&G.eps[idx]; if(!has_slug(ep->slug)){ + stop_playback_sync(); tt_lvgl_lock(portMAX_DELAY); - if(G.lbl_status) lv_label_set_text(G.lbl_status,"Downloading..."); + // Show download overlay with progress UI + show_dl_overlay_locked(ep->title, ep->slug, ep->season, ep->ep_num); + if(G.lbl_status) lv_label_set_text(G.lbl_status,"Downloading…"); tt_lvgl_unlock(); if(G.dl_handle){ return; } - xTaskCreate(dl_task,"dm_dl",12288,(void*)(intptr_t)idx,5,&G.dl_handle); + xTaskCreate(dl_task,"dm_dl",16384,(void*)(intptr_t)idx,5,&G.dl_handle); return; } start_idx_internal(idx); @@ -721,28 +1175,78 @@ static void start_idx(int idx){ static void select_ep(int idx){ if(idx<0||idx>=G.ep_cnt) return; + // Save current episode position before switching (if different) + if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt && G.cur_ep_idx!=idx){ + save_current_ep_pos(); + } G.cur_ep_idx=idx; EpInfo* ep=&G.eps[idx]; G.cur_season=ep->season; strncpy(G.last_slug, ep->slug, sizeof(G.last_slug)-1); + + // Restore saved position for this episode, if any + int saved = get_saved_pos_for_slug(ep->slug); + if(saved>5){ + G.pos_sec=saved; + ESP_LOGI(TAG,"select_ep restore %s pos=%d", ep->slug, saved); + } else { + // If no saved pos, start from 0 (or keep if it's same episode and we already have pos) + // To avoid losing current playback pos when select_ep called for same idx (e.g., after pause), only reset if idx changed previously handled + // Here we are switching, so reset to saved (which is 0) unless we want to keep + if(find_saved_pos_idx(ep->slug)<0){ + // No entry, check if we are re-selecting same episode after stop with tiny pos – keep 0 + // If G.pos_sec was from previous episode, it would be wrong, so reset + // But if we are called from go_next after save_current, G.pos_sec still holds old ep pos, so must overwrite + G.pos_sec=saved; + } else { + G.pos_sec=saved; + } + } + G.last_pct=-1; + G.total_sec=0; // will be recalculated on play start from file size + tt_lvgl_lock(portMAX_DELAY); if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title); if(G.lbl_meta){ - char b[48]; - snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":""); + char b[64]; + if(saved>5){ + char tbuf[16]; + fmt_time(tbuf,saved); + snprintf(b,sizeof(b),"S%02d E%02d %s • %s",ep->season,ep->ep_num,ep->seen?"*":"",tbuf); + } else { + snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":""); + } lv_label_set_text(G.lbl_meta,b); } - if(G.lbl_status) lv_label_set_text(G.lbl_status,ep->seen?"On SD":"Tap Play to DL"); - if(G.bar) lv_bar_set_value(G.bar,0,LV_ANIM_OFF); - if(G.lbl_time) lv_label_set_text(G.lbl_time,"0:00 / --:--"); + if(G.lbl_status){ + if(saved>5) lv_label_set_text(G.lbl_status,"Resume – tap Play"); + else lv_label_set_text(G.lbl_status,ep->seen?"On SD – tap Play":"Tap Play to download"); + } + if(G.bar){ + // If we have saved pos and total estimate, show progress; else 0 + lv_bar_set_value(G.bar,0,LV_ANIM_OFF); + } + if(G.lbl_time){ + if(saved>5){ + char a[16]; + fmt_time(a,saved); + char buf[40]; + snprintf(buf,sizeof(buf),"%s / --:--",a); + lv_label_set_text(G.lbl_time,buf); + } else { + lv_label_set_text(G.lbl_time,"0:00 / --:--"); + } + } tt_lvgl_unlock(); - G.pos_sec=0; - G.last_pct=-1; save_state(); } static void go_next(){ if(G.ep_cnt==0) return; + // Save current before leaving + save_current_ep_pos(); + save_state(); + stop_playback_sync(); int n=G.cur_ep_idx+1; if(n>=G.ep_cnt) n=0; select_ep(n); @@ -750,16 +1254,38 @@ static void go_next(){ } static void go_prev(){ if(G.ep_cnt==0) return; - if(G.pos_sec>5){ G.pos_sec=0; return; } + if(G.pos_sec>5){ + // Restart current episode + ESP_LOGI(TAG,"go_prev restart current %d sec",G.pos_sec); + // Clear saved pos for current to restart from beginning + if(G.cur_ep_idx>=0){ + set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, 0); + save_state(); + } + stop_playback_sync(); + G.pos_sec=0; + int cur=G.cur_ep_idx; + if(cur<0) cur=0; + // select_ep will see 0 saved and show 0 + select_ep(cur); + start_idx(cur); + return; + } + save_current_ep_pos(); + save_state(); + stop_playback_sync(); int p=G.cur_ep_idx-1; if(p<0) p=G.ep_cnt-1; select_ep(p); start_idx(p); } -static void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); } static void ui_timer(lv_timer_t* t){ (void)t; + // Download overlay progress + if(G.downloading){ + update_dl_overlay_ui(); + } if(G.bar){ if(G.total_sec>0){ int pct=G.pos_sec*100/G.total_sec; @@ -785,16 +1311,32 @@ static void ui_timer(lv_timer_t* t){ snprintf(buf,sizeof(buf),"%s / %s",a,b); lv_label_set_text(G.lbl_time,buf); } - if(G.is_playing && G.lbl_status){ + if(G.is_playing && G.lbl_status && !G.downloading){ const char* cur = lv_label_get_text(G.lbl_status); if(!cur || strcmp(cur,"Playing")!=0) lv_label_set_text(G.lbl_status,"Playing"); } + // Periodic save every ~5 sec while playing (ui_timer called every 200ms, so 25 ticks = 5 sec) + if(G.is_playing && !G.is_paused && !G.downloading){ + G.last_save_tick++; + if(G.last_save_tick >= 25){ + G.last_save_tick=0; + save_current_ep_pos(); + save_state(); + } + } } static void btn_play_cb(lv_event_t* e){ (void)e; + if(G.downloading){ + // If downloading, ignore play or show cancel? Currently ignore. + return; + } if(G.is_playing && !G.is_paused){ G.is_paused=true; + // Save position on pause + save_current_ep_pos(); + save_state(); tt_lvgl_lock(portMAX_DELAY); if(G.lbl_status) lv_label_set_text(G.lbl_status,"Paused"); if(G.btn_play){ @@ -820,8 +1362,8 @@ static void btn_play_cb(lv_event_t* e){ tt_lvgl_unlock(); } } -static void btn_prev_cb(lv_event_t* e){ (void)e; go_prev(); } -static void btn_next_cb(lv_event_t* e){ (void)e; go_next(); } +static void btn_prev_cb(lv_event_t* e){ (void)e; if(G.downloading) return; go_prev(); } +static void btn_next_cb(lv_event_t* e){ (void)e; if(G.downloading) return; go_next(); } static void close_overlay(){ if(G.overlay){ @@ -852,6 +1394,7 @@ static void dropdown_cb(lv_event_t* e){ static void btn_seasons_cb(lv_event_t* e){ (void)e; + if(G.downloading) return; if(G.overlay){ close_overlay(); return; } tt_lvgl_lock(portMAX_DELAY); G.overlay=lv_obj_create(lv_scr_act()); @@ -971,7 +1514,7 @@ static void build_ui(){ lv_obj_center(ls); lv_obj_add_event_cb(bs,btn_seasons_cb,LV_EVENT_CLICKED,NULL); - lv_timer_create(ui_timer,500,NULL); + lv_timer_create(ui_timer,200,NULL); tt_lvgl_unlock(); } @@ -994,14 +1537,38 @@ static void fetch_task_fn(void* arg){ tt_lvgl_lock(portMAX_DELAY); if(G.ep_cnt>0 && G.cur_ep_idx>=0){ EpInfo* ep=&G.eps[G.cur_ep_idx]; + int saved = get_saved_pos_for_slug(ep->slug); + if(saved==0) saved = G.pos_sec; // fallback to legacy global pos if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title); if(G.lbl_meta){ - char b[48]; snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":""); + char b[64]; + if(saved>5){ + char tbuf[16]; + fmt_time(tbuf,saved); + snprintf(b,sizeof(b),"S%02d E%02d %s • %s",ep->season,ep->ep_num,ep->seen?"*":"",tbuf); + } else { + snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":""); + } lv_label_set_text(G.lbl_meta,b); } - if(G.lbl_status) lv_label_set_text(G.lbl_status,ep->seen?"On SD":"Tap Play to DL"); + if(G.lbl_status){ + if(saved>5) lv_label_set_text(G.lbl_status,"Resume – tap Play"); + else lv_label_set_text(G.lbl_status,ep->seen?"On SD – tap Play":"Tap Play to DL"); + } if(G.bar) lv_bar_set_value(G.bar,0,LV_ANIM_OFF); - if(G.lbl_time) lv_label_set_text(G.lbl_time,"0:00 / --:--"); + if(G.lbl_time){ + if(saved>5){ + char a[16]; + fmt_time(a,saved); + char buf[40]; + snprintf(buf,sizeof(buf),"%s / --:--",a); + lv_label_set_text(G.lbl_time,buf); + } else { + lv_label_set_text(G.lbl_time,"0:00 / --:--"); + } + } + // Ensure G.pos_sec reflects saved resume + if(saved>0) G.pos_sec=saved; } else { if(G.lbl_status) lv_label_set_text(G.lbl_status,"No data"); } @@ -1022,6 +1589,8 @@ static void onShow(AppHandle app, void* data, lv_obj_t* parent){ G.cur_ep_idx=-1; G.volume=80; G.last_pct=-1; + G.dl_last_pct=-1; + G.dl_expected=-1; ensure_dir(); load_state(); build_ui(); @@ -1030,6 +1599,8 @@ static void onShow(AppHandle app, void* data, lv_obj_t* parent){ static void onHide(AppHandle app, void* data){ (void)app; (void)data; + // Save current position before shutting down playback + save_current_ep_pos(); tt_lvgl_lock(portMAX_DELAY); if(G.fetch_handle){ vTaskDelete(G.fetch_handle); @@ -1037,12 +1608,19 @@ static void onHide(AppHandle app, void* data){ G.fetching=false; } if(G.dl_handle){ - vTaskDelete(G.dl_handle); - G.dl_handle=NULL; - G.downloading=false; + // Request cancel, then force delete if still alive + G.dl_cancel_req=true; + vTaskDelay(pdMS_TO_TICKS(100)); + if(G.dl_handle){ + vTaskDelete(G.dl_handle); + G.dl_handle=NULL; + G.downloading=false; + if(G.dl_overlay) hide_dl_overlay(); + } } wait_play_exit(); close_overlay(); + if(G.dl_overlay) hide_dl_overlay(); tt_lvgl_unlock(); save_state(); }