Files
tactility_apps/Apps/DiscoveryMountain/main/Source/main.c
T
Adolfo d4a257a01c feat(DM): instant cache load, manual refresh, full-screen season selector, season persistence fix
- Cache loads synchronously on onShow for instant startup, no auto fetch
- Added manual Refresh button, removed auto refresh that blocked UI
- Made UI non-blocking during fetch (episode list usable while fetching)
- Fixed season loss after player/downloader by preserving season in static s_preserved_season surviving G memset
- Season selector now full-screen overlay parented to lv_scr_act()
- Removed DL Missing batch button and bottom help/status label per UX request
- load_state no longer overwrites browsing season with last_slug season
2026-07-30 16:13:47 -04:00

492 lines
18 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"
// Preserved browsing season that survives G memset across external app launch/return
// This fixes season reset to 1 when returning from player/downloader where file read may race
static int s_preserved_season = -1;
void dm_preserve_season(int season){
if(season>0) s_preserved_season = season;
}
int dm_consume_preserved_season(void){
int s = s_preserved_season;
s_preserved_season = -1;
return s;
}
static void fetch_task_fn(void* arg){
(void)arg;
G.fetching=true;
G.fetch_cancel_req=false;
ESP_LOGI(TAG,"fetch task start (manual refresh)");
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Fetching from network...");
tt_lvgl_unlock();
// Preserve user's current season selection across fetch
int preserved_season = G.cur_season;
char preserved_slug[96]={0};
if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt){
strncpy(preserved_slug, G.eps[G.cur_ep_idx].slug, sizeof(preserved_slug)-1);
} else if(strlen(G.last_slug)>0){
strncpy(preserved_slug, G.last_slug, sizeof(preserved_slug)-1);
}
bool fetch_ok = fetch_data();
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"fetch task canceled");
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 {
ESP_LOGW(TAG,"Network fetch failed");
// Do not overwrite current data if fetch fails – keep existing cache in memory
// If we have no data at all, fetch_data already created fallback
}
ESP_LOGI(TAG,"fetch done seasons=%d eps=%d fetch_ok=%d", G.season_cnt, G.ep_cnt, fetch_ok);
// Restore preserved season if it still exists
if(preserved_season>0){
bool season_exists=false;
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==preserved_season){ season_exists=true; break; }
if(season_exists){
G.cur_season = preserved_season;
// try to restore slug within preserved season
int best=-1;
if(strlen(preserved_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==preserved_season && strcmp(G.eps[i].slug,preserved_slug)==0){ best=i; break; }
}
if(best==-1){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==preserved_season){ best=i; break; }
}
if(best!=-1) G.cur_ep_idx=best;
strncpy(G.last_slug, preserved_slug, sizeof(G.last_slug)-1);
} else {
// Fallback to preserved slug or first
if(strlen(preserved_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,preserved_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
if(G.cur_ep_idx<0 && G.ep_cnt>0){
G.cur_ep_idx=0;
G.cur_season=G.eps[0].season;
}
}
} else {
if(G.cur_ep_idx<0 && G.ep_cnt>0){
G.cur_ep_idx=0;
G.cur_season=G.eps[0].season;
}
}
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, "Refreshed – select episode");
else lv_label_set_text(G.lbl_status, "Refresh failed – using cache");
} else {
lv_label_set_text(G.lbl_status,"No data – check network");
}
}
tt_lvgl_unlock();
G.fetch_handle=NULL;
save_state();
vTaskDelete(NULL);
}
void trigger_manual_refresh(void){
if(G.fetching){
ESP_LOGI(TAG,"refresh already in progress");
return;
}
if(G.fetch_handle){
ESP_LOGW(TAG,"fetch handle still present, not starting new");
return;
}
G.fetch_cancel_req=false;
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,"trigger_manual_refresh heap: 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 manual refresh 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 start refresh");
tt_lvgl_unlock();
G.fetch_handle=NULL;
}
}
static void onShow(AppHandle app, void* data, lv_obj_t* parent){
(void)app; (void)data; (void)parent;
// Check for preserved season from external app launch (survives G memset)
int preserved = dm_consume_preserved_season();
if(preserved>0){
ESP_LOGI(TAG,"onShow preserved season S%02d from previous launch", preserved);
}
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();
// If we have a preserved season from launching player/downloader, it overrides file
if(preserved>0){
G.cur_season = preserved;
ESP_LOGI(TAG,"onShow overriding cur_season with preserved S%02d", preserved);
}
build_ui(parent);
// Instant cache load – no network, fully synchronous for fast startup
// NOTE: load_state() already loaded last_season (browsing season) and last_slug (last played)
// We want to preserve browsing season, NOT overwrite it with last_slug's season.
bool cache_loaded = load_cache();
if(cache_loaded){
ESP_LOGI(TAG,"Cache loaded instantly: %d seasons, %d eps browsing S%02d last_slug=%s preserved=%d", G.season_cnt, G.ep_cnt, G.cur_season, G.last_slug, preserved);
// Validate browsing season still exists, otherwise fallback to first season
bool season_exists=false;
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==G.cur_season){ season_exists=true; break; }
if(!season_exists){
// If we had a preserved season but it's not in seasons list, maybe list incomplete – try preserved again
if(preserved>0){
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==preserved){ season_exists=true; G.cur_season=preserved; break; }
}
if(!season_exists && G.season_cnt>0){
ESP_LOGW(TAG,"Browsing season S%02d not found in cache, fallback to S%02d", G.cur_season, G.seasons[0].num);
G.cur_season = G.seasons[0].num;
season_exists=true;
}
}
// Resolve cur_ep_idx: prefer last_slug if it is within browsing season, otherwise first ep in browsing season
G.cur_ep_idx=-1;
if(strlen(G.last_slug)>0 && season_exists){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season && strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; break; }
}
if(G.cur_ep_idx<0){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ G.cur_ep_idx=i; break; }
}
if(G.cur_ep_idx<0 && G.ep_cnt>0){
G.cur_ep_idx=0;
G.cur_season=G.eps[0].season;
}
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Loaded from cache – tap Refresh to update");
tt_lvgl_unlock();
} else {
ESP_LOGI(TAG,"No cache at startup, cur_season S%02d preserved %d", G.cur_season, preserved);
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"No cache – tap Refresh");
tt_lvgl_unlock();
ui_rebuild_episode_list();
}
// Final safeguard: if preserved was set, ensure it is saved back to state file so next open keeps it
if(preserved>0){
save_state();
}
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 after cache load: internal=%d psram=%d cache=%d cur_season=%d", free_internal, free_psram, cache_loaded, G.cur_season);
}
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();
// Handle sequential full-season download queue (safe single-file mode)
if(G.dl_missing_active){
if(success) G.dl_missing_done++;
else G.dl_missing_failed++;
int next = find_next_missing_in_season(G.dl_missing_season);
if(next!=-1){
ESP_LOGI(TAG, "Batch queue continuing: %d/%d done, next idx %d", G.dl_missing_done, G.dl_missing_total, next);
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL %d/%d S%02d...", G.dl_missing_done+1, G.dl_missing_total, G.dl_missing_season);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
// Small delay to let UI update before launching next downloader
vTaskDelay(pdMS_TO_TICKS(300));
if(download_episode(next)){
// started next
return;
} else {
// failed to start next, abort queue
G.dl_missing_active=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL stopped %d ok %d fail", G.dl_missing_done, G.dl_missing_failed);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return;
}
} else {
// No more missing – queue finished
ESP_LOGI(TAG, "Batch queue finished: %d ok %d fail", G.dl_missing_done, G.dl_missing_failed);
G.dl_missing_active=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL S%02d done %d ok %d fail", G.dl_missing_season, G.dl_missing_done, G.dl_missing_failed);
lv_label_set_text(G.lbl_status, msg);
}
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);
return;
}
}
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;
G.dl_missing_active=false;
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.btn_refresh = 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;
}