#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 "esp_http_client.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* i2s_dev; bool is_playing; bool is_paused; bool need_stop; TaskHandle_t play_handle; int pos_sec; int total_sec; int volume; lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play; lv_obj_t *overlay, *roller; char cur_title[128]; } G; static const char* sd_path(const char* slug){ static char p[256]; snprintf(p,sizeof(p),"%s/%s.mp3",DM_DIR,slug); return p; } static void ensure_dir(){ mkdir("/sdcard",0777); 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){ struct stat st; return stat(sd_path(slug),&st)==0; } static void save_state(){ cJSON* r=cJSON_CreateObject(); if(G.ep_cnt>0 && G.cur_ep_idx>=0) cJSON_AddStringToObject(r,"last_slug",G.eps[G.cur_ep_idx].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; 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); fread(buf,1,sz,f); buf[sz]=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)){ for(int i=0;ivaluestring)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; } } cJSON_Delete(r); } static int cmp_season(const void*a,const void*b){ return ((SeasonInfo*)a)->num - ((SeasonInfo*)b)->num; } static int cmp_ep(const void*a,const void*b){ EpInfo* ea=(EpInfo*)a; EpInfo* eb=(EpInfo*)b; if(ea->season!=eb->season) return ea->season-eb->season; return ea->ep_num-eb->ep_num; } static int http_get(const char* url, char** out){ esp_http_client_config_t cfg={ .url=url, .timeout_ms=8000 }; esp_http_client_handle_t c=esp_http_client_init(&cfg); if(!c) return -1; esp_http_client_set_method(c,HTTP_METHOD_GET); if(esp_http_client_perform(c)!=ESP_OK){ esp_http_client_cleanup(c); return -1; } if(esp_http_client_get_status_code(c)!=200){ esp_http_client_cleanup(c); return -1; } int len=esp_http_client_get_content_length(c); if(len<=0) len=65536; char* buf=malloc(len+1); int rlen=esp_http_client_read_response(c,buf,len); buf[rlen]=0; esp_http_client_cleanup(c); *out=buf; return rlen; } static void fetch_data(){ char* js=NULL; char url[256]; snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num",PB); if(http_get(url,&js)>0){ 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++; } } qsort(G.seasons,G.season_cnt,sizeof(SeasonInfo),cmp_season); } cJSON_Delete(root); } } if(G.season_cnt==0){ for(int i=1;i<=37;i++) G.seasons[i-1].num=i; G.season_cnt=37; } snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=400&sort=season_num,episode_num",PB); if(http_get(url,&js)>0){ 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++; } qsort(G.eps,G.ep_cnt,sizeof(EpInfo),cmp_ep); } cJSON_Delete(root); } } if(G.ep_cnt==0){ 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;id_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(G.cur_ep_idx>=0){ const char* curp=sd_path(G.eps[G.cur_ep_idx].slug); if(strcmp(full,curp)==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;islug)) return true; ensure_dir(); lru_check(); if(strlen(ep->audio_url)==0) return false; char tmp[300]; snprintf(tmp,sizeof(tmp),"%s.tmp",sd_path(ep->slug)); FILE* f=fopen(tmp,"wb"); if(!f) return false; esp_http_client_config_t cfg={ .url=ep->audio_url, .timeout_ms=30000 }; esp_http_client_handle_t c=esp_http_client_init(&cfg); if(!c){ fclose(f); return false; } if(esp_http_client_perform(c)!=ESP_OK){ esp_http_client_cleanup(c); fclose(f); unlink(tmp); return false; } if(esp_http_client_get_status_code(c)!=200){ esp_http_client_cleanup(c); fclose(f); unlink(tmp); return false; } char buf[2048]; int total=0; while(1){ int r=esp_http_client_read(c,buf,sizeof(buf)); if(r<=0) break; fwrite(buf,1,r,f); total+=r; if(G.lbl_status){ char m[32]; snprintf(m,sizeof(m),"DL %dKB",total/1024); lv_label_set_text(G.lbl_status,m);} } esp_http_client_cleanup(c); fclose(f); if(total<1024){ unlink(tmp); return false; } rename(tmp,sd_path(ep->slug)); ep->seen=true; return true; } static void stop_play(){ if(G.play_handle){ G.need_stop=true; vTaskDelay(pdMS_TO_TICKS(100)); if(G.play_handle){ vTaskDelete(G.play_handle); G.play_handle=NULL; } } if(G.i2s_dev){ device_lock(G.i2s_dev); i2s_controller_reset(G.i2s_dev); device_unlock(G.i2s_dev); G.i2s_dev=NULL; } 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]; FILE* file=fopen(sd_path(ep->slug),"rb"); if(!file){ 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); G.i2s_dev=device_find_by_name("i2s0"); if(!G.i2s_dev){ fclose(file); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; } if(fsize>0) G.total_sec=(int)(fsize/16000); mp3dec_t dec; mp3dec_init(&dec); uint8_t* inbuf=malloc(MP3_BUF); int16_t* pcm=malloc(1152*2*2); size_t buffered=0; mp3dec_frame_info_t info; memset(&info,0,sizeof(info)); bool configured=false; size_t r=fread(inbuf,1,MP3_BUF,file); buffered=r; 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){ struct I2sConfig cfg={ .communication_format=I2S_FORMAT_STAND_I2S, .sample_rate=(uint32_t)info.hz, .bits_per_sample=16, .channel_left=0, .channel_right=(info.channels==2)?1:I2S_CHANNEL_NONE }; device_lock(G.i2s_dev); i2s_controller_set_config(G.i2s_dev,&cfg); device_unlock(G.i2s_dev); if(G.pos_sec>5){ long seek=(long)G.pos_sec*16000; if(seek1) vol=1; for(int i=0;i32767) s=32767; if(s<-32768) s=-32768; pcm[i]=(int16_t)s; } size_t to_write=samples*2*2; 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); if(G.i2s_dev){ device_lock(G.i2s_dev); i2s_controller_reset(G.i2s_dev); device_unlock(G.i2s_dev); G.i2s_dev=NULL; } G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); } static void start_idx(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]; if(!has_slug(ep->slug)){ if(G.lbl_status) lv_label_set_text(G.lbl_status,"Downloading..."); if(!dl_ep(ep)){ if(G.lbl_status) lv_label_set_text(G.lbl_status,"DL failed"); return; } } stop_play(); 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(); xTaskCreate(play_task,"dm_play",6144,NULL,5,&G.play_handle); } 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; 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 / --:--"); 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; lv_bar_set_value(G.bar,pct,LV_ANIM_OFF); } else if(G.is_playing){ lv_bar_set_value(G.bar,(G.pos_sec%10)*10,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) 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; 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);} } else if(G.is_paused){ G.is_paused=false; 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);} } else { start_idx(G.cur_ep_idx); 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);} } } 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_del(G.overlay); G.overlay=NULL; G.roller=NULL; } } static void roller_cb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_VALUE_CHANGED) return; int sel=lv_roller_get_selected(G.roller); 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 && G.cur_ep_idx>=0) select_ep(G.cur_ep_idx); } static void onHide(AppHandle app, void* data){ (void)app; (void)data; stop_play(); save_state(); } int main(int argc, char* argv[]){ (void)argc; (void)argv; tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide }); return 0; }