From be9229b80dc32f496a257dc15ef1d74401c39a82 Mon Sep 17 00:00:00 2001 From: Adolfo Date: Wed, 22 Jul 2026 17:11:59 -0400 Subject: [PATCH] refactor(DiscoveryMountain): split monolithic main.c into modular files - Extracted app_context (global AppCtx G definition) - storage: path helpers, LRU, sort, fmt_time, per-episode resume map, state persistence - network: resolve_host, normalize URL, http_get_raw with growing PSRAM buffer, fetch_data - download: dl_ep_raw with progress tracking, cancel, overlay UI (show/hide/update) - player: audio device, stream, play_task with resume seek, start/select/next/prev with position save/restore - ui: build_ui, timers, season picker, button callbacks, periodic save - main: minimal glue, fetch_task, onShow/onHide, app registration - Preserves all previous fixes: download progress overlay, per-episode resume, robust download handling - Build verified: 111 undef, all exported --- .../main/Source/app_context.c | 3 + .../main/Source/app_context.h | 87 + Apps/DiscoveryMountain/main/Source/download.c | 393 +++++ Apps/DiscoveryMountain/main/Source/download.h | 24 + Apps/DiscoveryMountain/main/Source/main.c | 1527 +---------------- Apps/DiscoveryMountain/main/Source/network.c | 249 +++ Apps/DiscoveryMountain/main/Source/network.h | 23 + Apps/DiscoveryMountain/main/Source/player.c | 344 ++++ Apps/DiscoveryMountain/main/Source/player.h | 25 + Apps/DiscoveryMountain/main/Source/storage.c | 203 +++ Apps/DiscoveryMountain/main/Source/storage.h | 38 + Apps/DiscoveryMountain/main/Source/ui.c | 244 +++ Apps/DiscoveryMountain/main/Source/ui.h | 21 + 13 files changed, 1662 insertions(+), 1519 deletions(-) create mode 100644 Apps/DiscoveryMountain/main/Source/app_context.c create mode 100644 Apps/DiscoveryMountain/main/Source/app_context.h create mode 100644 Apps/DiscoveryMountain/main/Source/download.c create mode 100644 Apps/DiscoveryMountain/main/Source/download.h create mode 100644 Apps/DiscoveryMountain/main/Source/network.c create mode 100644 Apps/DiscoveryMountain/main/Source/network.h create mode 100644 Apps/DiscoveryMountain/main/Source/player.c create mode 100644 Apps/DiscoveryMountain/main/Source/player.h create mode 100644 Apps/DiscoveryMountain/main/Source/storage.c create mode 100644 Apps/DiscoveryMountain/main/Source/storage.h create mode 100644 Apps/DiscoveryMountain/main/Source/ui.c create mode 100644 Apps/DiscoveryMountain/main/Source/ui.h diff --git a/Apps/DiscoveryMountain/main/Source/app_context.c b/Apps/DiscoveryMountain/main/Source/app_context.c new file mode 100644 index 0000000..5fdadde --- /dev/null +++ b/Apps/DiscoveryMountain/main/Source/app_context.c @@ -0,0 +1,3 @@ +#include "app_context.h" + +AppCtx G = {0}; diff --git a/Apps/DiscoveryMountain/main/Source/app_context.h b/Apps/DiscoveryMountain/main/Source/app_context.h new file mode 100644 index 0000000..3c330f6 --- /dev/null +++ b/Apps/DiscoveryMountain/main/Source/app_context.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#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 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; + +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 *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 + SavedPos saved_pos[MAX_EPS]; + int saved_pos_cnt; + int last_save_tick; +} AppCtx; + +extern AppCtx G; + +#ifdef __cplusplus +} +#endif diff --git a/Apps/DiscoveryMountain/main/Source/download.c b/Apps/DiscoveryMountain/main/Source/download.c new file mode 100644 index 0000000..8e3fb8c --- /dev/null +++ b/Apps/DiscoveryMountain/main/Source/download.c @@ -0,0 +1,393 @@ +#include "download.h" +#include "storage.h" +#include "network.h" +#include "player.h" +#include +#include +#include +#include +#include +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include +#include + +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..."); +} + +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){ + 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); +} + +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 { + 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){ + 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); + } +} + +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); + 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"); + + 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; + + 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; + } + } + + ESP_LOGI(TAG,"dl streaming body cl=%d",content_len); + int consecutive_timeouts=0; + const int max_timeouts=12; + 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){ + 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){ + 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; + } + + 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){ + 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); + return false; + } + ep->seen=true; + return true; +} + +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); +} + +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); +} diff --git a/Apps/DiscoveryMountain/main/Source/download.h b/Apps/DiscoveryMountain/main/Source/download.h new file mode 100644 index 0000000..be44218 --- /dev/null +++ b/Apps/DiscoveryMountain/main/Source/download.h @@ -0,0 +1,24 @@ +#pragma once + +#include "app_context.h" +#include + +#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 diff --git a/Apps/DiscoveryMountain/main/Source/main.c b/Apps/DiscoveryMountain/main/Source/main.c index 1daea3f..adb15d7 100644 --- a/Apps/DiscoveryMountain/main/Source/main.c +++ b/Apps/DiscoveryMountain/main/Source/main.c @@ -1,1523 +1,16 @@ #include #include -#include -#include -#include -#include -#include +#include "app_context.h" +#include "storage.h" +#include "network.h" +#include "download.h" +#include "player.h" +#include "ui.h" + #include -#include -#include -#include -#include -#include -#include -#include -#include #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=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;i0 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;ivaluestring)==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;iseason != 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(pos16){ 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=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;ivalueint; 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;ititle,t->valuestring,sizeof(e->title)-1); - if(slug&&cJSON_IsString(slug)) strncpy(e->slug,slug->valuestring,sizeof(e->slug)-1); - else if(t) strncpy(e->slug,t->valuestring,sizeof(e->slug)-1); - e->season=sn->valueint; - e->ep_num=en->valueint; - if(au&&cJSON_IsString(au)) strncpy(e->audio_url,au->valuestring,sizeof(e->audio_url)-1); - e->seen=has_slug(e->slug); - G.ep_cnt++; - } - 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=0 && G.cur_ep_idxd_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;iaudio_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;i32767) 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(off0 && 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;i0 && 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(saved==0) saved = G.pos_sec; if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title); if(G.lbl_meta){ char b[64]; @@ -1567,7 +60,6 @@ static void fetch_task_fn(void* arg){ 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"); @@ -1579,7 +71,6 @@ static void fetch_task_fn(void* arg){ vTaskDelete(NULL); } - static void onShow(AppHandle app, void* data, lv_obj_t* parent){ (void)app; (void)data; (void)parent; memset(&G,0,sizeof(G)); @@ -1599,7 +90,6 @@ static void onShow(AppHandle app, void* data, lv_obj_t* parent){ 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){ @@ -1608,7 +98,6 @@ static void onHide(AppHandle app, void* data){ 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){ diff --git a/Apps/DiscoveryMountain/main/Source/network.c b/Apps/DiscoveryMountain/main/Source/network.c new file mode 100644 index 0000000..0be868b --- /dev/null +++ b/Apps/DiscoveryMountain/main/Source/network.c @@ -0,0 +1,249 @@ +#include "network.h" +#include "storage.h" +#include "cJSON.h" +#include +#include +#include +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include + +uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>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; + 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(pos16){ 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=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; +} + +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;ivalueint; 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;ititle,t->valuestring,sizeof(e->title)-1); + if(slug&&cJSON_IsString(slug)) strncpy(e->slug,slug->valuestring,sizeof(e->slug)-1); + else if(t) strncpy(e->slug,t->valuestring,sizeof(e->slug)-1); + e->season=sn->valueint; + e->ep_num=en->valueint; + if(au&&cJSON_IsString(au)) strncpy(e->audio_url,au->valuestring,sizeof(e->audio_url)-1); + e->seen=has_slug(e->slug); + G.ep_cnt++; + } + 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 +#include + +#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 +void fetch_data(void); + +#ifdef __cplusplus +} +#endif diff --git a/Apps/DiscoveryMountain/main/Source/player.c b/Apps/DiscoveryMountain/main/Source/player.c new file mode 100644 index 0000000..7083ec7 --- /dev/null +++ b/Apps/DiscoveryMountain/main/Source/player.c @@ -0,0 +1,344 @@ +#include "player.h" +#include "storage.h" +#include "download.h" +#include "ui.h" + +#include +#include +#include +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include + +#define MINIMP3_IMPLEMENTATION +#define MINIMP3_NO_SIMD +#include "minimp3.h" + +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; +} +void close_stream(){ + if(G.stream_handle){ + audio_stream_close(G.stream_handle); + G.stream_handle=NULL; + } +} +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; +} + +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(); +} + +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; +} + +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; } + + 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); + } + + int resume_sec = G.pos_sec; + if(resume_sec>5){ + long seek_bytes = (long)resume_sec * 16000; + 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){ + 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(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;i32767) 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(off0 && 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){ + 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; + save_state(); + } else { + 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); +} + +void start_idx_internal(int idx){ + if(idx<0||idx>=G.ep_cnt) return; + EpInfo* ep=&G.eps[idx]; + 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); + } + 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); +} + +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_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); +} + +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); + if(saved>5){ + G.pos_sec=saved; + ESP_LOGI(TAG,"select_ep restore %s pos=%d", ep->slug, saved); + } else { + 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(); + stop_playback_sync(); + int n=G.cur_ep_idx+1; + if(n>=G.ep_cnt) n=0; + select_ep(n); + start_idx(n); +} +void go_prev(){ + if(G.ep_cnt==0) return; + if(G.pos_sec>5){ + ESP_LOGI(TAG,"go_prev restart current %d sec",G.pos_sec); + 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(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); +} diff --git a/Apps/DiscoveryMountain/main/Source/player.h b/Apps/DiscoveryMountain/main/Source/player.h new file mode 100644 index 0000000..f2a30aa --- /dev/null +++ b/Apps/DiscoveryMountain/main/Source/player.h @@ -0,0 +1,25 @@ +#pragma once + +#include "app_context.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +bool find_audio_device(void); +void close_stream(void); +bool open_stream(uint32_t sr, uint8_t ch); +void wait_play_exit(void); +void stop_playback_sync(void); + +void play_task(void* arg); +void start_idx_internal(int idx); +void start_idx(int idx); +void select_ep(int idx); +void go_next(void); +void go_prev(void); + +#ifdef __cplusplus +} +#endif diff --git a/Apps/DiscoveryMountain/main/Source/storage.c b/Apps/DiscoveryMountain/main/Source/storage.c new file mode 100644 index 0000000..e893df3 --- /dev/null +++ b/Apps/DiscoveryMountain/main/Source/storage.c @@ -0,0 +1,203 @@ +#include "storage.h" +#include "cJSON.h" +#include +#include +#include +#include +#include +#include + +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;iseason != 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; + } + } + } +} + +// ── Per-episode position helpers ── +int find_saved_pos_idx(const char* slug){ + if(!slug) return -1; + for(int i=0;i=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;i0){ + 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;ivaluestring)==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); +} + +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_idxd_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 +#include + +#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); + +#ifdef __cplusplus +} +#endif diff --git a/Apps/DiscoveryMountain/main/Source/ui.c b/Apps/DiscoveryMountain/main/Source/ui.c new file mode 100644 index 0000000..342bae1 --- /dev/null +++ b/Apps/DiscoveryMountain/main/Source/ui.c @@ -0,0 +1,244 @@ +#include "ui.h" +#include "storage.h" +#include "player.h" +#include "download.h" + +#include +#include +#include +#include +#include "esp_log.h" + +void close_overlay(){ + if(G.overlay){ + lv_obj_delete(G.overlay); + G.overlay=NULL; + G.dropdown=NULL; + } +} + +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;i0){ + 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"); + } + 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(); + } + } +} + +void btn_play_cb(lv_event_t* e){ + (void)e; + if(G.downloading){ + return; + } + if(G.is_playing && !G.is_paused){ + G.is_paused=true; + 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(); + } +} +void btn_prev_cb(lv_event_t* e){ (void)e; if(G.downloading) return; go_prev(); } +void btn_next_cb(lv_event_t* e){ (void)e; if(G.downloading) return; go_next(); } diff --git a/Apps/DiscoveryMountain/main/Source/ui.h b/Apps/DiscoveryMountain/main/Source/ui.h new file mode 100644 index 0000000..88ed77a --- /dev/null +++ b/Apps/DiscoveryMountain/main/Source/ui.h @@ -0,0 +1,21 @@ +#pragma once + +#include "app_context.h" + +#ifdef __cplusplus +extern "C" { +#endif + +void build_ui(void); +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 close_overlay(void); +void dropdown_cb(lv_event_t* e); + +#ifdef __cplusplus +} +#endif