Files
tactility_apps/Apps/DiscoveryMountain/main/Source/main.c
T
Adolfo 3778cb3399 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...'
2026-07-25 22:06:43 -04:00

384 lines
13 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_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;
}