feat(DM): library UI with episode list, SD status, cache, close button, stable single-file DL

- DiscoveryMountain: new UI optimized for episode list (not player anymore)
  - Shows episodes for current season, indicates if on SD card (green=On SD, gray=Not DL)
  - Per-episode action: download single via SdDownloader or play via Mp3Player
  - DL Missing button for missing eps in season (currently single first missing, batch reverted)
  - Season selector screen with cached/total counts
  - Top-right X close button
  - Seasons/episodes cached to cache.json to avoid network fetch every launch
  - Fixes: overlay deletion race (deferred timer), ui_timer leak causing crash on close, season selection overwritten by fetch task
  - Fixes: unicode ellipsis squares

- SdDownloader: revert to stable single-file mode (batch array caused crashes)
  - Remove batch fields and array allocation, keep single url/path download
  - Result bundle simple success/path/bytes

- Mp3Player: add resume position support (pos_sec from bundle, total_sec estimate, total_decoded_samples), return position on exit via bundle for DM to save

Tested on 192.168.68.133: opens, shows S10 2/6, Play/DL buttons, close works, no crash on open/close cycles, cache shows 'Loaded from cache, refreshing...'
This commit is contained in:
Adolfo
2026-07-25 22:06:43 -04:00
parent 6a1fe5dda6
commit 3778cb3399
25 changed files with 5178 additions and 17 deletions
@@ -0,0 +1,8 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
idf_component_register(
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
REQUIRES TactilitySDK esp_http_client lwip
)
@@ -0,0 +1,3 @@
#include "app_context.h"
AppCtx G = {0};
@@ -0,0 +1,99 @@
#pragma once
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <lvgl.h>
#include <tactility/lvgl_fonts.h>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#ifdef __cplusplus
extern "C" {
#endif
#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 CACHE_PATH "/sdcard/apps/one.tactility.discoverymountain/userdata/cache.json"
#define MAX_SEASONS 40
#define MAX_EPS 250
#define MP3_BUF 4096
typedef struct { int num; } SeasonInfo;
typedef struct {
char title[64];
char slug[96];
int season;
int ep_num;
char audio_url[128];
bool seen;
} EpInfo;
typedef struct { char slug[96]; int pos; } SavedPos;
typedef 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 *root;
lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play;
lv_obj_t *lbl_season;
lv_obj_t *ep_list_cont;
lv_obj_t *btn_seasons;
lv_obj_t *btn_dl_missing;
lv_obj_t *overlay, *dropdown;
lv_obj_t *season_list_cont;
lv_timer_t *ui_timer_handle;
// 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;
volatile int dl_total;
int dl_expected;
int dl_last_ui_update;
volatile bool dl_cancel_req;
volatile bool fetch_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
SavedPos saved_pos[MAX_EPS];
int saved_pos_cnt;
int last_save_tick;
// External downloader integration (SdDownloader)
int pending_dl_idx;
} AppCtx;
extern AppCtx G;
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,49 @@
#include "download.h"
#include "storage.h"
#include <tt_lvgl.h>
#include <string.h>
#include "esp_log.h"
// Simplified: internal download removed, we use external SdDownloader app
// Keep overlay functions as stubs for compatibility, but they do minimal
void hide_dl_overlay(){
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;
}
void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num){
// No longer used - external downloader has its own UI
// Keep as stub that shows simple status
(void)ep_title; (void)slug; (void)season; (void)ep_num;
G.downloading=true;
}
void update_dl_overlay_ui(){
// No internal download progress anymore
}
bool dl_ep_raw(EpInfo* ep){
(void)ep;
return false;
}
bool dl_ep(EpInfo* ep){
(void)ep;
return false;
}
void dl_task(void* arg){
(void)arg;
G.downloading=false;
G.dl_handle=NULL;
vTaskDelete(NULL);
}
@@ -0,0 +1,24 @@
#pragma once
#include "app_context.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Raw download to file with progress tracking
bool dl_ep_raw(EpInfo* ep);
bool dl_ep(EpInfo* ep);
// Download task
void dl_task(void* arg);
// Download overlay UI
void hide_dl_overlay(void);
void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num);
void update_dl_overlay_ui(void);
#ifdef __cplusplus
}
#endif
+383
View File
@@ -0,0 +1,383 @@
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_bundle.h>
#include "app_context.h"
#include "storage.h"
#include "network.h"
#include "download.h"
#include "player.h"
#include "ui.h"
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
static void fetch_task_fn(void* arg){
(void)arg;
G.fetching=true;
G.fetch_cancel_req=false;
ESP_LOGI(TAG,"fetch task start");
// Try load cached seasons/episodes instant offline support
bool cache_loaded = load_cache();
if(cache_loaded){
ESP_LOGI(TAG,"Cache loaded: %d seasons, %d eps", G.season_cnt, G.ep_cnt);
// Restore cur idx from last_slug
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 && G.ep_cnt>0){
s1=0;
G.cur_season=G.eps[0].season;
}
G.cur_ep_idx=s1;
}
// Show cached data immediately
G.fetching=false;
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Loaded from cache, refreshing...");
tt_lvgl_unlock();
G.fetching=true;
vTaskDelay(pdMS_TO_TICKS(200));
} else {
ESP_LOGI(TAG,"No cache found, will fetch from network");
}
// Network fetch updates if possible, otherwise keeps cache
bool fetch_ok = fetch_data();
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"fetch task canceled, exiting");
G.fetching=false;
G.fetch_handle=NULL;
vTaskDelete(NULL);
return;
}
if(fetch_ok){
ESP_LOGI(TAG,"Network fetch ok: %d seasons, %d eps saving cache", G.season_cnt, G.ep_cnt);
save_cache();
} else {
if(cache_loaded){
ESP_LOGW(TAG,"Network fetch failed, restoring cache");
// fetch_data overwrote with fallback, restore cache
load_cache();
} else {
ESP_LOGW(TAG,"Network fetch failed and no cache using fallback");
}
}
ESP_LOGI(TAG,"fetch done seasons=%d eps=%d cache=%d fetch_ok=%d",G.season_cnt,G.ep_cnt,cache_loaded,fetch_ok);
vTaskDelay(pdMS_TO_TICKS(300));
// Resolve current episode index preserve user season selection if cache was already shown
if(cache_loaded){
// User may have changed season while network fetch was in progress keep their selection if it still exists
int cur_s = G.cur_season;
bool season_exists=false;
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==cur_s) { season_exists=true; break; }
if(!season_exists){
// Fallback to last_slug or first season
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; season_exists=true; break; }
}
}
if(season_exists){
// Keep cur_season, find best idx within it
int best=-1;
if(strlen(G.last_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==cur_s && strcmp(G.eps[i].slug,G.last_slug)==0){ best=i; break; }
}
if(best==-1){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==cur_s){ best=i; break; }
}
if(best!=-1) G.cur_ep_idx=best;
} else {
// Season no longer exists, fallback to first
if(G.ep_cnt>0){
G.cur_ep_idx=0;
G.cur_season=G.eps[0].season;
}
}
} else {
// First launch use last_slug to restore position
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 && G.ep_cnt>0){
s1=0;
G.cur_season=G.eps[0].season;
}
G.cur_ep_idx=s1;
}
}
G.fetching=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.fetch_cancel_req){
tt_lvgl_unlock();
G.fetch_handle=NULL;
vTaskDelete(NULL);
return;
}
tt_lvgl_unlock();
ui_rebuild_episode_list();
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;
if(saved>0) G.pos_sec=saved;
}
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
if(G.ep_cnt>0){
if(fetch_ok) lv_label_set_text(G.lbl_status, "Select episode to play / download");
else if(cache_loaded) lv_label_set_text(G.lbl_status, "Offline, using cached data");
else lv_label_set_text(G.lbl_status,"No network limited data");
} else {
lv_label_set_text(G.lbl_status,"No data check network");
}
}
tt_lvgl_unlock();
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.pending_dl_idx=-1;
G.volume=80;
G.last_pct=-1;
G.dl_last_pct=-1;
G.dl_expected=-1;
G.fetch_cancel_req=false;
G.dl_cancel_req=false;
ensure_dir();
load_state();
build_ui(parent);
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
size_t free_psram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
ESP_LOGI(TAG, "onShow heap before fetch task: internal=%d psram=%d", free_internal, free_psram);
BaseType_t res = xTaskCreate(fetch_task_fn,"dm_fetch",12288,NULL,5,&G.fetch_handle);
ESP_LOGI(TAG, "xTaskCreate fetch_task res=%d handle=%p", res, G.fetch_handle);
if (res != pdPASS) {
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Failed to create fetch task");
tt_lvgl_unlock();
}
}
static void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData){
(void)app; (void)data; (void)launchId;
ESP_LOGI(TAG, "onResult: result=%d pending_idx=%d cur_idx=%d", result, G.pending_dl_idx, G.cur_ep_idx);
// Check if result contains timestamp from Mp3Player
if (resultData) {
int32_t pos = 0;
bool has_pos = false;
if (tt_bundle_opt_int32(resultData, "pos_sec", &pos) ||
tt_bundle_opt_int32(resultData, "timestamp", &pos) ||
tt_bundle_opt_int32(resultData, "position", &pos)) {
has_pos = true;
}
char file_path[512]={0};
bool has_file = tt_bundle_opt_string(resultData, "file", file_path, sizeof(file_path)) ||
tt_bundle_opt_string(resultData, "path", file_path, sizeof(file_path));
if (has_pos) {
ESP_LOGI(TAG, "Mp3Player returned pos %d sec for file %s", pos, has_file?file_path:"unknown");
int target_idx = -1;
if (has_file && strlen(file_path)>0) {
for(int i=0;i<G.ep_cnt;i++){
char ep_path[300];
make_sd_path(ep_path, sizeof(ep_path), G.eps[i].slug);
if (strcmp(ep_path, file_path)==0) { target_idx = i; break; }
}
}
if (target_idx<0 && G.pending_dl_idx>=0 && G.pending_dl_idx < G.ep_cnt) target_idx = G.pending_dl_idx;
if (target_idx<0 && G.cur_ep_idx>=0) target_idx = G.cur_ep_idx;
if (target_idx>=0) {
bool finished = false;
tt_bundle_opt_bool(resultData, "finished", &finished);
int32_t total_sec = 0;
tt_bundle_opt_int32(resultData, "total_sec", &total_sec);
if (finished || (total_sec>0 && pos >= total_sec-10)) {
set_saved_pos_for_slug(G.eps[target_idx].slug, 0);
G.pos_sec = 0;
ESP_LOGI(TAG, "Episode %s finished, clearing pos", G.eps[target_idx].slug);
} else {
set_saved_pos_for_slug(G.eps[target_idx].slug, pos);
G.pos_sec = pos;
ESP_LOGI(TAG, "Saved pos %d for %s", pos, G.eps[target_idx].slug);
}
save_state();
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) {
if (pos>5) lv_label_set_text(G.lbl_status,"Progress saved tap episode to resume");
else lv_label_set_text(G.lbl_status,"Select episode to play / download");
}
tt_lvgl_unlock();
}
G.pending_dl_idx = -1;
return;
}
}
// SdDownloader batch result handling
if (G.pending_dl_idx <0 || G.pending_dl_idx >= G.ep_cnt) {
if (resultData) {
int32_t succ=0, fail=0, total=0;
if (tt_bundle_opt_int32(resultData, "success_count", &succ) ||
tt_bundle_opt_int32(resultData, "total_count", &total)) {
tt_bundle_opt_int32(resultData, "fail_count", &fail);
ESP_LOGI(TAG, "Batch DL result: success %ld fail %ld total %ld", (long)succ, (long)fail, (long)total);
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
save_state();
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) {
char msg[64];
snprintf(msg,sizeof(msg),"DL: %ld ok, %ld fail", (long)succ, (long)fail);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
G.pending_dl_idx = -1;
return;
}
}
G.pending_dl_idx = -1;
// Still refresh list in case files appeared
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
ui_rebuild_episode_list();
return;
}
// Single download result
bool success = false;
if (resultData) {
tt_bundle_opt_bool(resultData, "success", &success);
}
if (result == APP_RESULT_OK) {
if (resultData) {
bool b=false;
if (tt_bundle_opt_bool(resultData, "success", &b)) success = b;
else success = true;
} else {
success = true;
}
}
int idx = G.pending_dl_idx;
G.pending_dl_idx = -1;
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
save_state();
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) {
if (success) lv_label_set_text(G.lbl_status, "DL done tap to play");
else lv_label_set_text(G.lbl_status, "DL failed tap to retry");
}
tt_lvgl_unlock();
if(success && idx>=0){
ESP_LOGI(TAG, "Single DL success idx %d", idx);
} else {
ESP_LOGW(TAG, "External DL failed for idx %d", idx);
}
}
static void onHide(AppHandle app, void* data){
(void)app; (void)data;
save_current_ep_pos();
G.fetch_cancel_req=true;
G.dl_cancel_req=true;
if(G.fetch_handle){
ESP_LOGI(TAG,"onHide: waiting fetch to exit");
tt_lvgl_lock(portMAX_DELAY);
for(int i=0;i<20 && G.fetch_handle!=NULL; i++){
tt_lvgl_unlock();
vTaskDelay(pdMS_TO_TICKS(100));
tt_lvgl_lock(portMAX_DELAY);
}
if(G.fetch_handle){
ESP_LOGW(TAG,"onHide: force deleting fetch task (leak risk)");
vTaskDelete(G.fetch_handle);
G.fetch_handle=NULL;
}
G.fetching=false;
tt_lvgl_unlock();
}
if(G.dl_handle){
ESP_LOGI(TAG,"onHide: waiting dl to exit");
tt_lvgl_lock(portMAX_DELAY);
for(int i=0;i<30 && G.dl_handle!=NULL; i++){
tt_lvgl_unlock();
vTaskDelay(pdMS_TO_TICKS(100));
tt_lvgl_lock(portMAX_DELAY);
}
if(G.dl_handle){
ESP_LOGW(TAG,"onHide: force deleting dl task after timeout");
vTaskDelete(G.dl_handle);
G.dl_handle=NULL;
G.downloading=false;
if(G.dl_overlay) hide_dl_overlay();
}
tt_lvgl_unlock();
}
tt_lvgl_lock(portMAX_DELAY);
wait_play_exit();
ui_deinit();
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
if(G.root){
lv_obj_delete(G.root);
G.root = NULL;
G.lbl_title = NULL;
G.lbl_meta = NULL;
G.lbl_season = NULL;
G.ep_list_cont = NULL;
G.lbl_time = NULL;
G.lbl_status = NULL;
G.btn_play = NULL;
G.btn_seasons = NULL;
G.btn_dl_missing = NULL;
G.season_list_cont = NULL;
G.bar = NULL;
}
tt_lvgl_unlock();
save_state();
}
int main(int argc, char* argv[]){
(void)argc; (void)argv;
tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide, .onResult=onResult });
return 0;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,330 @@
#include "network.h"
#include "storage.h"
#include "cJSON.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <lwip/sockets.h>
#include <lwip/inet.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include <unistd.h>
uint16_t my_htons(uint16_t v){
// Proper byte swap with masking to avoid overflow
return (uint16_t)(((v & 0x00FF) << 8) | ((v & 0xFF00) >> 8));
}
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;
}
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(strstr(in,"://")==NULL){
if(in[0]=='/') snprintf(out,out_len,"%s%s",PB,in);
else snprintf(out,out_len,"%s/%s",PB,in);
return;
}
strncpy(out,in,out_len-1);
out[out_len-1]=0;
}
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;
ESP_LOGI(TAG,"Resolving host %s",host);
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"ip fail %s",host); return -1; }
ESP_LOGI(TAG,"Resolved %s -> ip 0x%08x", host, ip);
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
ESP_LOGI(TAG,"Socket fd=%d", fd);
if(fd<0){ ESP_LOGE(TAG,"socket fail"); return -1; }
struct sockaddr_in sa;
ESP_LOGI(TAG,"Memset sa");
memset(&sa,0,sizeof(sa));
ESP_LOGI(TAG,"Set sa len/family/port/addr");
sa.sin_len = sizeof(sa);
sa.sin_family=AF_INET;
sa.sin_port=my_htons(port);
sa.sin_addr.s_addr=ip;
ESP_LOGI(TAG,"SetSockOpt RCV");
struct timeval tv={5,0};
int opt_res = lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
ESP_LOGI(TAG,"SetSockOpt RCV res=%d", opt_res);
ESP_LOGI(TAG,"SetSockOpt SND");
opt_res = lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
ESP_LOGI(TAG,"SetSockOpt SND res=%d", opt_res);
ESP_LOGI(TAG,"Connecting to %s:%d",host,port);
int conn_res = lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa));
ESP_LOGI(TAG,"Connect res=%d", conn_res);
if(conn_res<0){ ESP_LOGE(TAG,"connect fail %s:%d",host,port); close(fd); return -1; }
ESP_LOGI(TAG,"Connected, sending request");
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; }
ESP_LOGI(TAG,"Request sent, waiting for response");
int capacity=8192; // further reduced to 8K internal to avoid PSRAM StoreProhibited crash (seen 0x8208775e)
char* buf=malloc(capacity);
if(!buf){
buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!buf){ ESP_LOGE(TAG,"buf 8k 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){
// Graceful cancel support to avoid force vTaskDelete leaking PSRAM/SD resources
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"http_get_raw canceled by user");
heap_caps_free(buf);
close(fd);
return -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){
if(r<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==ETIMEDOUT)){
ESP_LOGW(TAG,"http_get_raw timeout total=%d",total);
// On timeout, if fetching canceled, abort
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"http_get_raw timeout + canceled");
heap_caps_free(buf);
close(fd);
return -1;
}
// Continue loop LWIP will retry until EOF
// But if we already have header and content_len satisfied, break
if(header_end && content_len>=0 && !is_chunked && total-body_start >= content_len) break;
// If no header yet and timeout, treat as failure to avoid 15s hang
if(!header_end && total==0){
ESP_LOGE(TAG,"http_get_raw timeout before header");
heap_caps_free(buf);
close(fd);
return -1;
}
// Otherwise continue to try again
continue;
}
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;
}
bool fetch_data(){
char* js=NULL;
char url[320];
bool seasons_ok=false;
bool episodes_ok=false;
ESP_LOGI(TAG,"fetch seasons");
// Reduce payload via fields filter drastically lowers cJSON malloc pressure (internal heap low warning)
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num&fields=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();
if(G.season_cnt>0) seasons_ok=true;
}
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 paginated to reduce internal heap (prev low 7KB)");
G.ep_cnt=0;
for(int page=1; page<=10; page++){
if(G.fetch_cancel_req) break;
if(G.ep_cnt>=MAX_EPS) break;
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=25&page=%d&sort=season_num,episode_num&fields=title,slug,season_num,episode_num,audio_url",PB,page);
len=http_get_raw(url,&js);
if(len<=0 || !js){
ESP_LOGW(TAG,"episodes page %d fetch failed",page);
if(js){ free(js); js=NULL; }
break;
}
cJSON* root=cJSON_Parse(js);
free(js); js=NULL;
if(!root){
ESP_LOGW(TAG,"episodes page %d JSON parse fail",page);
break;
}
cJSON* items=cJSON_GetObjectItem(root,"items");
if(!cJSON_IsArray(items)){
cJSON_Delete(root);
break;
}
int n=cJSON_GetArraySize(items);
if(n==0){
cJSON_Delete(root);
break;
}
ESP_LOGI(TAG,"episodes page %d got %d items (total so far %d)",page,n,G.ep_cnt);
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++;
}
episodes_ok=true;
cJSON_Delete(root);
// Yield between pages to let MemoryChecker recover
vTaskDelay(pdMS_TO_TICKS(400));
if(n<25) break; // last page
}
// Final sort after all pages
if(G.ep_cnt>0) sort_eps();
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);
return (seasons_ok && episodes_ok);
}
@@ -0,0 +1,23 @@
#pragma once
#include "app_context.h"
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
uint16_t my_htons(uint16_t v);
bool resolve_host(const char* host, uint32_t* out_ip);
void normalize_audio_url(const char* in, char* out, size_t out_len);
// Robust HTTP GET with PSRAM buffer, returns length and allocates *out_body (must be free'd via free/heap_caps_free)
int http_get_raw(const char* url, char** out_body);
// Fetch seasons and episodes from PB returns true if network fetch succeeded with real data
bool fetch_data(void);
#ifdef __cplusplus
}
#endif
+235
View File
@@ -0,0 +1,235 @@
#include "player.h"
#include "storage.h"
#include "ui.h"
#include "download.h"
#include <tt_lvgl.h>
#include <tt_app.h>
#include <tt_bundle.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
void wait_play_exit(void) {
// No internal playback anymore, nothing to wait
}
void stop_playback_sync(void) {
// No internal playback, nothing to stop
G.is_playing = false;
G.is_paused = false;
}
static bool try_external_downloader(int idx) {
if(idx<0||idx>=G.ep_cnt) return false;
EpInfo* ep=&G.eps[idx];
if (strlen(ep->audio_url) < 8) {
ESP_LOGW(TAG, "Skipping DL for %s: empty URL", ep->slug);
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "No URL fetch failed?");
tt_lvgl_unlock();
return false;
}
char fpath[300];
make_sd_path(fpath, sizeof(fpath), ep->slug);
tt_lvgl_lock(portMAX_DELAY);
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
tt_lvgl_unlock();
BundleHandle b = tt_bundle_alloc();
if (!b) {
ESP_LOGW(TAG, "Failed to alloc bundle for external DL");
return false;
}
tt_bundle_put_string(b, "url", ep->audio_url);
tt_bundle_put_string(b, "path", fpath);
tt_bundle_put_bool(b, "override", false);
ESP_LOGI(TAG, "Starting external SdDownloader for %s -> %s", ep->slug, fpath);
G.pending_dl_idx = idx;
tt_lvgl_lock(portMAX_DELAY);
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Starting downloader...");
tt_lvgl_unlock();
tt_app_start_with_bundle("one.tactility.sddownloader", b);
return true;
}
static bool try_external_downloader_batch(int season) {
// Reverted to single-file mode batch array caused crashes in SdDownloader
// Now just download first missing episode in season
int first_missing = -1;
for(int i=0;i<G.ep_cnt;i++){
if(G.eps[i].season == season && !G.eps[i].seen && strlen(G.eps[i].audio_url)>=8){
first_missing = i;
break;
}
}
if(first_missing==-1){
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Season already cached");
tt_lvgl_unlock();
return true;
}
ESP_LOGI(TAG, "DL Missing (single-file revert) downloading first missing S%02d idx %d", season, first_missing);
return try_external_downloader(first_missing);
}
void start_idx_internal(int idx){
if(idx<0||idx>=G.ep_cnt) return;
EpInfo* ep=&G.eps[idx];
char fpath[300];
make_sd_path(fpath, sizeof(fpath), ep->slug);
if(!has_slug(ep->slug)){
ESP_LOGW(TAG, "start_idx_internal called but file missing %s", ep->slug);
start_idx(idx);
return;
}
int saved = 0;
if(G.cur_ep_idx!=idx){
saved = get_saved_pos_for_slug(ep->slug);
if(saved>5){
ESP_LOGI(TAG,"Restoring saved pos %d for %s", saved, ep->slug);
}
} else {
saved = G.pos_sec;
}
tt_lvgl_lock(portMAX_DELAY);
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
tt_lvgl_unlock();
BundleHandle b = tt_bundle_alloc();
if(b){
tt_bundle_put_string(b, "file", fpath);
tt_bundle_put_string(b, "path", fpath);
tt_bundle_put_int32(b, "pos_sec", saved);
tt_bundle_put_int32(b, "position", saved);
tt_bundle_put_int32(b, "volume", G.volume);
ESP_LOGI(TAG, "Launching Mp3Player for %s pos %d", fpath, saved);
G.pending_dl_idx = idx;
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);
save_state();
tt_app_start_with_bundle("one.tactility.mp3player", b);
return;
}
ESP_LOGW(TAG, "Failed to alloc bundle for Mp3Player");
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Failed to launch player");
tt_lvgl_unlock();
}
void start_idx(int idx){
if(idx<0||idx>=G.ep_cnt) return;
EpInfo* ep=&G.eps[idx];
if(!has_slug(ep->slug)){
// File not on SD, download via external downloader
if (try_external_downloader(idx)) {
return;
}
// Fallback: show message, cannot download internally anymore
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Install SdDownloader");
tt_lvgl_unlock();
return;
}
start_idx_internal(idx);
}
void select_ep(int idx){
if(idx<0||idx>=G.ep_cnt) return;
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);
int saved = get_saved_pos_for_slug(ep->slug);
G.pos_sec=saved;
G.last_pct=-1;
G.total_sec=0;
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){
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();
}
void go_next(){
if(G.ep_cnt==0) return;
save_current_ep_pos();
save_state();
int n=G.cur_ep_idx+1;
if(n>=G.ep_cnt) n=0;
select_ep(n);
// Do not auto-play next, per user request: no autoplay
}
void go_prev(){
if(G.ep_cnt==0) return;
if(G.pos_sec>5){
if(G.cur_ep_idx>=0){
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, 0);
save_state();
}
G.pos_sec=0;
int cur=G.cur_ep_idx;
if(cur<0) cur=0;
select_ep(cur);
return;
}
save_current_ep_pos();
save_state();
int p=G.cur_ep_idx-1;
if(p<0) p=G.ep_cnt-1;
select_ep(p);
}
// Called from UI for season batch download
bool download_season(int season) {
return try_external_downloader_batch(season);
}
@@ -0,0 +1,23 @@
#pragma once
#include "app_context.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Simplified: no internal audio, only external apps
void wait_play_exit(void);
void stop_playback_sync(void);
void start_idx_internal(int idx);
void start_idx(int idx);
void select_ep(int idx);
void go_next(void);
void go_prev(void);
bool download_season(int season);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,321 @@
#include "storage.h"
#include "cJSON.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
#include "esp_heap_caps.h"
void make_sd_path(char* out, size_t out_len, const char* slug){
if(!out || !slug) return;
snprintf(out, out_len, "%s/%s.mp3", DM_DIR, slug);
}
void ensure_dir(){
mkdir("/sdcard/apps",0777);
mkdir("/sdcard/apps/one.tactility.discoverymountain",0777);
mkdir("/sdcard/apps/one.tactility.discoverymountain/userdata",0777);
mkdir(DM_DIR,0777);
}
bool has_slug(const char* slug){
char p[300];
make_sd_path(p, sizeof(p), slug);
struct stat st;
return stat(p,&st)==0;
}
void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); }
void sort_seasons(){
for(int i=0;i<G.season_cnt;i++){
for(int j=i+1;j<G.season_cnt;j++){
if(G.seasons[j].num < G.seasons[i].num){
SeasonInfo t=G.seasons[i]; G.seasons[i]=G.seasons[j]; G.seasons[j]=t;
}
}
}
}
void sort_eps(){
// Use PSRAM for temp to avoid stack overflow (EpInfo ~300 bytes, was on stack)
EpInfo *t = heap_caps_malloc(sizeof(EpInfo), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!t) return;
for(int i=0;i<G.ep_cnt;i++){
for(int j=i+1;j<G.ep_cnt;j++){
EpInfo *a=&G.eps[i], *b=&G.eps[j];
bool should_swap=false;
if(a->season != b->season) should_swap = (b->season < a->season);
else should_swap = (b->ep_num < a->ep_num);
if(should_swap){
*t=*a; *a=*b; *b=*t;
}
}
}
heap_caps_free(t);
}
// ── Per-episode position helpers ──
int find_saved_pos_idx(const char* slug){
if(!slug) return -1;
for(int i=0;i<G.saved_pos_cnt;i++){
if(strcmp(G.saved_pos[i].slug, slug)==0) return i;
}
return -1;
}
int get_saved_pos_for_slug(const char* slug){
int idx=find_saved_pos_idx(slug);
if(idx>=0) return G.saved_pos[idx].pos;
return 0;
}
void set_saved_pos_for_slug(const char* slug, int pos){
if(!slug || strlen(slug)==0) return;
if(pos<0) pos=0;
if(G.total_sec>0 && pos>0 && pos >= G.total_sec-10){
pos=0;
}
int idx=find_saved_pos_idx(slug);
if(idx>=0){
G.saved_pos[idx].pos=pos;
return;
}
if(G.saved_pos_cnt < MAX_EPS){
strncpy(G.saved_pos[G.saved_pos_cnt].slug, slug, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1);
G.saved_pos[G.saved_pos_cnt].slug[sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1]=0;
G.saved_pos[G.saved_pos_cnt].pos=pos;
G.saved_pos_cnt++;
}
}
void save_current_ep_pos(){
if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt){
const char* slug=G.eps[G.cur_ep_idx].slug;
set_saved_pos_for_slug(slug, G.pos_sec);
} else if(strlen(G.last_slug)>0){
set_saved_pos_for_slug(G.last_slug, G.pos_sec);
}
}
void save_state(){
if(G.cur_ep_idx>=0 && G.ep_cnt>0){
if(G.pos_sec>=0){
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, G.pos_sec);
}
}
cJSON* r=cJSON_CreateObject();
if(G.ep_cnt>0 && G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt) cJSON_AddStringToObject(r,"last_slug",G.eps[G.cur_ep_idx].slug);
else if(strlen(G.last_slug)>0) cJSON_AddStringToObject(r,"last_slug",G.last_slug);
cJSON_AddNumberToObject(r,"last_season",G.cur_season);
cJSON_AddNumberToObject(r,"pos_sec",G.pos_sec);
if(G.saved_pos_cnt>0){
cJSON* pos_obj=cJSON_CreateObject();
for(int i=0;i<G.saved_pos_cnt;i++){
if(G.saved_pos[i].slug[0]==0) continue;
if(G.saved_pos[i].pos>0){
cJSON_AddNumberToObject(pos_obj, G.saved_pos[i].slug, G.saved_pos[i].pos);
}
}
cJSON_AddItemToObject(r,"positions",pos_obj);
}
char* s=cJSON_PrintUnformatted(r);
if(s){
FILE* f=fopen(STATE_PATH,"w");
if(f){ fwrite(s,1,strlen(s),f); fclose(f);}
free(s);
}
cJSON_Delete(r);
}
void load_state(){
G.cur_season=1; G.cur_ep_idx=-1; G.pos_sec=0;
G.saved_pos_cnt=0;
G.last_save_tick=0;
memset(G.last_slug,0,sizeof(G.last_slug));
memset(G.saved_pos,0,sizeof(G.saved_pos));
FILE* f=fopen(STATE_PATH,"r");
if(!f) return;
fseek(f,0,SEEK_END);
long sz=ftell(f);
fseek(f,0,SEEK_SET);
if(sz<=0||sz>16384){ fclose(f); return;}
char* buf=malloc(sz+1);
if(!buf){ fclose(f); return; }
size_t got=fread(buf,1,sz,f);
buf[got]=0;
fclose(f);
cJSON* r=cJSON_Parse(buf);
free(buf);
if(!r) return;
cJSON* ls=cJSON_GetObjectItem(r,"last_slug");
cJSON* lsn=cJSON_GetObjectItem(r,"last_season");
cJSON* ps=cJSON_GetObjectItem(r,"pos_sec");
if(ps&&cJSON_IsNumber(ps)) G.pos_sec=ps->valueint;
if(lsn&&cJSON_IsNumber(lsn)) G.cur_season=lsn->valueint;
if(ls&&cJSON_IsString(ls)){
strncpy(G.last_slug, ls->valuestring, sizeof(G.last_slug)-1);
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,ls->valuestring)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
cJSON* poss=cJSON_GetObjectItem(r,"positions");
if(poss && cJSON_IsObject(poss)){
cJSON* child=NULL;
cJSON_ArrayForEach(child, poss){
if(cJSON_IsNumber(child) && child->string && G.saved_pos_cnt < MAX_EPS){
strncpy(G.saved_pos[G.saved_pos_cnt].slug, child->string, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1);
G.saved_pos[G.saved_pos_cnt].pos=child->valueint;
G.saved_pos_cnt++;
}
}
if(strlen(G.last_slug)>0){
int sp=get_saved_pos_for_slug(G.last_slug);
if(sp>0) G.pos_sec=sp;
}
}
cJSON_Delete(r);
}
bool save_cache(void){
if(G.ep_cnt==0 || G.season_cnt==0) return false;
cJSON* root=cJSON_CreateObject();
if(!root) return false;
cJSON* seasons_arr=cJSON_CreateArray();
if(!seasons_arr){ cJSON_Delete(root); return false; }
for(int i=0;i<G.season_cnt && i<MAX_SEASONS;i++){
cJSON_AddItemToArray(seasons_arr, cJSON_CreateNumber(G.seasons[i].num));
}
cJSON_AddItemToObject(root,"seasons",seasons_arr);
cJSON* eps_arr=cJSON_CreateArray();
if(!eps_arr){ cJSON_Delete(root); return false; }
for(int i=0;i<G.ep_cnt && i<MAX_EPS;i++){
EpInfo* e=&G.eps[i];
if(e->slug[0]==0) continue;
cJSON* obj=cJSON_CreateObject();
if(!obj) continue;
cJSON_AddStringToObject(obj,"title", e->title);
cJSON_AddStringToObject(obj,"slug", e->slug);
cJSON_AddNumberToObject(obj,"season_num", e->season);
cJSON_AddNumberToObject(obj,"episode_num", e->ep_num);
cJSON_AddStringToObject(obj,"audio_url", e->audio_url);
cJSON_AddItemToArray(eps_arr, obj);
}
cJSON_AddItemToObject(root,"episodes",eps_arr);
char* s=cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if(!s) return false;
FILE* f=fopen(CACHE_PATH,"w");
bool ok=false;
if(f){
size_t len=strlen(s);
size_t wrote=fwrite(s,1,len,f);
fclose(f);
ok = (wrote==len);
}
free(s);
return ok;
}
bool load_cache(void){
FILE* f=fopen(CACHE_PATH,"r");
if(!f) return false;
fseek(f,0,SEEK_END);
long sz=ftell(f);
fseek(f,0,SEEK_SET);
if(sz<=0 || sz>200000){ fclose(f); return false; }
// Use PSRAM for large json to avoid internal heap pressure
char* buf=heap_caps_malloc(sz+1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!buf){
buf=malloc(sz+1);
if(!buf){ fclose(f); return false; }
}
size_t got=fread(buf,1,sz,f);
buf[got]=0;
fclose(f);
cJSON* root=cJSON_Parse(buf);
heap_caps_free(buf);
if(!root) return false;
cJSON* seasons_arr=cJSON_GetObjectItem(root,"seasons");
cJSON* eps_arr=cJSON_GetObjectItem(root,"episodes");
if(!cJSON_IsArray(seasons_arr) || !cJSON_IsArray(eps_arr)){
cJSON_Delete(root);
return false;
}
int scnt=cJSON_GetArraySize(seasons_arr);
G.season_cnt=0;
for(int i=0;i<scnt && G.season_cnt<MAX_SEASONS;i++){
cJSON* it=cJSON_GetArrayItem(seasons_arr,i);
if(cJSON_IsNumber(it)){
G.seasons[G.season_cnt].num=it->valueint;
G.season_cnt++;
}
}
int ecnt=cJSON_GetArraySize(eps_arr);
G.ep_cnt=0;
for(int i=0;i<ecnt && G.ep_cnt<MAX_EPS;i++){
cJSON* it=cJSON_GetArrayItem(eps_arr,i);
if(!cJSON_IsObject(it)) continue;
cJSON* t=cJSON_GetObjectItem(it,"title");
cJSON* slug=cJSON_GetObjectItem(it,"slug");
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
if(!sn || !en) continue;
EpInfo* e=&G.eps[G.ep_cnt];
memset(e,0,sizeof(*e));
if(t && cJSON_IsString(t)) strncpy(e->title, t->valuestring, sizeof(e->title)-1);
if(slug && cJSON_IsString(slug)) strncpy(e->slug, slug->valuestring, sizeof(e->slug)-1);
else if(t && cJSON_IsString(t)) strncpy(e->slug, t->valuestring, sizeof(e->slug)-1);
e->season=sn->valueint;
e->ep_num=en->valueint;
if(au && cJSON_IsString(au)) strncpy(e->audio_url, au->valuestring, sizeof(e->audio_url)-1);
e->seen=has_slug(e->slug);
G.ep_cnt++;
}
cJSON_Delete(root);
if(G.season_cnt>0) sort_seasons();
if(G.ep_cnt>0) sort_eps();
// Refresh seen in case SD changed
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
return (G.season_cnt>0 && G.ep_cnt>0);
}
void lru_check(){
typedef struct{ char path[300]; time_t mt; } FE;
// Allocate from PSRAM to avoid stack overflow (200*~304=60KB on stack would overflow 12KB task)
FE *files = heap_caps_malloc(200 * sizeof(FE), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!files) return;
int fc=0;
DIR* d=opendir(DM_DIR);
if(!d){ heap_caps_free(files); return; }
struct dirent* de;
char cur_path[300]={0};
if(G.cur_ep_idx>=0 && G.cur_ep_idx<G.ep_cnt){
make_sd_path(cur_path, sizeof(cur_path), G.eps[G.cur_ep_idx].slug);
}
while((de=readdir(d))!=NULL && fc<200){
if(de->d_name[0]=='.') continue;
size_t l=strlen(de->d_name);
if(l<5) continue;
if(strcmp(de->d_name+l-4,".mp3")!=0) continue;
char full[300];
snprintf(full,sizeof(full),"%s/%.200s",DM_DIR,de->d_name);
struct stat st;
if(stat(full,&st)!=0) continue;
if(cur_path[0] && strcmp(full,cur_path)==0) continue;
strncpy(files[fc].path,full,sizeof(files[fc].path)-1);
files[fc].mt=st.st_mtime;
fc++;
}
closedir(d);
if(fc<12){ heap_caps_free(files); return; }
for(int i=0;i<fc;i++){
for(int j=i+1;j<fc;j++){
if(files[j].mt<files[i].mt){ FE t=files[i]; files[i]=files[j]; files[j]=t; }
}
}
int todel=fc-8;
for(int i=0;i<todel;i++) unlink(files[i].path);
heap_caps_free(files);
}
@@ -0,0 +1,42 @@
#pragma once
#include "app_context.h"
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Path helpers
void make_sd_path(char* out, size_t out_len, const char* slug);
void ensure_dir(void);
bool has_slug(const char* slug);
// LRU cache cleanup
void lru_check(void);
// Sorting
void sort_seasons(void);
void sort_eps(void);
// Time formatting
void fmt_time(char* o, int s);
// Per-episode resume helpers
int find_saved_pos_idx(const char* slug);
int get_saved_pos_for_slug(const char* slug);
void set_saved_pos_for_slug(const char* slug, int pos);
void save_current_ep_pos(void);
// Persistence
void save_state(void);
void load_state(void);
// Seasons / episodes cache (PB data, rarely changes)
bool save_cache(void);
bool load_cache(void);
#ifdef __cplusplus
}
#endif
+554
View File
@@ -0,0 +1,554 @@
#include "ui.h"
#include "storage.h"
#include "player.h"
#include "download.h"
#include <tt_lvgl.h>
#include <tt_app.h>
#include <tt_bundle.h>
#include <tactility/lvgl_fonts.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include "esp_log.h"
#include "esp_heap_caps.h"
void close_overlay(){
if(G.overlay){
lv_obj_delete(G.overlay);
G.overlay=NULL;
G.dropdown=NULL;
G.season_list_cont=NULL;
}
}
static void overlay_close_timer_cb(lv_timer_t* t){
close_overlay();
lv_timer_delete(t);
}
static void close_overlay_safe(void){
// Defer deletion to avoid deleting ancestor of current event target (season buttons inside overlay)
lv_timer_create(overlay_close_timer_cb, 20, NULL);
}
// Forward decls
static void ep_action_cb(lv_event_t* e);
static void season_select_cb(lv_event_t* e);
static void rebuild_episode_list_locked(void);
static void clear_container(lv_obj_t* cont){
if(!cont) return;
uint32_t child_cnt = lv_obj_get_child_cnt(cont);
while(child_cnt>0){
lv_obj_t* child = lv_obj_get_child(cont, 0);
if(child) lv_obj_delete(child);
else break;
child_cnt = lv_obj_get_child_cnt(cont);
}
}
static void btn_close_cb(lv_event_t* e){
(void)e;
if(G.overlay){
close_overlay();
return;
}
tt_app_stop();
}
static void get_season_stats(int season, int* total, int* cached){
int t=0,c=0;
for(int i=0;i<G.ep_cnt;i++){
if(G.eps[i].season==season){
t++;
if(G.eps[i].seen) c++;
}
}
if(total) *total=t;
if(cached) *cached=c;
}
void ui_update_status_locked(const char* msg){
if(G.lbl_status && msg) lv_label_set_text(G.lbl_status, msg);
}
void ui_update_header_locked(void){
if(!G.lbl_title && !G.lbl_season) return;
if(G.fetching){
if(G.lbl_season) lv_label_set_text(G.lbl_season, "Fetching...");
return;
}
if(G.season_cnt==0){
if(G.lbl_season) lv_label_set_text(G.lbl_season, "No seasons");
return;
}
int total=0,cached=0;
get_season_stats(G.cur_season,&total,&cached);
char buf[64];
if(total>0){
snprintf(buf,sizeof(buf),"S%02d • %d/%d on SD • %d eps", G.cur_season, cached, total, total);
}else{
snprintf(buf,sizeof(buf),"S%02d • %d eps", G.cur_season, total);
}
if(G.lbl_season) lv_label_set_text(G.lbl_season, buf);
if(G.lbl_title){
// Keep app name
// optionally update title to count
lv_label_set_text(G.lbl_title, "Discovery Mountain");
}
}
static void rebuild_episode_list_locked(void){
if(!G.ep_list_cont) return;
clear_container(G.ep_list_cont);
if(G.fetching){
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
lv_label_set_text(lbl,"Fetching episodes...");
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
lv_obj_align(lbl,LV_ALIGN_TOP_MID,0,20);
return;
}
if(G.ep_cnt==0){
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
lv_label_set_text(lbl,"No episodes");
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
return;
}
// Count episodes for cur season
int count=0;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season) count++;
if(count==0){
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
lv_label_set_text(lbl,"No episodes in season");
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
// Also show all if fallback?
// list first few from all
return;
}
for(int i=0;i<G.ep_cnt;i++){
if(G.eps[i].season!=G.cur_season) continue;
EpInfo* ep=&G.eps[i];
// Row container optimized for low internal heap (4 objs per ep)
lv_obj_t* row=lv_obj_create(G.ep_list_cont);
lv_obj_set_size(row, LV_PCT(100), 50);
lv_obj_set_style_bg_color(row, ep->seen ? lv_color_hex(0x1E2A22) : lv_color_hex(0x222222),0);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER,0);
lv_obj_set_style_radius(row,8,0);
lv_obj_set_style_pad_all(row,6,0);
lv_obj_set_style_border_width(row,1,0);
lv_obj_set_style_border_color(row, ep->seen ? lv_color_hex(0x2E7D32) : lv_color_hex(0x333333),0);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_pad_gap(row,6,0);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(row, ep_action_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
// Left single label with title (truncated) we embed meta as second line via \n to save objects
lv_obj_t* ltitle=lv_label_create(row);
lv_obj_set_flex_grow(ltitle,1);
// Truncate title to ~26 chars for display
char short_title[30];
strncpy(short_title, ep->title, sizeof(short_title)-1);
short_title[sizeof(short_title)-1]=0;
if(strlen(ep->title) > 26){
short_title[23]='.';
short_title[24]='.';
short_title[25]='.';
short_title[26]=0;
}
char line1[64];
snprintf(line1,sizeof(line1),"E%02d: %s", ep->ep_num, short_title);
// Second line status
char line2[32];
int saved = get_saved_pos_for_slug(ep->slug);
if(ep->seen){
if(saved>5){
char tb[16]; fmt_time(tb,saved);
snprintf(line2,sizeof(line2),"On SD %s", tb);
}else{
snprintf(line2,sizeof(line2),"On SD");
}
}else{
snprintf(line2,sizeof(line2),"Not DL");
}
char tbuf[96];
snprintf(tbuf,sizeof(tbuf),"%s\n%s", line1, line2);
lv_label_set_text(ltitle, tbuf);
lv_obj_set_width(ltitle, LV_PCT(100));
lv_label_set_long_mode(ltitle, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_font(ltitle, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(ltitle, ep->seen ? lv_color_hex(0xE0E0E0) : lv_color_hex(0xCCCCCC),0);
// Right side: button
lv_obj_t* btn=lv_btn_create(row);
lv_obj_set_size(btn, 52, 34);
if(ep->seen){
lv_obj_set_style_bg_color(btn, lv_color_hex(0x2E7D32),0);
}else{
lv_obj_set_style_bg_color(btn, lv_color_hex(0x1E88E5),0);
}
lv_obj_set_style_radius(btn,6,0);
lv_obj_t* bl=lv_label_create(btn);
lv_label_set_text(bl, ep->seen ? LV_SYMBOL_PLAY : LV_SYMBOL_DOWNLOAD);
lv_obj_center(bl);
lv_obj_add_event_cb(btn, ep_action_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
}
// Scroll to top after rebuild
lv_obj_scroll_to_y(G.ep_list_cont, 0, LV_ANIM_OFF);
}
void ui_rebuild_episode_list(void){
tt_lvgl_lock(portMAX_DELAY);
if(G.ep_list_cont) rebuild_episode_list_locked();
ui_update_header_locked();
tt_lvgl_unlock();
}
static void ep_action_cb(lv_event_t* e){
if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return;
// Retrieve idx from user data
int idx = (int)(intptr_t)lv_event_get_user_data(e);
// Fallback: try event target's user data via current target?
// lv_event_get_user_data should already give us the idx
if(idx<0 || idx>=G.ep_cnt) return;
if(G.downloading || G.fetching) return;
EpInfo* ep=&G.eps[idx];
ESP_LOGI(TAG,"ep_action idx %d S%02dE%02d seen %d", idx, ep->season, ep->ep_num, ep->seen);
if(ep->season != G.cur_season){
// Shouldn't happen but update cur season to match
G.cur_season = ep->season;
}
// Use start_idx which handles both DL and Play
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
if(ep->seen) lv_label_set_text(G.lbl_status,"Starting player...");
else lv_label_set_text(G.lbl_status,"Starting downloader...");
}
tt_lvgl_unlock();
start_idx(idx);
}
static void season_select_cb(lv_event_t* e){
if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return;
int season_num = (int)(intptr_t)lv_event_get_user_data(e);
if(season_num<=0) return;
ESP_LOGI(TAG,"Season selected S%02d", season_num);
// Defer overlay deletion deleting ancestor during event is unsafe
close_overlay_safe();
G.cur_season = season_num;
// Save state immediately
tt_lvgl_lock(portMAX_DELAY);
ui_update_header_locked();
rebuild_episode_list_locked();
if(G.lbl_status){
char buf[40];
snprintf(buf,sizeof(buf),"Switched to S%02d", season_num);
lv_label_set_text(G.lbl_status, buf);
}
tt_lvgl_unlock();
save_state();
// Jump to first unseen or first in season
int first=-1, first_unseen=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==season_num){
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){
// Optional: select ep updates title but we already rebuilt list; keep cur_ep_idx
G.cur_ep_idx = tgt;
strncpy(G.last_slug, G.eps[tgt].slug, sizeof(G.last_slug)-1);
save_state();
}
}
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_safe();
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;
G.cur_ep_idx=tgt;
strncpy(G.last_slug, G.eps[tgt].slug, sizeof(G.last_slug)-1);
tt_lvgl_lock(portMAX_DELAY);
ui_update_header_locked();
rebuild_episode_list_locked();
tt_lvgl_unlock();
save_state();
}
}
static void close_btn_cb(lv_event_t* e){
(void)e;
close_overlay_safe();
}
void btn_seasons_cb(lv_event_t* e){
(void)e;
if(G.downloading) return;
if(G.fetching) return;
if(G.overlay){ close_overlay(); return; }
tt_lvgl_lock(portMAX_DELAY);
// Create overlay centered modal parented to root if possible to avoid lingering on app background
lv_obj_t* ovl_parent = G.root ? G.root : lv_scr_act();
G.overlay=lv_obj_create(ovl_parent);
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_set_style_border_width(G.overlay,1,0);
lv_obj_set_style_border_color(G.overlay, lv_color_hex(0x444444),0);
lv_obj_set_style_pad_all(G.overlay,8,0);
lv_obj_set_flex_flow(G.overlay, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(G.overlay, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_gap(G.overlay,6,0);
lv_obj_t* title=lv_label_create(G.overlay);
lv_label_set_text(title,"Select Season");
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_DEFAULT),0);
// Scrollable list container
G.season_list_cont=lv_obj_create(G.overlay);
lv_obj_set_size(G.season_list_cont, LV_PCT(100), 200);
lv_obj_set_flex_grow(G.season_list_cont,1);
lv_obj_set_style_bg_color(G.season_list_cont, lv_color_hex(0x1A1A1A),0);
lv_obj_set_style_radius(G.season_list_cont,8,0);
lv_obj_set_style_pad_all(G.season_list_cont,4,0);
lv_obj_set_flex_flow(G.season_list_cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_gap(G.season_list_cont,4,0);
// Ensure scrollable
lv_obj_add_flag(G.season_list_cont, LV_OBJ_FLAG_SCROLLABLE);
for(int i=0;i<G.season_cnt;i++){
int sn=G.seasons[i].num;
int total,cached;
get_season_stats(sn,&total,&cached);
lv_obj_t* btn=lv_btn_create(G.season_list_cont);
lv_obj_set_size(btn, LV_PCT(100), 38);
lv_obj_set_style_bg_color(btn, sn==G.cur_season ? lv_color_hex(0x2E7D32) : lv_color_hex(0x333333),0);
lv_obj_set_style_radius(btn,6,0);
lv_obj_set_style_pad_all(btn,6,0);
lv_obj_add_event_cb(btn, season_select_cb, LV_EVENT_CLICKED, (void*)(intptr_t)sn);
char blabel[40];
if(total>0){
if(sn==G.cur_season) snprintf(blabel,sizeof(blabel),"S%02d %d/%d " LV_SYMBOL_OK, sn, cached, total);
else snprintf(blabel,sizeof(blabel),"S%02d %d/%d", sn, cached, total);
}else{
if(sn==G.cur_season) snprintf(blabel,sizeof(blabel),"S%02d " LV_SYMBOL_OK, sn);
else snprintf(blabel,sizeof(blabel),"S%02d", sn);
}
lv_obj_t* l1=lv_label_create(btn);
lv_label_set_text(l1, blabel);
lv_obj_set_style_text_font(l1, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(l1);
}
lv_obj_t* bc=lv_btn_create(G.overlay);
lv_obj_set_size(bc, LV_PCT(60), 36);
lv_obj_set_style_bg_color(bc, lv_color_hex(0x444444),0);
lv_obj_t* lc=lv_label_create(bc);
lv_label_set_text(lc,"Close");
lv_obj_center(lc);
lv_obj_add_event_cb(bc,close_btn_cb,LV_EVENT_CLICKED,NULL);
tt_lvgl_unlock();
}
void btn_dl_season_cb(lv_event_t* e){
(void)e;
if(G.downloading) return;
if(G.fetching) return;
if(G.ep_cnt==0) return;
int season = G.cur_season;
ESP_LOGI(TAG, "DL Missing S%02d pressed", season);
tt_lvgl_lock(portMAX_DELAY);
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
if(G.lbl_status){
char buf[40];
snprintf(buf,sizeof(buf),"Checking S%02d missing...", season);
lv_label_set_text(G.lbl_status, buf);
}
tt_lvgl_unlock();
if (!download_season(season)) {
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"DL Season failed no missing?");
tt_lvgl_unlock();
}
}
void build_ui(lv_obj_t* parent){
tt_lvgl_lock(portMAX_DELAY);
lv_obj_t* root = lv_obj_create(parent);
G.root = root;
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,6,0);
lv_obj_set_style_pad_gap(root,6,0);
lv_obj_set_style_border_width(root,0,0);
lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
// Header container
lv_obj_t* header=lv_obj_create(root);
lv_obj_set_size(header, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(header, LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(header,0,0);
lv_obj_set_style_pad_all(header,2,0);
lv_obj_set_style_pad_gap(header,6,0);
lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(header, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(header, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* header_left=lv_obj_create(header);
lv_obj_set_size(header_left, LV_PCT(80), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(header_left, LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(header_left,0,0);
lv_obj_set_style_pad_all(header_left,0,0);
lv_obj_set_flex_flow(header_left, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_gap(header_left,2,0);
lv_obj_remove_flag(header_left, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_grow(header_left,1);
G.lbl_title=lv_label_create(header_left);
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_style_text_color(G.lbl_title, lv_color_hex(0xFFFFFF),0);
G.lbl_season=lv_label_create(header_left);
lv_label_set_text(G.lbl_season,"Loading seasons...");
lv_obj_set_style_text_font(G.lbl_season, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(G.lbl_season,lv_color_hex(0xAAAAAA),0);
// Close button top right
lv_obj_t* btn_close=lv_btn_create(header);
lv_obj_set_size(btn_close, 36, 32);
lv_obj_set_style_bg_color(btn_close, lv_color_hex(0x333333),0);
lv_obj_set_style_radius(btn_close,6,0);
lv_obj_t* l_close=lv_label_create(btn_close);
lv_label_set_text(l_close, LV_SYMBOL_CLOSE);
lv_obj_set_style_text_font(l_close, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(l_close);
lv_obj_add_event_cb(btn_close, btn_close_cb, LV_EVENT_CLICKED, NULL);
// Action row: Seasons + DL Missing
lv_obj_t* row=lv_obj_create(root);
lv_obj_set_size(row,LV_PCT(100),44);
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_set_style_pad_all(row,0,0);
lv_obj_set_style_pad_gap(row,8,0);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* bs=lv_btn_create(row);
lv_obj_set_size(bs,LV_PCT(48),36);
G.btn_seasons=bs;
lv_obj_set_style_bg_color(bs, lv_color_hex(0x333333),0);
lv_obj_set_style_radius(bs,6,0);
lv_obj_t* ls=lv_label_create(bs);
lv_label_set_text(ls,"Seasons");
lv_obj_set_style_text_font(ls, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(ls);
lv_obj_add_event_cb(bs,btn_seasons_cb,LV_EVENT_CLICKED,NULL);
lv_obj_t* bd=lv_btn_create(row);
lv_obj_set_size(bd,LV_PCT(48),36);
G.btn_dl_missing=bd;
lv_obj_set_style_bg_color(bd, lv_color_hex(0x1E88E5), 0);
lv_obj_set_style_radius(bd,6,0);
lv_obj_t* ld=lv_label_create(bd);
lv_label_set_text(ld,"DL Missing");
lv_obj_set_style_text_font(ld, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(ld);
lv_obj_add_event_cb(bd,btn_dl_season_cb,LV_EVENT_CLICKED,NULL);
// Episode list container fills remaining space
G.ep_list_cont=lv_obj_create(root);
lv_obj_set_size(G.ep_list_cont, LV_PCT(100), LV_PCT(100));
lv_obj_set_flex_grow(G.ep_list_cont, 1);
lv_obj_set_style_bg_color(G.ep_list_cont, lv_color_hex(0x0E0E0E),0);
lv_obj_set_style_bg_opa(G.ep_list_cont, LV_OPA_COVER,0);
lv_obj_set_style_radius(G.ep_list_cont,8,0);
lv_obj_set_style_pad_all(G.ep_list_cont,4,0);
lv_obj_set_style_pad_gap(G.ep_list_cont,4,0);
lv_obj_set_flex_flow(G.ep_list_cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(G.ep_list_cont, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
// Ensure scrollable
lv_obj_add_flag(G.ep_list_cont, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(G.ep_list_cont, LV_SCROLLBAR_MODE_AUTO);
lv_obj_t* hint=lv_label_create(G.ep_list_cont);
lv_label_set_text(hint,"Loading episodes...");
lv_obj_set_style_text_color(hint, lv_color_hex(0x888888),0);
lv_obj_set_style_text_font(hint, lvgl_get_text_font(FONT_SIZE_SMALL),0);
// Status bar at bottom
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_set_style_text_font(G.lbl_status, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_width(G.lbl_status, LV_PCT(100));
lv_label_set_long_mode(G.lbl_status, LV_LABEL_LONG_DOT);
// Legacy placeholders nulled
G.lbl_meta=NULL;
G.bar=NULL;
G.lbl_time=NULL;
G.btn_play=NULL;
G.dl_overlay=NULL;
G.ui_timer_handle=NULL;
G.ui_timer_handle = lv_timer_create(ui_timer,300,NULL);
tt_lvgl_unlock();
}
void ui_deinit(void){
if(G.ui_timer_handle){
lv_timer_delete(G.ui_timer_handle);
G.ui_timer_handle=NULL;
}
}
void ui_timer(lv_timer_t* t){
(void)t;
// If app is closing (root null) skip
if(!G.root) return;
if(G.fetching){
return;
}
if(G.downloading && G.dl_overlay){
update_dl_overlay_ui();
}
}
void btn_play_cb(lv_event_t* e){ (void)e; /* legacy, not used */ }
void btn_prev_cb(lv_event_t* e){ (void)e; /* legacy */ }
void btn_next_cb(lv_event_t* e){ (void)e; /* legacy */ }
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "app_context.h"
#ifdef __cplusplus
extern "C" {
#endif
void build_ui(lv_obj_t* parent);
void ui_timer(lv_timer_t* t);
void btn_play_cb(lv_event_t* e);
void btn_prev_cb(lv_event_t* e);
void btn_next_cb(lv_event_t* e);
void btn_seasons_cb(lv_event_t* e);
void btn_dl_season_cb(lv_event_t* e);
void close_overlay(void);
void dropdown_cb(lv_event_t* e);
// New library UI helpers
void ui_rebuild_episode_list(void);
void ui_update_header_locked(void);
void ui_update_status_locked(const char* msg);
void ui_deinit(void);
#ifdef __cplusplus
}
#endif