#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_log.h" #include "cJSON.h" #define MINIMP3_IMPLEMENTATION #define MINIMP3_NO_SIMD #include "minimp3.h" #define TAG "DM" #define PB "http://192.168.68.110:8095" #define DM_DIR "/sdcard/dm" #define STATE_PATH "/sdcard/apps/one.tactility.discoverymountain/userdata/state.json" #define MAX_SEASONS 40 #define MAX_EPS 400 #define MP3_BUF 16384 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; EpInfo eps[MAX_EPS]; int ep_cnt; int cur_season; int cur_ep_idx; struct Device* stream_dev; AudioStreamHandle stream_handle; bool is_playing; bool is_paused; bool need_stop; TaskHandle_t play_handle; TaskHandle_t fetch_handle; TaskHandle_t dl_handle; int pos_sec; int total_sec; int volume; 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){ if(!out || !slug) return; snprintf(out, out_len, "%s/%s.mp3", DM_DIR, slug); } static void ensure_dir(){ mkdir("/sdcard/apps",0777); mkdir("/sdcard/apps/one.tactility.discoverymountain",0777); mkdir("/sdcard/apps/one.tactility.discoverymountain/userdata",0777); mkdir(DM_DIR,0777); } static bool has_slug(const char* slug){ char p[300]; make_sd_path(p, sizeof(p), slug); struct stat st; 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"); if(f){ fwrite(s,1,strlen(s),f); fclose(f);} free(s); } cJSON_Delete(r); } 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>16384){ fclose(f); return;} char* buf=malloc(sz+1); if(!buf){ fclose(f); return; } size_t got=fread(buf,1,sz,f); buf[got]=0; fclose(f); cJSON* r=cJSON_Parse(buf); free(buf); if(!r) return; cJSON* ls=cJSON_GetObjectItem(r,"last_slug"); cJSON* lsn=cJSON_GetObjectItem(r,"last_season"); cJSON* ps=cJSON_GetObjectItem(r,"pos_sec"); if(ps&&cJSON_IsNumber(ps)) G.pos_sec=ps->valueint; if(lsn&&cJSON_IsNumber(lsn)) G.cur_season=lsn->valueint; if(ls&&cJSON_IsString(ls)){ 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;iseason != b->season) should_swap = (b->season < a->season); else should_swap = (b->ep_num < a->ep_num); if(should_swap){ EpInfo t=*a; *a=*b; *b=t; } } } } // ── Host resolution helper ── static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>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 %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; } char hostport[128]={0}; size_t hlen=slash-p; if(hlen>=sizeof(hostport)) return -1; 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); } ESP_LOGI(TAG,"GET %s host=%s port=%d",url,host,port); 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)); sa.sin_family=AF_INET; sa.sin_port=my_htons(port); sa.sin_addr.s_addr=ip; struct timeval tv={8,0}; lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv)); lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv)); if(lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa))<0){ ESP_LOGE(TAG,"connect fail %s:%d",host,port); close(fd); return -1; } 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,"send fail"); close(fd); return -1; } int capacity=65536; char* buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); 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(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; buf[total]=0; if(!header_end){ header_end=strstr(buf,"\r\n\r\n"); if(header_end){ body_start=(header_end-buf)+4; if(strstr(buf,"chunked")) is_chunked=true; char* cl=strstr(buf,"Content-Length:"); if(!cl) cl=strstr(buf,"content-length:"); if(cl){ cl=strchr(cl,':'); if(cl){ cl++; while(*cl==' ') cl++; content_len=atoi(cl); } } ESP_LOGI(TAG,"header_end chunked=%d cl=%d total=%d",is_chunked,content_len,total); if(content_len>=0 && !is_chunked){ if(total-body_start >= content_len) break; } } } else { if(content_len>=0 && !is_chunked){ if(total-body_start >= content_len) break; } } } close(fd); if(!header_end){ ESP_LOGE(TAG,"no header end total=%d",total); heap_caps_free(buf); return -1; } if(strncmp(buf,"HTTP/1.1 200",12)!=0 && strncmp(buf,"HTTP/1.0 200",12)!=0){ ESP_LOGE(TAG,"not 200: %.60s",buf); heap_caps_free(buf); return -1; } int raw_len=total-body_start; char* raw=buf+body_start; char* final_body=NULL; int final_len=0; if(is_chunked){ final_body=heap_caps_malloc(200000, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if(!final_body){ heap_caps_free(buf); return -1; } int pos=0, out=0; while(pos16){ pos+=off+2; continue; } char hex[16]={0}; memcpy(hex,raw+pos,off); char* semi=strchr(hex,';'); if(semi) *semi=0; long sz=strtol(hex,NULL,16); if(sz==0) break; pos+=off+2; if(pos+sz>raw_len) sz=raw_len-pos; if(out+sz>=200000) break; memcpy(final_body+out,raw+pos,sz); out+=sz; pos+=sz; if(pos+1=0 && bl>content_len) bl=content_len; final_body=heap_caps_malloc(bl+1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if(!final_body){ heap_caps_free(buf); return -1; } memcpy(final_body,raw,bl); final_body[bl]=0; final_len=bl; } heap_caps_free(buf); *out_body=final_body; ESP_LOGI(TAG,"GET ok %d",final_len); return final_len; } static void fetch_data(){ char* js=NULL; char url[256]; ESP_LOGI(TAG,"fetch seasons"); snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num",PB); int len=http_get_raw(url,&js); if(len>0 && js){ cJSON* root=cJSON_Parse(js); free(js); js=NULL; if(root){ cJSON* items=cJSON_GetObjectItem(root,"items"); if(cJSON_IsArray(items)){ G.season_cnt=0; int n=cJSON_GetArraySize(items); for(int i=0;ivalueint; G.season_cnt++; } } sort_seasons(); } cJSON_Delete(root); } } else { ESP_LOGW(TAG,"seasons fetch failed"); } if(G.season_cnt==0){ ESP_LOGW(TAG,"seasons fallback"); for(int i=1;i<=37;i++) G.seasons[i-1].num=i; G.season_cnt=37; } ESP_LOGI(TAG,"fetch episodes"); snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=400&sort=season_num,episode_num",PB); len=http_get_raw(url,&js); if(len>0 && js){ cJSON* root=cJSON_Parse(js); free(js); js=NULL; if(root){ cJSON* items=cJSON_GetObjectItem(root,"items"); if(cJSON_IsArray(items)){ G.ep_cnt=0; int n=cJSON_GetArraySize(items); for(int i=0;ititle,t->valuestring,sizeof(e->title)-1); if(slug&&cJSON_IsString(slug)) strncpy(e->slug,slug->valuestring,sizeof(e->slug)-1); else if(t) strncpy(e->slug,t->valuestring,sizeof(e->slug)-1); e->season=sn->valueint; e->ep_num=en->valueint; if(au&&cJSON_IsString(au)) strncpy(e->audio_url,au->valuestring,sizeof(e->audio_url)-1); e->seen=has_slug(e->slug); G.ep_cnt++; } sort_eps(); } cJSON_Delete(root); } } else { ESP_LOGW(TAG,"episodes fetch failed"); } if(G.ep_cnt==0){ ESP_LOGW(TAG,"episodes fallback"); for(int i=0;i<6;i++){ EpInfo* e=&G.eps[i]; snprintf(e->slug,sizeof(e->slug),"S01E%02d",i+1); snprintf(e->title,sizeof(e->title),"Episode %d",i+1); e->season=1; e->ep_num=i+1; } G.ep_cnt=6; } for(int i=0;i=0 && G.cur_ep_idxd_name[0]=='.') continue; size_t l=strlen(de->d_name); if(l<5) continue; if(strcmp(de->d_name+l-4,".mp3")!=0) continue; char full[300]; snprintf(full,sizeof(full),"%s/%.200s",DM_DIR,de->d_name); struct stat st; if(stat(full,&st)!=0) continue; if(cur_path[0] && strcmp(full,cur_path)==0) continue; strncpy(files[fc].path,full,sizeof(files[fc].path)-1); files[fc].mt=st.st_mtime; fc++; } closedir(d); if(fc<12) return; for(int i=0;iaudio_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); // 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; } 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"); // header buffer in PSRAM 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; // 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 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; } } // 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,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; G.dl_total=total; // No LVGL lock here – UI timer will poll G.dl_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){ // 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; } ep->seen=true; return true; } static 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); } static bool find_audio_device(){ struct Device* dev=device_find_by_name("audio-stream"); if(dev){ G.stream_dev=dev; return true; } dev=device_find_first_by_type(&AUDIO_STREAM_TYPE); if(dev){ G.stream_dev=dev; return true; } return false; } static void close_stream(){ if(G.stream_handle){ audio_stream_close(G.stream_handle); G.stream_handle=NULL; } } static bool open_stream(uint32_t sr, uint8_t ch){ close_stream(); if(!G.stream_dev && !find_audio_device()) return false; struct AudioStreamConfig cfg={ .sample_rate=sr, .bits_per_sample=16, .channels=ch }; error_t err=audio_stream_open_output(G.stream_dev,&cfg,&G.stream_handle); return err==ERROR_NONE; } static void wait_play_exit(){ if(G.play_handle){ G.need_stop=true; while(G.play_handle!=NULL){ tt_lvgl_unlock(); vTaskDelay(pdMS_TO_TICKS(10)); tt_lvgl_lock(portMAX_DELAY); } } 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; if(idx<0||idx>=G.ep_cnt){ G.play_handle=NULL; vTaskDelete(NULL); return; } EpInfo* ep=&G.eps[idx]; char path[300]; make_sd_path(path,sizeof(path),ep->slug); FILE* file=fopen(path,"rb"); if(!file){ ESP_LOGE(TAG,"open fail %s",path); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; } fseek(file,0,SEEK_END); long fsize=ftell(file); fseek(file,0,SEEK_SET); if(fsize>0) G.total_sec=(int)(fsize/16000); if(!find_audio_device()){ ESP_LOGE(TAG,"no audio-stream dev"); fclose(file); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; } // 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); 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); 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)); bool configured=false; 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 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){ 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)){ ESP_LOGE(TAG,"open_stream fail %d %d",info.hz,info.channels); break; } ESP_LOGI(TAG,"open_stream ok"); sr=info.hz; ch=info.channels; configured=true; // If we resumed, correct total_decoded_samples to match actual resume_sec * actual hz if(resume_sec>5 && 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; if(vol<0) vol=0; if(vol>1) vol=1; int total_samples = samples * (info.channels>0?info.channels:1); for(int i=0;i32767) s=32767; if(s<-32768) s=-32768; pcm[i]=(int16_t)s; } 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); buffered-=info.frame_bytes; } else { buffered=0; } if(buffered < MP3_BUF/2){ size_t nr=fread(inbuf+buffered,1,MP3_BUF-buffered,file); if(nr>0) buffered+=nr; else { if(samples==0) break; } } if(buffered==0) break; } fclose(file); free(inbuf); free(pcm); heap_caps_free(dec); close_stream(); 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); } static void start_idx_internal(int idx){ if(idx<0||idx>=G.ep_cnt) return; 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(); xTaskCreate(play_task,"dm_play",16384,NULL,5,&G.play_handle); } 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){ 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); } 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); // 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",16384,(void*)(intptr_t)idx,5,&G.dl_handle); return; } start_idx_internal(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[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){ 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(); 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); start_idx(n); } static void go_prev(){ if(G.ep_cnt==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 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; if(pct>100) pct=100; if(pct!=G.last_pct){ G.last_pct=pct; lv_bar_set_value(G.bar,pct,LV_ANIM_OFF); } } else if(G.is_playing){ int pct=(G.pos_sec%10)*10; if(pct!=G.last_pct){ G.last_pct=pct; lv_bar_set_value(G.bar,pct,LV_ANIM_OFF); } } } if(G.lbl_time){ char a[16],b[16]; fmt_time(a,G.pos_sec); if(G.total_sec>0) fmt_time(b,G.total_sec); else snprintf(b,sizeof(b),"--:--"); char buf[40]; snprintf(buf,sizeof(buf),"%s / %s",a,b); lv_label_set_text(G.lbl_time,buf); } 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){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PLAY); } tt_lvgl_unlock(); } else if(G.is_paused){ G.is_paused=false; tt_lvgl_lock(portMAX_DELAY); if(G.btn_play){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE); } tt_lvgl_unlock(); } else { start_idx(G.cur_ep_idx); tt_lvgl_lock(portMAX_DELAY); if(G.btn_play){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE); } tt_lvgl_unlock(); } } 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){ lv_obj_delete(G.overlay); G.overlay=NULL; G.dropdown=NULL; } } static void dropdown_cb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_VALUE_CHANGED) return; lv_obj_t* dd = (lv_obj_t*)lv_event_get_target(e); int sel=lv_dropdown_get_selected(dd); if(sel<0||sel>=G.season_cnt) return; int sn=G.seasons[sel].num; close_overlay(); int first=-1, first_unseen=-1; for(int i=0;i0){ for(int i=0;i0 && 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[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){ 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){ 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"); } tt_lvgl_unlock(); G.fetching=false; G.fetch_handle=NULL; save_state(); vTaskDelete(NULL); } static void onShow(AppHandle app, void* data, lv_obj_t* parent){ (void)app; (void)data; (void)parent; memset(&G,0,sizeof(G)); G.pos_sec=0; G.total_sec=0; G.cur_season=1; 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(); xTaskCreate(fetch_task_fn,"dm_fetch",16384,NULL,5,&G.fetch_handle); } 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); G.fetch_handle=NULL; G.fetching=false; } if(G.dl_handle){ // 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(); } int main(int argc, char* argv[]){ (void)argc; (void)argv; tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide }); return 0; }