#include "ui.h" #include "storage.h" #include "player.h" #include "download.h" #include #include #include #include #include #include #include #include #include "esp_log.h" #include "esp_heap_caps.h" void close_overlay(){ if(G.overlay){ lv_obj_delete(G.overlay); G.overlay=NULL; G.dropdown=NULL; G.season_list_cont=NULL; } } static void overlay_close_timer_cb(lv_timer_t* t){ close_overlay(); lv_timer_delete(t); } static void close_overlay_safe(void){ // Defer deletion to avoid deleting ancestor of current event target (season buttons inside overlay) lv_timer_create(overlay_close_timer_cb, 20, NULL); } // Forward decls static void ep_action_cb(lv_event_t* e); static void season_select_cb(lv_event_t* e); static void rebuild_episode_list_locked(void); static void clear_container(lv_obj_t* cont){ if(!cont) return; uint32_t child_cnt = lv_obj_get_child_cnt(cont); while(child_cnt>0){ lv_obj_t* child = lv_obj_get_child(cont, 0); if(child) lv_obj_delete(child); else break; child_cnt = lv_obj_get_child_cnt(cont); } } static void btn_close_cb(lv_event_t* e){ (void)e; if(G.overlay){ close_overlay(); return; } tt_app_stop(); } static void get_season_stats(int season, int* total, int* cached){ int t=0,c=0; for(int i=0;i0){ snprintf(buf,sizeof(buf),"S%02d • %d/%d on SD • %d eps", G.cur_season, cached, total, total); }else{ snprintf(buf,sizeof(buf),"S%02d • %d eps", G.cur_season, total); } if(G.lbl_season) lv_label_set_text(G.lbl_season, buf); if(G.lbl_title){ // Keep app name // optionally update title to count lv_label_set_text(G.lbl_title, "Discovery Mountain"); } } static void rebuild_episode_list_locked(void){ if(!G.ep_list_cont) return; clear_container(G.ep_list_cont); if(G.fetching){ lv_obj_t* lbl=lv_label_create(G.ep_list_cont); lv_label_set_text(lbl,"Fetching episodes..."); lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0); lv_obj_align(lbl,LV_ALIGN_TOP_MID,0,20); return; } if(G.ep_cnt==0){ lv_obj_t* lbl=lv_label_create(G.ep_list_cont); lv_label_set_text(lbl,"No episodes"); lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0); return; } // Count episodes for cur season int count=0; for(int i=0;iseen ? lv_color_hex(0x1E2A22) : lv_color_hex(0x222222),0); lv_obj_set_style_bg_opa(row, LV_OPA_COVER,0); lv_obj_set_style_radius(row,8,0); lv_obj_set_style_pad_all(row,6,0); lv_obj_set_style_border_width(row,1,0); lv_obj_set_style_border_color(row, ep->seen ? lv_color_hex(0x2E7D32) : lv_color_hex(0x333333),0); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_pad_gap(row,6,0); lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(row, ep_action_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); // Left single label with title (truncated) – we embed meta as second line via \n to save objects lv_obj_t* ltitle=lv_label_create(row); lv_obj_set_flex_grow(ltitle,1); // Truncate title to ~26 chars for display char short_title[30]; strncpy(short_title, ep->title, sizeof(short_title)-1); short_title[sizeof(short_title)-1]=0; if(strlen(ep->title) > 26){ short_title[23]='.'; short_title[24]='.'; short_title[25]='.'; short_title[26]=0; } char line1[64]; snprintf(line1,sizeof(line1),"E%02d: %s", ep->ep_num, short_title); // Second line status char line2[32]; int saved = get_saved_pos_for_slug(ep->slug); if(ep->seen){ if(saved>5){ char tb[16]; fmt_time(tb,saved); snprintf(line2,sizeof(line2),"On SD %s", tb); }else{ snprintf(line2,sizeof(line2),"On SD"); } }else{ snprintf(line2,sizeof(line2),"Not DL"); } char tbuf[96]; snprintf(tbuf,sizeof(tbuf),"%s\n%s", line1, line2); lv_label_set_text(ltitle, tbuf); lv_obj_set_width(ltitle, LV_PCT(100)); lv_label_set_long_mode(ltitle, LV_LABEL_LONG_DOT); lv_obj_set_style_text_font(ltitle, lvgl_get_text_font(FONT_SIZE_SMALL),0); lv_obj_set_style_text_color(ltitle, ep->seen ? lv_color_hex(0xE0E0E0) : lv_color_hex(0xCCCCCC),0); // Right side: button lv_obj_t* btn=lv_btn_create(row); lv_obj_set_size(btn, 52, 34); if(ep->seen){ lv_obj_set_style_bg_color(btn, lv_color_hex(0x2E7D32),0); }else{ lv_obj_set_style_bg_color(btn, lv_color_hex(0x1E88E5),0); } lv_obj_set_style_radius(btn,6,0); lv_obj_t* bl=lv_label_create(btn); lv_label_set_text(bl, ep->seen ? LV_SYMBOL_PLAY : LV_SYMBOL_DOWNLOAD); lv_obj_center(bl); lv_obj_add_event_cb(btn, ep_action_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); } // Scroll to top after rebuild lv_obj_scroll_to_y(G.ep_list_cont, 0, LV_ANIM_OFF); } void ui_rebuild_episode_list(void){ tt_lvgl_lock(portMAX_DELAY); if(G.ep_list_cont) rebuild_episode_list_locked(); ui_update_header_locked(); tt_lvgl_unlock(); } static void ep_action_cb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return; // Retrieve idx from user data int idx = (int)(intptr_t)lv_event_get_user_data(e); // Fallback: try event target's user data via current target? // lv_event_get_user_data should already give us the idx if(idx<0 || idx>=G.ep_cnt) return; if(G.downloading || G.fetching) return; EpInfo* ep=&G.eps[idx]; ESP_LOGI(TAG,"ep_action idx %d S%02dE%02d seen %d", idx, ep->season, ep->ep_num, ep->seen); if(ep->season != G.cur_season){ // Shouldn't happen but update cur season to match G.cur_season = ep->season; } // Use start_idx which handles both DL and Play tt_lvgl_lock(portMAX_DELAY); if(G.lbl_status){ if(ep->seen) lv_label_set_text(G.lbl_status,"Starting player..."); else lv_label_set_text(G.lbl_status,"Starting downloader..."); } tt_lvgl_unlock(); start_idx(idx); } static void season_select_cb(lv_event_t* e){ if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return; int season_num = (int)(intptr_t)lv_event_get_user_data(e); if(season_num<=0) return; ESP_LOGI(TAG,"Season selected S%02d", season_num); // Defer overlay deletion – deleting ancestor during event is unsafe close_overlay_safe(); G.cur_season = season_num; // Save state immediately tt_lvgl_lock(portMAX_DELAY); ui_update_header_locked(); rebuild_episode_list_locked(); if(G.lbl_status){ char buf[40]; snprintf(buf,sizeof(buf),"Switched to S%02d", season_num); lv_label_set_text(G.lbl_status, buf); } tt_lvgl_unlock(); save_state(); // Jump to first unseen or first in season int first=-1, first_unseen=-1; for(int i=0;i=G.season_cnt) return; int sn=G.seasons[sel].num; close_overlay_safe(); int first=-1, first_unseen=-1; for(int i=0;i0){ if(sn==G.cur_season) snprintf(blabel,sizeof(blabel),"S%02d %d/%d " LV_SYMBOL_OK, sn, cached, total); else snprintf(blabel,sizeof(blabel),"S%02d %d/%d", sn, cached, total); }else{ if(sn==G.cur_season) snprintf(blabel,sizeof(blabel),"S%02d " LV_SYMBOL_OK, sn); else snprintf(blabel,sizeof(blabel),"S%02d", sn); } lv_obj_t* l1=lv_label_create(btn); lv_label_set_text(l1, blabel); lv_obj_set_style_text_font(l1, lvgl_get_text_font(FONT_SIZE_SMALL),0); lv_obj_center(l1); } lv_obj_t* bc=lv_btn_create(G.overlay); lv_obj_set_size(bc, LV_PCT(60), 36); lv_obj_set_style_bg_color(bc, lv_color_hex(0x444444),0); lv_obj_t* lc=lv_label_create(bc); lv_label_set_text(lc,"Close"); lv_obj_center(lc); lv_obj_add_event_cb(bc,close_btn_cb,LV_EVENT_CLICKED,NULL); tt_lvgl_unlock(); } void btn_dl_season_cb(lv_event_t* e){ (void)e; if(G.downloading) return; if(G.fetching) return; if(G.ep_cnt==0) return; int season = G.cur_season; ESP_LOGI(TAG, "DL Missing S%02d pressed", season); tt_lvgl_lock(portMAX_DELAY); close_overlay(); if(G.dl_overlay) hide_dl_overlay(); if(G.lbl_status){ char buf[40]; snprintf(buf,sizeof(buf),"Checking S%02d missing...", season); lv_label_set_text(G.lbl_status, buf); } tt_lvgl_unlock(); if (!download_season(season)) { tt_lvgl_lock(portMAX_DELAY); if(G.lbl_status) lv_label_set_text(G.lbl_status,"DL Season failed – no missing?"); tt_lvgl_unlock(); } } void build_ui(lv_obj_t* parent){ tt_lvgl_lock(portMAX_DELAY); lv_obj_t* root = lv_obj_create(parent); G.root = root; lv_obj_set_size(root,LV_PCT(100),LV_PCT(100)); lv_obj_set_style_bg_color(root,lv_color_hex(0x111111),0); lv_obj_set_style_pad_all(root,6,0); lv_obj_set_style_pad_gap(root,6,0); lv_obj_set_style_border_width(root,0,0); lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE); // Header container lv_obj_t* header=lv_obj_create(root); lv_obj_set_size(header, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(header, LV_OPA_TRANSP,0); lv_obj_set_style_border_width(header,0,0); lv_obj_set_style_pad_all(header,2,0); lv_obj_set_style_pad_gap(header,6,0); lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(header, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_remove_flag(header, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* header_left=lv_obj_create(header); lv_obj_set_size(header_left, LV_PCT(80), LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(header_left, LV_OPA_TRANSP,0); lv_obj_set_style_border_width(header_left,0,0); lv_obj_set_style_pad_all(header_left,0,0); lv_obj_set_flex_flow(header_left, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_gap(header_left,2,0); lv_obj_remove_flag(header_left, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_grow(header_left,1); G.lbl_title=lv_label_create(header_left); lv_label_set_text(G.lbl_title,"Discovery Mountain"); lv_obj_set_style_text_font(G.lbl_title, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0); lv_obj_set_style_text_color(G.lbl_title, lv_color_hex(0xFFFFFF),0); G.lbl_season=lv_label_create(header_left); lv_label_set_text(G.lbl_season,"Loading seasons..."); lv_obj_set_style_text_font(G.lbl_season, lvgl_get_text_font(FONT_SIZE_SMALL),0); lv_obj_set_style_text_color(G.lbl_season,lv_color_hex(0xAAAAAA),0); // Close button top right lv_obj_t* btn_close=lv_btn_create(header); lv_obj_set_size(btn_close, 36, 32); lv_obj_set_style_bg_color(btn_close, lv_color_hex(0x333333),0); lv_obj_set_style_radius(btn_close,6,0); lv_obj_t* l_close=lv_label_create(btn_close); lv_label_set_text(l_close, LV_SYMBOL_CLOSE); lv_obj_set_style_text_font(l_close, lvgl_get_text_font(FONT_SIZE_SMALL),0); lv_obj_center(l_close); lv_obj_add_event_cb(btn_close, btn_close_cb, LV_EVENT_CLICKED, NULL); // Action row: Seasons + DL Missing lv_obj_t* row=lv_obj_create(root); lv_obj_set_size(row,LV_PCT(100),44); lv_obj_set_flex_flow(row,LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(row,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER); lv_obj_set_style_bg_opa(row,LV_OPA_TRANSP,0); lv_obj_set_style_border_width(row,0,0); lv_obj_set_style_pad_all(row,0,0); lv_obj_set_style_pad_gap(row,8,0); lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* bs=lv_btn_create(row); lv_obj_set_size(bs,LV_PCT(48),36); G.btn_seasons=bs; lv_obj_set_style_bg_color(bs, lv_color_hex(0x333333),0); lv_obj_set_style_radius(bs,6,0); lv_obj_t* ls=lv_label_create(bs); lv_label_set_text(ls,"Seasons"); lv_obj_set_style_text_font(ls, lvgl_get_text_font(FONT_SIZE_SMALL),0); lv_obj_center(ls); lv_obj_add_event_cb(bs,btn_seasons_cb,LV_EVENT_CLICKED,NULL); lv_obj_t* bd=lv_btn_create(row); lv_obj_set_size(bd,LV_PCT(48),36); G.btn_dl_missing=bd; lv_obj_set_style_bg_color(bd, lv_color_hex(0x1E88E5), 0); lv_obj_set_style_radius(bd,6,0); lv_obj_t* ld=lv_label_create(bd); lv_label_set_text(ld,"DL Missing"); lv_obj_set_style_text_font(ld, lvgl_get_text_font(FONT_SIZE_SMALL),0); lv_obj_center(ld); lv_obj_add_event_cb(bd,btn_dl_season_cb,LV_EVENT_CLICKED,NULL); // Episode list container – fills remaining space G.ep_list_cont=lv_obj_create(root); lv_obj_set_size(G.ep_list_cont, LV_PCT(100), LV_PCT(100)); lv_obj_set_flex_grow(G.ep_list_cont, 1); lv_obj_set_style_bg_color(G.ep_list_cont, lv_color_hex(0x0E0E0E),0); lv_obj_set_style_bg_opa(G.ep_list_cont, LV_OPA_COVER,0); lv_obj_set_style_radius(G.ep_list_cont,8,0); lv_obj_set_style_pad_all(G.ep_list_cont,4,0); lv_obj_set_style_pad_gap(G.ep_list_cont,4,0); lv_obj_set_flex_flow(G.ep_list_cont, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(G.ep_list_cont, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); // Ensure scrollable lv_obj_add_flag(G.ep_list_cont, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scrollbar_mode(G.ep_list_cont, LV_SCROLLBAR_MODE_AUTO); lv_obj_t* hint=lv_label_create(G.ep_list_cont); lv_label_set_text(hint,"Loading episodes..."); lv_obj_set_style_text_color(hint, lv_color_hex(0x888888),0); lv_obj_set_style_text_font(hint, lvgl_get_text_font(FONT_SIZE_SMALL),0); // Status bar at bottom G.lbl_status=lv_label_create(root); lv_label_set_text(G.lbl_status,"Fetching data..."); lv_obj_set_style_text_color(G.lbl_status,lv_color_hex(0x888888),0); lv_obj_set_style_text_font(G.lbl_status, lvgl_get_text_font(FONT_SIZE_SMALL),0); lv_obj_set_width(G.lbl_status, LV_PCT(100)); lv_label_set_long_mode(G.lbl_status, LV_LABEL_LONG_DOT); // Legacy placeholders nulled G.lbl_meta=NULL; G.bar=NULL; G.lbl_time=NULL; G.btn_play=NULL; G.dl_overlay=NULL; G.ui_timer_handle=NULL; G.ui_timer_handle = lv_timer_create(ui_timer,300,NULL); tt_lvgl_unlock(); } void ui_deinit(void){ if(G.ui_timer_handle){ lv_timer_delete(G.ui_timer_handle); G.ui_timer_handle=NULL; } } void ui_timer(lv_timer_t* t){ (void)t; // If app is closing (root null) skip if(!G.root) return; if(G.fetching){ return; } if(G.downloading && G.dl_overlay){ update_dl_overlay_ui(); } } void btn_play_cb(lv_event_t* e){ (void)e; /* legacy, not used */ } void btn_prev_cb(lv_event_t* e){ (void)e; /* legacy */ } void btn_next_cb(lv_event_t* e){ (void)e; /* legacy */ }