6bfe514ff1
- 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
1633 lines
59 KiB
C
1633 lines
59 KiB
C
#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;
|
||
}
|