3778cb3399
- DiscoveryMountain: new UI optimized for episode list (not player anymore) - Shows episodes for current season, indicates if on SD card (green=On SD, gray=Not DL) - Per-episode action: download single via SdDownloader or play via Mp3Player - DL Missing button for missing eps in season (currently single first missing, batch reverted) - Season selector screen with cached/total counts - Top-right X close button - Seasons/episodes cached to cache.json to avoid network fetch every launch - Fixes: overlay deletion race (deferred timer), ui_timer leak causing crash on close, season selection overwritten by fetch task - Fixes: unicode ellipsis squares - SdDownloader: revert to stable single-file mode (batch array caused crashes) - Remove batch fields and array allocation, keep single url/path download - Result bundle simple success/path/bytes - Mp3Player: add resume position support (pos_sec from bundle, total_sec estimate, total_decoded_samples), return position on exit via bundle for DM to save Tested on 192.168.68.133: opens, shows S10 2/6, Play/DL buttons, close works, no crash on open/close cycles, cache shows 'Loaded from cache, refreshing...'
322 lines
11 KiB
C
322 lines
11 KiB
C
#include "storage.h"
|
|
#include "cJSON.h"
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <stdio.h>
|
|
#include <dirent.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
#include "esp_heap_caps.h"
|
|
|
|
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);
|
|
}
|
|
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);
|
|
}
|
|
bool has_slug(const char* slug){
|
|
char p[300];
|
|
make_sd_path(p, sizeof(p), slug);
|
|
struct stat st;
|
|
return stat(p,&st)==0;
|
|
}
|
|
|
|
void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); }
|
|
|
|
void sort_seasons(){
|
|
for(int i=0;i<G.season_cnt;i++){
|
|
for(int j=i+1;j<G.season_cnt;j++){
|
|
if(G.seasons[j].num < G.seasons[i].num){
|
|
SeasonInfo t=G.seasons[i]; G.seasons[i]=G.seasons[j]; G.seasons[j]=t;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
void sort_eps(){
|
|
// Use PSRAM for temp to avoid stack overflow (EpInfo ~300 bytes, was on stack)
|
|
EpInfo *t = heap_caps_malloc(sizeof(EpInfo), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
|
if(!t) return;
|
|
for(int i=0;i<G.ep_cnt;i++){
|
|
for(int j=i+1;j<G.ep_cnt;j++){
|
|
EpInfo *a=&G.eps[i], *b=&G.eps[j];
|
|
bool should_swap=false;
|
|
if(a->season != b->season) should_swap = (b->season < a->season);
|
|
else should_swap = (b->ep_num < a->ep_num);
|
|
if(should_swap){
|
|
*t=*a; *a=*b; *b=*t;
|
|
}
|
|
}
|
|
}
|
|
heap_caps_free(t);
|
|
}
|
|
|
|
// ── Per-episode position helpers ──
|
|
int find_saved_pos_idx(const char* slug){
|
|
if(!slug) return -1;
|
|
for(int i=0;i<G.saved_pos_cnt;i++){
|
|
if(strcmp(G.saved_pos[i].slug, slug)==0) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
int get_saved_pos_for_slug(const char* slug){
|
|
int idx=find_saved_pos_idx(slug);
|
|
if(idx>=0) return G.saved_pos[idx].pos;
|
|
return 0;
|
|
}
|
|
void set_saved_pos_for_slug(const char* slug, int pos){
|
|
if(!slug || strlen(slug)==0) return;
|
|
if(pos<0) pos=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;
|
|
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++;
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
void save_state(){
|
|
if(G.cur_ep_idx>=0 && G.ep_cnt>0){
|
|
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);
|
|
if(G.saved_pos_cnt>0){
|
|
cJSON* pos_obj=cJSON_CreateObject();
|
|
for(int i=0;i<G.saved_pos_cnt;i++){
|
|
if(G.saved_pos[i].slug[0]==0) continue;
|
|
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);
|
|
}
|
|
|
|
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;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,ls->valuestring)==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++;
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
|
|
bool save_cache(void){
|
|
if(G.ep_cnt==0 || G.season_cnt==0) return false;
|
|
cJSON* root=cJSON_CreateObject();
|
|
if(!root) return false;
|
|
cJSON* seasons_arr=cJSON_CreateArray();
|
|
if(!seasons_arr){ cJSON_Delete(root); return false; }
|
|
for(int i=0;i<G.season_cnt && i<MAX_SEASONS;i++){
|
|
cJSON_AddItemToArray(seasons_arr, cJSON_CreateNumber(G.seasons[i].num));
|
|
}
|
|
cJSON_AddItemToObject(root,"seasons",seasons_arr);
|
|
|
|
cJSON* eps_arr=cJSON_CreateArray();
|
|
if(!eps_arr){ cJSON_Delete(root); return false; }
|
|
for(int i=0;i<G.ep_cnt && i<MAX_EPS;i++){
|
|
EpInfo* e=&G.eps[i];
|
|
if(e->slug[0]==0) continue;
|
|
cJSON* obj=cJSON_CreateObject();
|
|
if(!obj) continue;
|
|
cJSON_AddStringToObject(obj,"title", e->title);
|
|
cJSON_AddStringToObject(obj,"slug", e->slug);
|
|
cJSON_AddNumberToObject(obj,"season_num", e->season);
|
|
cJSON_AddNumberToObject(obj,"episode_num", e->ep_num);
|
|
cJSON_AddStringToObject(obj,"audio_url", e->audio_url);
|
|
cJSON_AddItemToArray(eps_arr, obj);
|
|
}
|
|
cJSON_AddItemToObject(root,"episodes",eps_arr);
|
|
|
|
char* s=cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
if(!s) return false;
|
|
FILE* f=fopen(CACHE_PATH,"w");
|
|
bool ok=false;
|
|
if(f){
|
|
size_t len=strlen(s);
|
|
size_t wrote=fwrite(s,1,len,f);
|
|
fclose(f);
|
|
ok = (wrote==len);
|
|
}
|
|
free(s);
|
|
return ok;
|
|
}
|
|
|
|
bool load_cache(void){
|
|
FILE* f=fopen(CACHE_PATH,"r");
|
|
if(!f) return false;
|
|
fseek(f,0,SEEK_END);
|
|
long sz=ftell(f);
|
|
fseek(f,0,SEEK_SET);
|
|
if(sz<=0 || sz>200000){ fclose(f); return false; }
|
|
// Use PSRAM for large json to avoid internal heap pressure
|
|
char* buf=heap_caps_malloc(sz+1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
|
if(!buf){
|
|
buf=malloc(sz+1);
|
|
if(!buf){ fclose(f); return false; }
|
|
}
|
|
size_t got=fread(buf,1,sz,f);
|
|
buf[got]=0;
|
|
fclose(f);
|
|
cJSON* root=cJSON_Parse(buf);
|
|
heap_caps_free(buf);
|
|
if(!root) return false;
|
|
|
|
cJSON* seasons_arr=cJSON_GetObjectItem(root,"seasons");
|
|
cJSON* eps_arr=cJSON_GetObjectItem(root,"episodes");
|
|
if(!cJSON_IsArray(seasons_arr) || !cJSON_IsArray(eps_arr)){
|
|
cJSON_Delete(root);
|
|
return false;
|
|
}
|
|
|
|
int scnt=cJSON_GetArraySize(seasons_arr);
|
|
G.season_cnt=0;
|
|
for(int i=0;i<scnt && G.season_cnt<MAX_SEASONS;i++){
|
|
cJSON* it=cJSON_GetArrayItem(seasons_arr,i);
|
|
if(cJSON_IsNumber(it)){
|
|
G.seasons[G.season_cnt].num=it->valueint;
|
|
G.season_cnt++;
|
|
}
|
|
}
|
|
|
|
int ecnt=cJSON_GetArraySize(eps_arr);
|
|
G.ep_cnt=0;
|
|
for(int i=0;i<ecnt && G.ep_cnt<MAX_EPS;i++){
|
|
cJSON* it=cJSON_GetArrayItem(eps_arr,i);
|
|
if(!cJSON_IsObject(it)) continue;
|
|
cJSON* t=cJSON_GetObjectItem(it,"title");
|
|
cJSON* slug=cJSON_GetObjectItem(it,"slug");
|
|
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
|
|
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
|
|
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
|
|
if(!sn || !en) continue;
|
|
EpInfo* e=&G.eps[G.ep_cnt];
|
|
memset(e,0,sizeof(*e));
|
|
if(t && cJSON_IsString(t)) strncpy(e->title, t->valuestring, sizeof(e->title)-1);
|
|
if(slug && cJSON_IsString(slug)) strncpy(e->slug, slug->valuestring, sizeof(e->slug)-1);
|
|
else if(t && cJSON_IsString(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++;
|
|
}
|
|
|
|
cJSON_Delete(root);
|
|
if(G.season_cnt>0) sort_seasons();
|
|
if(G.ep_cnt>0) sort_eps();
|
|
// Refresh seen in case SD changed
|
|
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
|
|
return (G.season_cnt>0 && G.ep_cnt>0);
|
|
}
|
|
|
|
void lru_check(){
|
|
typedef struct{ char path[300]; time_t mt; } FE;
|
|
// Allocate from PSRAM to avoid stack overflow (200*~304=60KB on stack would overflow 12KB task)
|
|
FE *files = heap_caps_malloc(200 * sizeof(FE), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
|
if(!files) return;
|
|
int fc=0;
|
|
DIR* d=opendir(DM_DIR);
|
|
if(!d){ heap_caps_free(files); return; }
|
|
struct dirent* de;
|
|
char cur_path[300]={0};
|
|
if(G.cur_ep_idx>=0 && G.cur_ep_idx<G.ep_cnt){
|
|
make_sd_path(cur_path, sizeof(cur_path), G.eps[G.cur_ep_idx].slug);
|
|
}
|
|
while((de=readdir(d))!=NULL && fc<200){
|
|
if(de->d_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){ heap_caps_free(files); return; }
|
|
for(int i=0;i<fc;i++){
|
|
for(int j=i+1;j<fc;j++){
|
|
if(files[j].mt<files[i].mt){ FE t=files[i]; files[i]=files[j]; files[j]=t; }
|
|
}
|
|
}
|
|
int todel=fc-8;
|
|
for(int i=0;i<todel;i++) unlink(files[i].path);
|
|
heap_caps_free(files);
|
|
}
|