#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; 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; char cur_title[128]; char last_slug[96]; bool fetching; bool downloading; } 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; } static void save_state(){ 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); 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; memset(G.last_slug,0,sizeof(G.last_slug)); 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;} 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_Delete(r); } 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; } } } } // Raw socket HTTP GET - avoids esp_http_client which was failing on device // Raw socket HTTP GET - PSRAM, handles chunked and content-length, HTTP/1.0 // Simplified HTTP GET - allocate 200KB PSRAM upfront, no realloc static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); } 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; } 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=ipaddr_addr(host); if(ip==0 || ip==0xFFFFFFFF){ 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=200000; char* buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if(!buf){ ESP_LOGE(TAG,"PSRAM 200k 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){ 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"); 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; } 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=ipaddr_addr(host); if(ip==0 || ip==0xFFFFFFFF){ 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); 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",host,port); 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); if(lwip_send(fd,req,reqlen,0)<0){ ESP_LOGE(TAG,"dl send fail"); 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(4096, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if(!recv_buf){ heap_caps_free(hdr); fclose(f); close(fd); unlink(tmp_path); return false; } // Read header while(!header_done){ 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(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: %.80s",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); 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; ESP_LOGI(TAG,"dl wrote initial %d",body_in_buf); } break; } } // Stream body ESP_LOGI(TAG,"dl streaming body cl=%d",content_len); while(1){ if(content_len>=0 && total>=content_len) break; int r=lwip_recv(fd,(char*)recv_buf,4096,0); if(r<=0) break; 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(); } } 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(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); } 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; 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 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; } mp3dec_t dec; 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; } 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",path,fsize); while(!G.need_stop){ if(G.is_paused){ vTaskDelay(pdMS_TO_TICKS(100)); continue; } int samples=mp3dec_decode_frame(&dec,inbuf,(int)buffered,pcm,&info); if(samples>0){ if(!configured || info.hz!=sr || info.channels!=ch){ if(!open_stream((uint32_t)info.hz,(uint8_t)info.channels)){ ESP_LOGE(TAG,"open_stream fail %d %d",info.hz,info.channels); break; } sr=info.hz; ch=info.channels; configured=true; if(G.pos_sec>5){ long seek=(long)G.pos_sec*16000; if(seek1) 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) G.pos_sec += samples / info.hz; } if(info.frame_bytes>0 && 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); close_stream(); ESP_LOGI(TAG,"play task end"); 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; if(G.cur_ep_idx!=idx) G.pos_sec=0; EpInfo* ep=&G.eps[idx]; 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); G.need_stop=false; G.is_paused=false; G.is_playing=true; save_state(); tt_lvgl_unlock(); xTaskCreate(play_task,"dm_play",12288,NULL,5,&G.play_handle); } static void dl_task(void* arg){ int idx = (int)(intptr_t)arg; G.downloading=true; 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); if(G.lbl_status){ lv_label_set_text(G.lbl_status, ok?"DL done, playing...":"DL failed"); } 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)){ tt_lvgl_lock(portMAX_DELAY); 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); return; } start_idx_internal(idx); } static void select_ep(int idx){ if(idx<0||idx>=G.ep_cnt) return; 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); 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?"*":""); 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 / --:--"); tt_lvgl_unlock(); G.pos_sec=0; save_state(); } static void go_next(){ if(G.ep_cnt==0) return; 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){ G.pos_sec=0; return; } 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; 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){ const char* cur = lv_label_get_text(G.lbl_status); if(!cur || strcmp(cur,"Playing")!=0) lv_label_set_text(G.lbl_status,"Playing"); } } static void btn_play_cb(lv_event_t* e){ (void)e; if(G.is_playing && !G.is_paused){ G.is_paused=true; 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; go_prev(); } static void btn_next_cb(lv_event_t* e){ (void)e; 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]; 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?"*":""); 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 / --:--"); } 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; 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; tt_lvgl_lock(portMAX_DELAY); if(G.fetch_handle){ vTaskDelete(G.fetch_handle); G.fetch_handle=NULL; G.fetching=false; } if(G.dl_handle){ vTaskDelete(G.dl_handle); G.dl_handle=NULL; G.downloading=false; } wait_play_exit(); close_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; }