Files
tactility_apps/Apps/DiscoveryMountain/main/Source/main.c
T
Adolfo 6bfe514ff1 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
2026-07-22 16:54:40 -04:00

1633 lines
59 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tt_bundle.h>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include <tactility/lvgl_fonts.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
#include <lwip/sockets.h>
#include <lwip/inet.h>
#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<G.saved_pos_cnt;i++){
if(strcmp(G.saved_pos[i].slug, slug)==0) return i;
}
return -1;
}
static 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;
}
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;i<G.saved_pos_cnt;i++){
if(G.saved_pos[i].slug[0]==0) continue;
// Only save positions >0 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;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++;
}
}
// 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<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;
}
}
}
}
static void sort_eps(){
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){
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(pos<raw_len){
char* crlf=strstr(raw+pos,"\r\n");
if(!crlf) break;
int off=crlf-(raw+pos);
if(off<=0 || off>16){ 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<raw_len && raw[pos]=='\r' && raw[pos+1]=='\n') pos+=2;
}
final_body[out]=0;
final_len=out;
} else {
int bl=raw_len;
if(content_len>=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;i<n&&G.season_cnt<MAX_SEASONS;i++){
cJSON* it=cJSON_GetArrayItem(items,i);
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
if(sn&&cJSON_IsNumber(sn)){ G.seasons[G.season_cnt].num=sn->valueint; 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;i<n&&G.ep_cnt<MAX_EPS;i++){
cJSON* it=cJSON_GetArrayItem(items,i);
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) 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<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
}
static void lru_check(){
typedef struct{ char path[300]; time_t mt; } FE;
FE files[200];
int fc=0;
DIR* d=opendir(DM_DIR);
if(!d) 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) 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);
}
// Raw socket download to file – robust version with progress tracking, no LVGL locks inside loop
static bool dl_ep_raw(EpInfo* ep){
if(!ep) 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; }
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;i<total_samples;i++){
int32_t s=pcm[i];
s=(int32_t)(s*vol);
if(s>32767) 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(off<to_write && !G.need_stop){
if(G.is_paused){
if(configured){
close_stream();
configured=false;
sr=0; ch=0;
}
break;
}
size_t w=0;
error_t e=audio_stream_write(G.stream_handle,(uint8_t*)pcm+off,to_write-off,&w,pdMS_TO_TICKS(500));
if(e==ERROR_NONE){
off+=w;
} else {
ESP_LOGW(TAG,"audio_write err %d",e);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
if(G.is_paused) continue;
}
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);
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;i<G.ep_cnt;i++) if(G.eps[i].season==sn){
if(first==-1) first=i;
if(!G.eps[i].seen && first_unseen==-1) first_unseen=i;
}
int tgt=first_unseen!=-1?first_unseen:first;
if(tgt!=-1){
G.cur_season=sn;
select_ep(tgt);
}
}
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());
lv_obj_set_size(G.overlay,240,280);
lv_obj_center(G.overlay);
lv_obj_set_style_bg_color(G.overlay,lv_color_hex(0x222222),0);
lv_obj_set_style_radius(G.overlay,12,0);
lv_obj_t* title=lv_label_create(G.overlay);
lv_label_set_text(title,"Select Season");
lv_obj_align(title,LV_ALIGN_TOP_MID,0,8);
static char opts[1024];
opts[0]=0;
for(int i=0;i<G.season_cnt;i++){
char line[16];
if(i==0) snprintf(opts,sizeof(opts),"S%d",G.seasons[i].num);
else { snprintf(line,sizeof(line),"\nS%d",G.seasons[i].num); strncat(opts,line,sizeof(opts)-strlen(opts)-1); }
}
G.dropdown=lv_dropdown_create(G.overlay);
lv_dropdown_set_options(G.dropdown, opts);
lv_obj_set_size(G.dropdown,160,40);
lv_obj_align(G.dropdown,LV_ALIGN_CENTER,0,10);
int sel=0;
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==G.cur_season) sel=i;
lv_dropdown_set_selected(G.dropdown, sel);
lv_obj_add_event_cb(G.dropdown,dropdown_cb,LV_EVENT_VALUE_CHANGED,NULL);
lv_obj_t* bc=lv_btn_create(G.overlay);
lv_obj_set_size(bc,80,32);
lv_obj_align(bc,LV_ALIGN_BOTTOM_MID,0,-8);
lv_obj_t* lc=lv_label_create(bc);
lv_label_set_text(lc,"Close");
lv_obj_center(lc);
lv_obj_add_event_cb(bc,(lv_event_cb_t)close_overlay,LV_EVENT_CLICKED,NULL);
tt_lvgl_unlock();
}
static void build_ui(){
tt_lvgl_lock(portMAX_DELAY);
lv_obj_t* root=lv_obj_create(lv_scr_act());
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
lv_obj_set_style_bg_color(root,lv_color_hex(0x111111),0);
lv_obj_set_style_pad_all(root,8,0);
lv_obj_set_style_border_width(root,0,0);
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
G.lbl_title=lv_label_create(root);
lv_label_set_text(G.lbl_title,"Discovery Mountain");
lv_obj_set_style_text_font(G.lbl_title, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
lv_obj_set_width(G.lbl_title,LV_PCT(100));
lv_label_set_long_mode(G.lbl_title,LV_LABEL_LONG_WRAP);
lv_obj_align(G.lbl_title,LV_ALIGN_TOP_LEFT,0,0);
G.lbl_meta=lv_label_create(root);
lv_label_set_text(G.lbl_meta,"Loading...");
lv_obj_set_style_text_color(G.lbl_meta,lv_color_hex(0xAAAAAA),0);
lv_obj_align(G.lbl_meta,LV_ALIGN_TOP_LEFT,0,28);
G.bar=lv_bar_create(root);
lv_obj_set_size(G.bar,LV_PCT(100),12);
lv_obj_align(G.bar,LV_ALIGN_TOP_LEFT,0,50);
lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
G.lbl_time=lv_label_create(root);
lv_label_set_text(G.lbl_time,"0:00 / --:--");
lv_obj_align(G.lbl_time,LV_ALIGN_TOP_LEFT,0,66);
G.lbl_status=lv_label_create(root);
lv_label_set_text(G.lbl_status,"Fetching data...");
lv_obj_set_style_text_color(G.lbl_status,lv_color_hex(0x888888),0);
lv_obj_align(G.lbl_status,LV_ALIGN_TOP_LEFT,0,82);
lv_obj_t* row=lv_obj_create(root);
lv_obj_set_size(row,LV_PCT(100),56);
lv_obj_align(row,LV_ALIGN_TOP_LEFT,0,104);
lv_obj_set_flex_flow(row,LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_bg_opa(row,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(row,0,0);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* b1=lv_btn_create(row);
lv_obj_set_size(b1,56,48);
lv_obj_t* l1=lv_label_create(b1);
lv_label_set_text(l1,LV_SYMBOL_PREV);
lv_obj_center(l1);
lv_obj_add_event_cb(b1,btn_prev_cb,LV_EVENT_CLICKED,NULL);
G.btn_play=lv_btn_create(row);
lv_obj_set_size(G.btn_play,72,48);
lv_obj_set_style_bg_color(G.btn_play,lv_color_hex(0x2E7D32),0);
lv_obj_t* l2=lv_label_create(G.btn_play);
lv_label_set_text(l2,LV_SYMBOL_PLAY);
lv_obj_center(l2);
lv_obj_add_event_cb(G.btn_play,btn_play_cb,LV_EVENT_CLICKED,NULL);
lv_obj_t* b3=lv_btn_create(row);
lv_obj_set_size(b3,56,48);
lv_obj_t* l3=lv_label_create(b3);
lv_label_set_text(l3,LV_SYMBOL_NEXT);
lv_obj_center(l3);
lv_obj_add_event_cb(b3,btn_next_cb,LV_EVENT_CLICKED,NULL);
lv_obj_t* row2=lv_obj_create(root);
lv_obj_set_size(row2,LV_PCT(100),44);
lv_obj_align(row2,LV_ALIGN_BOTTOM_LEFT,0,0);
lv_obj_set_flex_flow(row2,LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row2,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_bg_opa(row2,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(row2,0,0);
lv_obj_remove_flag(row2, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* bs=lv_btn_create(row2);
lv_obj_set_size(bs,LV_PCT(100),36);
lv_obj_t* ls=lv_label_create(bs);
lv_label_set_text(ls,"Seasons");
lv_obj_center(ls);
lv_obj_add_event_cb(bs,btn_seasons_cb,LV_EVENT_CLICKED,NULL);
lv_timer_create(ui_timer,200,NULL);
tt_lvgl_unlock();
}
static void fetch_task_fn(void* arg){
(void)arg;
G.fetching=true;
ESP_LOGI(TAG,"fetch task start");
fetch_data();
ESP_LOGI(TAG,"fetch done seasons=%d eps=%d",G.season_cnt,G.ep_cnt);
if(strlen(G.last_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
if(G.cur_ep_idx<0){
int s1=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ s1=i; break; }
if(s1==-1) s1=0;
G.cur_ep_idx=s1;
}
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[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;
}