#include #include #include #include #include #include #include #include #include "freertos/FreeRTOS.h" #include "esp_log.h" #include "tactility/lvgl_fonts.h" #define TAG "BibleVerse" #define AUTO_ADV_MS 60000 // 1 minute // Per-book binary format (produced by split_bible.py): // .bin : concatenated NUL-terminated UTF8 verse strings // .idx : header 8 bytes: magic 'BIBK' 4s, version u16, count u32 // then per entry: offset u32, cnum u16, vnum u16 (8 bytes) // books.json : array of {bnumber, bname, verses, chapters} #define MAX_BIBLE_BOOKS 66 #define MAX_BOOK_NAME 40 #define MAX_VERSE_TEXT 2048 #define MAX_PATH 320 typedef struct { uint32_t offset; uint16_t cnum; uint16_t vnum; } VerseIndex; typedef struct { int bnumber; char bname[MAX_BOOK_NAME]; int verse_count; int chapter_count; } BookInfo; typedef struct { // App AppHandle app; // Bible metadata BookInfo books[MAX_BIBLE_BOOKS]; int book_count; int total_verses; // Current position (global index 0..total-1 AND book/chap/verse breakdown) int cur_global; // 0-based global verse index int cur_book_idx; // 0..book_count-1 index into books[] int cur_verse_in_book; // 0..book.verse_count-1 // Loaded book data (only one book at a time) uint8_t* book_bin; // malloc'd content of XX.bin size_t book_bin_size; VerseIndex* book_idx; // malloc'd parsed index int book_idx_count; int loaded_bnumber; // which bnumber is loaded, -1 = none // Settings / favorites char progress_path[MAX_PATH]; char favorites_path[MAX_PATH]; char books_json_path[MAX_PATH]; char bible_dir[MAX_PATH]; // assets/bible/ path prefix // SD fallback paths char sd_bible_dir[MAX_PATH]; bool use_sd_bible; // Highlight bool is_highlighted; // UI lv_obj_t* root; lv_obj_t* verse_wrapper; // full screen, clickable to toggle chrome lv_obj_t* lbl_verse; lv_obj_t* lbl_ref; // small ref line below verse lv_obj_t* header_bar; lv_obj_t* ctrl_bar; lv_obj_t* lbl_position; lv_obj_t* lbl_book_title; lv_obj_t* btn_prev; lv_obj_t* btn_next; lv_obj_t* btn_play_pause; lv_obj_t* btn_highlight; lv_obj_t* btn_close; // Timer lv_timer_t* auto_timer; bool auto_paused; bool ui_visible; // chrome visible? } AppCtx; static AppCtx g_ctx; /* ── Forward ── */ static bool load_books_manifest(AppCtx* ctx); static bool load_book(AppCtx* ctx, int bnumber); static void unload_book(AppCtx* ctx); static void show_current_verse(AppCtx* ctx); static void advance_verse(AppCtx* ctx, int delta); static void save_progress(AppCtx* ctx); static bool load_progress(AppCtx* ctx); static void toggle_ui(AppCtx* ctx); static void set_chrome_visible(AppCtx* ctx, bool visible); static bool is_favorite(AppCtx* ctx, int global_idx); static void toggle_favorite(AppCtx* ctx); static void ensure_user_dir(AppCtx* ctx); /* ── Utilities ── */ static void ensure_user_dir(AppCtx* ctx) { // mkdir -p for progress dir: parent of progress_path char dir[MAX_PATH]; strncpy(dir, ctx->progress_path, sizeof(dir)-1); dir[sizeof(dir)-1]='\0'; char* last = strrchr(dir, '/'); if (last) { *last='\0'; // create intermediate char tmp[MAX_PATH]; size_t len = strlen(dir); for (size_t i=1;i<=len;i++) { if (dir[i]=='/' || dir[i]=='\0') { char c=dir[i]; dir[i]='\0'; strncpy(tmp, dir, sizeof(tmp)-1); tmp[sizeof(tmp)-1]='\0'; mkdir(tmp, 0755); dir[i]=c; } } } } static bool file_exists(const char* path) { struct stat st; return stat(path, &st)==0; } /* ── Bible asset discovery: try assets/bible then SD fallback ── */ static void resolve_bible_paths(AppCtx* ctx) { // First try app assets: tt_app_get_assets_child_path("bible") char assets_path[MAX_PATH]; size_t sz=sizeof(assets_path); tt_app_get_assets_path(ctx->app, assets_path, &sz); // assets_path already is the assets folder itself snprintf(ctx->bible_dir, sizeof(ctx->bible_dir), "%s/bible", assets_path); snprintf(ctx->books_json_path, sizeof(ctx->books_json_path), "%s/books.json", ctx->bible_dir); // SD fallback: /sdcard/bibles or /sdcard/bible ctx->use_sd_bible = false; const char* candidates[] = {"/sdcard/bibles", "/sdcard/bible", "/data/bibles", NULL}; for (int i=0;candidates[i];i++) { char cand_books[MAX_PATH]; snprintf(cand_books, sizeof(cand_books), "%s/books.json", candidates[i]); if (file_exists(cand_books)) { strncpy(ctx->sd_bible_dir, candidates[i], sizeof(ctx->sd_bible_dir)-1); // prefer SD if assets not found if (!file_exists(ctx->books_json_path)) { strncpy(ctx->bible_dir, candidates[i], sizeof(ctx->bible_dir)-1); strncpy(ctx->books_json_path, cand_books, sizeof(ctx->books_json_path)-1); ctx->use_sd_bible = true; } ESP_LOGI(TAG, "Found SD bible at %s (assets bible exists=%d)", candidates[i], file_exists(assets_path)); break; } // also check if XX.bin exists without manifest - if so accept if (!ctx->use_sd_bible) { char sample[MAX_PATH]; snprintf(sample, sizeof(sample), "%s/01.bin", candidates[i]); if (file_exists(sample)) { // synthesize if no json - will still attempt scan strncpy(ctx->sd_bible_dir, candidates[i], sizeof(ctx->sd_bible_dir)-1); if (!file_exists(ctx->books_json_path)) { strncpy(ctx->bible_dir, candidates[i], sizeof(ctx->bible_dir)-1); snprintf(ctx->books_json_path, sizeof(ctx->books_json_path), "%s/books.json", ctx->bible_dir); ctx->use_sd_bible = true; } break; } } } ESP_LOGI(TAG, "Bible dir resolved: %s use_sd=%d", ctx->bible_dir, ctx->use_sd_bible); } /* ── Simple JSON parse for books.json (no cJSON to keep deps low) ── */ static bool parse_books_json(AppCtx* ctx, const char* json_str) { // Extremely simple: look for "bnumber": N and "bname": "...", "verses": N, "chapters": N // We'll do a state scan: search for { ... } objects inside "books": [ const char* p = strstr(json_str, "\"books\""); if (!p) return false; p = strchr(p, '['); if (!p) return false; p++; ctx->book_count = 0; while (ctx->book_count < MAX_BIBLE_BOOKS) { const char* obj_start = strchr(p, '{'); if (!obj_start) break; const char* obj_end = strchr(obj_start, '}'); if (!obj_end) break; // parse fields within obj_start..obj_end char temp[512]; size_t len = (size_t)(obj_end - obj_start + 1); if (len >= sizeof(temp)) len = sizeof(temp)-1; memcpy(temp, obj_start, len); temp[len]='\0'; int bnum=0, verses=0, chapters=0; char bname[MAX_BOOK_NAME]={0}; const char* pn = strstr(temp, "\"bnumber\""); if (pn) sscanf(pn, "\"bnumber\"%*[: ]%d", &bnum); const char* pb = strstr(temp, "\"bname\""); if (pb) { const char* colon = strchr(pb, ':'); if (colon) { const char* q1 = strchr(colon, '\"'); if (q1) { q1++; const char* q2 = strchr(q1, '\"'); if (q2 && (q2-q1)< (int)sizeof(bname)-1) { memcpy(bname, q1, q2-q1); bname[q2-q1]='\0'; } } } } const char* pv = strstr(temp, "\"verses\""); if (pv) sscanf(pv, "\"verses\"%*[: ]%d", &verses); const char* pc = strstr(temp, "\"chapters\""); if (pc) sscanf(pc, "\"chapters\"%*[: ]%d", &chapters); if (bnum>0 && bname[0]) { ctx->books[ctx->book_count].bnumber = bnum; strncpy(ctx->books[ctx->book_count].bname, bname, MAX_BOOK_NAME-1); ctx->books[ctx->book_count].verse_count = verses; ctx->books[ctx->book_count].chapter_count = chapters; ctx->book_count++; } p = obj_end+1; // stop if ] seen before next { const char* next_brace = strchr(p, '{'); const char* next_bracket = strchr(p, ']'); if (next_bracket && (!next_brace || next_bracket < next_brace)) break; } // total_verses const char* pt = strstr(json_str, "\"total_verses\""); if (pt) { sscanf(pt, "\"total_verses\"%*[: ]%d", &ctx->total_verses); } else { ctx->total_verses=0; for (int i=0;ibook_count;i++) ctx->total_verses+=ctx->books[i].verse_count; } return ctx->book_count>0; } static char* read_file_to_str(const char* path, size_t* out_len) { FILE* f = fopen(path, "rb"); if (!f) return NULL; fseek(f,0,SEEK_END); long sz = ftell(f); if (sz<=0 || sz>500000) { fclose(f); return NULL; } fseek(f,0,SEEK_SET); char* buf = malloc(sz+1); if (!buf) { fclose(f); return NULL; } size_t n = fread(buf,1,sz,f); fclose(f); buf[n]='\0'; if (out_len) *out_len=n; return buf; } static bool load_books_manifest(AppCtx* ctx) { char* json = read_file_to_str(ctx->books_json_path, NULL); if (!json) { // fallback: scan directory for *.bin and create synthetic list if books.json missing ESP_LOGW(TAG, "books.json not found at %s, scanning dir", ctx->books_json_path); DIR* d = opendir(ctx->bible_dir); if (!d) { ESP_LOGE(TAG, "Cannot open bible dir %s", ctx->bible_dir); return false; } ctx->book_count=0; ctx->total_verses=0; struct dirent* ent; while ((ent=readdir(d))!=NULL && ctx->book_countd_name[0]=='.') continue; size_t l=strlen(ent->d_name); if (l<5) continue; if (strcmp(ent->d_name+l-4, ".bin")!=0) continue; int bnum=0; if (sscanf(ent->d_name, "%d", &bnum)!=1) continue; // try read idx to get count char idx_path[MAX_PATH]; snprintf(idx_path, sizeof(idx_path), "%s/%02d.idx", ctx->bible_dir, bnum); FILE* idxf = fopen(idx_path, "rb"); int vcount=0; if (idxf) { uint8_t hdr[10]={0}; if (fread(hdr,1,10,idxf)==10) { if (memcmp(hdr, "BIBK",4)==0) { // format <4sHI uint16_t ver; uint32_t cnt; memcpy(&ver, hdr+4,2); memcpy(&cnt, hdr+6,4); vcount = (int)cnt; } else { // maybe old header <4sHI as earlier: try // Actually we wrote <4sHI -> magic 4, H 2, I 4 =10 bytes // So above. } } fclose(idxf); } // generic name from bnumber if can't find ctx->books[ctx->book_count].bnumber = bnum; snprintf(ctx->books[ctx->book_count].bname, MAX_BOOK_NAME, "Book %02d", bnum); ctx->books[ctx->book_count].verse_count = vcount; ctx->books[ctx->book_count].chapter_count = 0; ctx->book_count++; ctx->total_verses+=vcount; } closedir(d); // sort by bnumber for (int i=0;ibook_count-1;i++) for (int j=0;jbook_count-i-1;j++) if (ctx->books[j].bnumber > ctx->books[j+1].bnumber) { BookInfo tmp = ctx->books[j]; ctx->books[j]=ctx->books[j+1]; ctx->books[j+1]=tmp; } return ctx->book_count>0; } bool ok = parse_books_json(ctx, json); free(json); ESP_LOGI(TAG, "Loaded %d books, total verses %d from %s", ctx->book_count, ctx->total_verses, ctx->books_json_path); return ok; } /* ── Book loading ── */ static bool load_book(AppCtx* ctx, int bnumber) { if (ctx->loaded_bnumber == bnumber && ctx->book_bin && ctx->book_idx) return true; unload_book(ctx); char bin_path[MAX_PATH]; char idx_path[MAX_PATH]; snprintf(bin_path, sizeof(bin_path), "%s/%02d.bin", ctx->bible_dir, bnumber); snprintf(idx_path, sizeof(idx_path), "%s/%02d.idx", ctx->bible_dir, bnumber); if (!file_exists(bin_path) || !file_exists(idx_path)) { ESP_LOGE(TAG, "Book files missing bnum=%d bin=%s idx=%s", bnumber, bin_path, idx_path); return false; } // load idx first FILE* f = fopen(idx_path, "rb"); if (!f) return false; uint8_t hdr[10]; if (fread(hdr,1,10,f)!=10) { fclose(f); return false; } if (memcmp(hdr, "BIBK",4)!=0) { fclose(f); return false; } uint16_t ver=0; uint32_t count=0; memcpy(&ver, hdr+4, 2); memcpy(&count, hdr+6, 4); if (count==0 || count>10000) { fclose(f); return false; } ctx->book_idx = malloc(sizeof(VerseIndex)*count); if (!ctx->book_idx) { fclose(f); return false; } for (uint32_t i=0;ibook_idx); ctx->book_idx=NULL; fclose(f); return false; } uint32_t off; uint16_t cnum, vnum; memcpy(&off, entry,4); memcpy(&cnum, entry+4,2); memcpy(&vnum, entry+6,2); ctx->book_idx[i].offset=off; ctx->book_idx[i].cnum=cnum; ctx->book_idx[i].vnum=vnum; } fclose(f); ctx->book_idx_count = (int)count; // load bin f = fopen(bin_path, "rb"); if (!f) { free(ctx->book_idx); ctx->book_idx=NULL; return false; } fseek(f,0,SEEK_END); long sz = ftell(f); fseek(f,0,SEEK_SET); if (sz<=0 || sz>1024*1024) { fclose(f); free(ctx->book_idx); ctx->book_idx=NULL; return false; } ctx->book_bin = malloc(sz+1); if (!ctx->book_bin) { fclose(f); free(ctx->book_idx); ctx->book_idx=NULL; return false; } size_t n = fread(ctx->book_bin,1,sz,f); fclose(f); ctx->book_bin[sz]='\0'; ctx->book_bin_size = n; ctx->loaded_bnumber = bnumber; ESP_LOGI(TAG, "Loaded book %02d (%d verses, %ld bytes)", bnumber, ctx->book_idx_count, sz); return true; } static void unload_book(AppCtx* ctx) { if (ctx->book_bin) { free(ctx->book_bin); ctx->book_bin=NULL; } if (ctx->book_idx) { free(ctx->book_idx); ctx->book_idx=NULL; } ctx->book_bin_size=0; ctx->book_idx_count=0; ctx->loaded_bnumber=-1; } /* ── Progress persistence ── */ static void save_progress(AppCtx* ctx) { ensure_user_dir(ctx); FILE* f = fopen(ctx->progress_path, "w"); if (!f) { ESP_LOGE(TAG,"save_progress fail %s", ctx->progress_path); return; } // Store global index and also b/c/v for human debug int bnum = 0; int cnum=1; int vnum=1; if (ctx->cur_book_idx>=0 && ctx->cur_book_idx < ctx->book_count) { bnum = ctx->books[ctx->cur_book_idx].bnumber; if (ctx->book_idx && ctx->cur_verse_in_book < ctx->book_idx_count) { cnum = ctx->book_idx[ctx->cur_verse_in_book].cnum; vnum = ctx->book_idx[ctx->cur_verse_in_book].vnum; } } fprintf(f, "%d\n%d %d %d\n%d %d\n", ctx->cur_global, bnum, cnum, vnum, ctx->cur_book_idx, ctx->cur_verse_in_book); fclose(f); ESP_LOGI(TAG,"Saved progress global=%d b=%d c=%d v=%d", ctx->cur_global, bnum, cnum, vnum); } static bool load_progress(AppCtx* ctx) { FILE* f = fopen(ctx->progress_path, "r"); if (!f) return false; int global_=0; int bnum=0,cnum=0,vnum=0; int book_idx_=0, verse_in_book_=0; int r = fscanf(f, "%d", &global_); if (r==1) { // try second line fscanf(f, "%d %d %d", &bnum, &cnum, &vnum); fscanf(f, "%d %d", &book_idx_, &verse_in_book_); } fclose(f); if (global_<0) global_=0; if (global_>=ctx->total_verses) global_=0; ctx->cur_global = global_; // We will resolve book_idx / verse_in_book from global later in global_to_book() (void)bnum;(void)cnum;(void)vnum;(void)book_idx_;(void)verse_in_book_; return true; } // Convert global idx -> book_idx and verse_in_book static void global_to_book(AppCtx* ctx, int global) { if (ctx->book_count==0) return; int remaining = global; for (int i=0;ibook_count;i++) { int vc = ctx->books[i].verse_count; if (vc<=0) continue; if (remaining < vc) { ctx->cur_book_idx = i; ctx->cur_verse_in_book = remaining; return; } remaining -= vc; } // clamp to last ctx->cur_book_idx = ctx->book_count-1; ctx->cur_verse_in_book = ctx->books[ctx->book_count-1].verse_count-1; if (ctx->cur_verse_in_book<0) ctx->cur_verse_in_book=0; } static void book_to_global(AppCtx* ctx) { int g=0; for (int i=0;icur_book_idx;i++) g+=ctx->books[i].verse_count; g+=ctx->cur_verse_in_book; ctx->cur_global=g; } /* ── Favorites ── */ static bool is_favorite(AppCtx* ctx, int global_idx) { if (global_idx<0) return false; FILE* f = fopen(ctx->favorites_path, "r"); if (!f) return false; int v; while (fscanf(f, "%d", &v)==1) { if (v==global_idx) { fclose(f); return true; } } fclose(f); return false; } static void toggle_favorite(AppCtx* ctx) { // load all int vals[512]; int cnt=0; FILE* f = fopen(ctx->favorites_path, "r"); if (f) { while (cnt<512 && fscanf(f, "%d", &vals[cnt])==1) cnt++; fclose(f); } // check existence int idx=-1; for (int i=0;icur_global) idx=i; if (idx>=0) { // remove for (int i=idx;iis_highlighted=false; } else { if (cnt<512) { vals[cnt++]=ctx->cur_global; ctx->is_highlighted=true; } } ensure_user_dir(ctx); f = fopen(ctx->favorites_path, "w"); if (!f) return; for (int i=0;ilbl_verse) return; size_t len = strlen(verse); // Tiers tuned from real device screenshots (240x320 style) // < 40 : ultra short e.g. "Jesus wept." -> giant, centered, lots of breathing // 40-100 : short -> large // 100-200: medium -> default/large borderline // 200-350: long -> small/default // 350+ : very long (Esther 8:9 etc) -> extra compact enum LvglFontSize font_sz; int line_space; int pad_v; int pad_h; int letter_space = 0; if (len < 40) { font_sz = FONT_SIZE_LARGE; line_space = 14; pad_v = 32; pad_h = 36; letter_space = 1; } else if (len < 100) { font_sz = FONT_SIZE_LARGE; line_space = 11; pad_v = 24; pad_h = 28; } else if (len < 200) { font_sz = FONT_SIZE_DEFAULT; line_space = 9; pad_v = 18; pad_h = 22; } else if (len < 350) { font_sz = FONT_SIZE_SMALL; line_space = 6; pad_v = 12; pad_h = 18; } else { font_sz = FONT_SIZE_SMALL; line_space = 4; pad_v = 8; pad_h = 14; } lv_obj_set_style_text_font(ctx->lbl_verse, lvgl_get_text_font(font_sz), 0); lv_obj_set_style_text_line_space(ctx->lbl_verse, line_space, 0); lv_obj_set_style_text_letter_space(ctx->lbl_verse, letter_space, 0); lv_obj_set_style_pad_top(ctx->lbl_verse, pad_v, 0); lv_obj_set_style_pad_bottom(ctx->lbl_verse, pad_v, 0); lv_obj_set_style_pad_left(ctx->lbl_verse, pad_h, 0); lv_obj_set_style_pad_right(ctx->lbl_verse, pad_h, 0); } static void show_current_verse(AppCtx* ctx) { if (ctx->book_count==0) return; global_to_book(ctx, ctx->cur_global); book_to_global(ctx); int bnum = ctx->books[ctx->cur_book_idx].bnumber; if (!load_book(ctx, bnum)) { lv_label_set_text(ctx->lbl_verse, "Failed to load book"); lv_label_set_text(ctx->lbl_ref, ""); return; } if (ctx->cur_verse_in_book<0) ctx->cur_verse_in_book=0; if (ctx->cur_verse_in_book>=ctx->book_idx_count) ctx->cur_verse_in_book=ctx->book_idx_count-1; VerseIndex* vi = &ctx->book_idx[ctx->cur_verse_in_book]; uint32_t off = vi->offset; const char* text_ptr = "(empty)"; if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off); char verse_buf[MAX_VERSE_TEXT]; strncpy(verse_buf, text_ptr, sizeof(verse_buf)-1); verse_buf[sizeof(verse_buf)-1]='\0'; char ref_buf[96]; snprintf(ref_buf, sizeof(ref_buf), "%s %d:%d", ctx->books[ctx->cur_book_idx].bname, vi->cnum, vi->vnum); char ref_pretty[96]; snprintf(ref_pretty, sizeof(ref_pretty), "— %s", ref_buf); if (ctx->lbl_verse) { lv_label_set_text(ctx->lbl_verse, verse_buf); apply_verse_scaling(ctx, verse_buf); } if (ctx->lbl_ref) lv_label_set_text(ctx->lbl_ref, ref_pretty); if (ctx->lbl_book_title) lv_label_set_text(ctx->lbl_book_title, ref_buf); if (ctx->lbl_position) { char pos_buf[40]; snprintf(pos_buf, sizeof(pos_buf), "%d / %d", ctx->cur_global+1, ctx->total_verses); lv_label_set_text(ctx->lbl_position, pos_buf); } ctx->is_highlighted = is_favorite(ctx, ctx->cur_global); if (ctx->btn_highlight) { lv_obj_t* lbl = lv_obj_get_child(ctx->btn_highlight, 0); if (lbl) lv_label_set_text(lbl, ctx->is_highlighted ? LV_SYMBOL_OK : LV_SYMBOL_DUMMY); if (ctx->is_highlighted) { lv_obj_set_style_bg_color(ctx->btn_highlight, lv_color_hex(0xF5C16C), 0); lv_obj_set_style_text_color(ctx->btn_highlight, lv_color_hex(0x1E1E2E), 0); } else { lv_obj_set_style_bg_color(ctx->btn_highlight, lv_color_hex(0x252538), 0); lv_obj_set_style_text_color(ctx->btn_highlight, lv_color_hex(0x8C8CB0), 0); } if (ctx->lbl_verse) { if (ctx->is_highlighted) { lv_obj_set_style_bg_color(ctx->lbl_verse, lv_color_hex(0x2E2818), 0); lv_obj_set_style_bg_opa(ctx->lbl_verse, LV_OPA_COVER, 0); lv_obj_set_style_border_color(ctx->lbl_verse, lv_color_hex(0xF5C16C), 0); lv_obj_set_style_border_width(ctx->lbl_verse, 1, 0); lv_obj_set_style_border_opa(ctx->lbl_verse, LV_OPA_40, 0); } else { lv_obj_set_style_bg_opa(ctx->lbl_verse, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(ctx->lbl_verse, 0, 0); } } } if (ctx->btn_prev) { if (ctx->cur_global<=0) lv_obj_add_state(ctx->btn_prev, LV_STATE_DISABLED); else lv_obj_clear_state(ctx->btn_prev, LV_STATE_DISABLED); } if (ctx->btn_next) { if (ctx->cur_global >= ctx->total_verses-1) lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED); else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED); } save_progress(ctx); } static void advance_verse(AppCtx* ctx, int delta) { int ng = ctx->cur_global + delta; if (ng < 0) ng = 0; if (ng >= ctx->total_verses) ng = ctx->total_verses-1; // stop at end, or wrap? we stop // Optionally wrap to start after Revelation // if (ng >= ctx->total_verses) ng = 0; if (ng != ctx->cur_global) { ctx->cur_global = ng; show_current_verse(ctx); } } /* ── Timer ── */ static void on_auto_timer(lv_timer_t* t) { AppCtx* ctx = (AppCtx*)lv_timer_get_user_data(t); if (ctx->auto_paused) return; if (ctx->cur_global < ctx->total_verses-1) { advance_verse(ctx, 1); } else { ctx->auto_paused = true; if (ctx->btn_play_pause) { lv_obj_t* lbl = lv_obj_get_child(ctx->btn_play_pause, 0); if (lbl) lv_label_set_text(lbl, LV_SYMBOL_PLAY); } } } /* ── UI callbacks ── */ static void on_verse_tap(lv_event_t* e) { AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); toggle_ui(ctx); } static void toggle_ui(AppCtx* ctx) { set_chrome_visible(ctx, !ctx->ui_visible); } static void set_chrome_visible(AppCtx* ctx, bool visible) { ctx->ui_visible = visible; if (visible) { if (ctx->header_bar) lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN); if (ctx->ctrl_bar) lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN); } else { if (ctx->header_bar) lv_obj_add_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN); if (ctx->ctrl_bar) lv_obj_add_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN); } } static void on_prev_click(lv_event_t* e) { AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); advance_verse(ctx, -1); } static void on_next_click(lv_event_t* e) { AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); advance_verse(ctx, 1); } static void on_play_pause_click(lv_event_t* e) { AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); ctx->auto_paused = !ctx->auto_paused; if (ctx->btn_play_pause) { lv_obj_t* lbl = lv_obj_get_child(ctx->btn_play_pause, 0); if (lbl) lv_label_set_text(lbl, ctx->auto_paused ? LV_SYMBOL_PLAY : LV_SYMBOL_PAUSE); } } static void on_highlight_click(lv_event_t* e) { AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); toggle_favorite(ctx); // refresh highlight visuals without reloading show_current_verse(ctx); } static void on_close_click(lv_event_t* e) { AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); (void)ctx; tt_app_stop(); } // Future hooks static void on_memo_click(lv_event_t* e) { // Placeholder: in future record voice memo to /sdcard/bible_memos/.wav AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); const char* btns[] = {"OK"}; tt_app_alertdialog_start("Voice Memo", "Voice memo recording will be added in a future version.\nFile will be saved under /sdcard/bible_memos/", btns, 1); } /* ── App lifecycle ── */ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { memset(&g_ctx, 0, sizeof(g_ctx)); g_ctx.app = app; g_ctx.loaded_bnumber = -1; g_ctx.auto_paused = false; g_ctx.ui_visible = false; g_ctx.root = parent; char ud_path[MAX_PATH]; size_t sz=sizeof(ud_path); tt_app_get_user_data_path(app, ud_path, &sz); snprintf(g_ctx.progress_path, sizeof(g_ctx.progress_path), "%s/progress.txt", ud_path); snprintf(g_ctx.favorites_path, sizeof(g_ctx.favorites_path), "%s/favorites.txt", ud_path); resolve_bible_paths(&g_ctx); if (!load_books_manifest(&g_ctx)) { lv_obj_t* lbl = lv_label_create(parent); lv_label_set_text(lbl, "Bible data not found.\n\nExpected:\n assets/bible/books.json\n or /sdcard/bibles/\n\nRun tools/convert_bible.py"); lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0); lv_label_set_long_mode(lbl, LV_LABEL_LONG_MODE_WRAP); lv_obj_set_width(lbl, LV_PCT(80)); return; } if (!load_progress(&g_ctx)) g_ctx.cur_global = 0; if (g_ctx.cur_global <0 || g_ctx.cur_global >= g_ctx.total_verses) g_ctx.cur_global=0; // Deep paper dark - eye friendly for meditation lv_obj_set_style_bg_color(parent, lv_color_hex(0x0E0E14), 0); lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0); lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_pad_all(parent, 0, 0); // Full screen tap area g_ctx.verse_wrapper = lv_obj_create(parent); lv_obj_set_size(g_ctx.verse_wrapper, LV_PCT(100), LV_PCT(100)); lv_obj_align(g_ctx.verse_wrapper, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_opa(g_ctx.verse_wrapper, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(g_ctx.verse_wrapper, 0, 0); lv_obj_set_style_pad_all(g_ctx.verse_wrapper, 0, 0); lv_obj_remove_flag(g_ctx.verse_wrapper, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_ctx.verse_wrapper, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(g_ctx.verse_wrapper, on_verse_tap, LV_EVENT_CLICKED, &g_ctx); // Center reading column with generous breathing room lv_obj_t* center_col = lv_obj_create(g_ctx.verse_wrapper); lv_obj_set_size(center_col, LV_PCT(90), LV_PCT(80)); lv_obj_center(center_col); lv_obj_set_style_bg_opa(center_col, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(center_col, 0, 0); lv_obj_set_flex_flow(center_col, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(center_col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_gap(center_col, 14, 0); lv_obj_set_style_pad_all(center_col, 0, 0); lv_obj_remove_flag(center_col, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(center_col, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(center_col, on_verse_tap, LV_EVENT_CLICKED, &g_ctx); g_ctx.lbl_verse = lv_label_create(center_col); lv_obj_set_width(g_ctx.lbl_verse, LV_PCT(100)); lv_label_set_long_mode(g_ctx.lbl_verse, LV_LABEL_LONG_MODE_WRAP); lv_obj_set_style_text_color(g_ctx.lbl_verse, lv_color_hex(0xE8E6E0), 0); lv_obj_set_style_text_font(g_ctx.lbl_verse, lvgl_get_text_font(FONT_SIZE_LARGE), 0); lv_obj_set_style_text_align(g_ctx.lbl_verse, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_line_space(g_ctx.lbl_verse, 8, 0); lv_obj_set_style_text_letter_space(g_ctx.lbl_verse, 0, 0); lv_obj_set_style_radius(g_ctx.lbl_verse, 16, 0); lv_obj_set_style_pad_all(g_ctx.lbl_verse, 16, 0); lv_label_set_text(g_ctx.lbl_verse, "Loading..."); g_ctx.lbl_ref = lv_label_create(center_col); lv_obj_set_style_text_color(g_ctx.lbl_ref, lv_color_hex(0x7A7A96), 0); lv_obj_set_style_text_font(g_ctx.lbl_ref, lvgl_get_text_font(FONT_SIZE_SMALL), 0); lv_obj_set_style_text_align(g_ctx.lbl_ref, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_letter_space(g_ctx.lbl_ref, 1, 0); lv_label_set_text(g_ctx.lbl_ref, ""); // ── Header bar (frosted dark) ── g_ctx.header_bar = lv_obj_create(parent); lv_obj_t* hb = g_ctx.header_bar; lv_obj_set_size(hb, LV_PCT(100), 44); lv_obj_align(hb, LV_ALIGN_TOP_MID, 0, 0); lv_obj_set_style_radius(hb, 0, 0); lv_obj_set_style_bg_color(hb, lv_color_hex(0x15151F), 0); lv_obj_set_style_bg_opa(hb, LV_OPA_COVER, 0); lv_obj_set_style_border_width(hb, 0, 0); lv_obj_set_style_border_side(hb, LV_BORDER_SIDE_BOTTOM, 0); lv_obj_set_style_border_color(hb, lv_color_hex(0x232338), 0); lv_obj_set_style_border_width(hb, 1, 0); lv_obj_set_style_pad_hor(hb, 12, 0); lv_obj_set_style_pad_ver(hb, 0, 0); lv_obj_remove_flag(hb, LV_OBJ_FLAG_SCROLLABLE); g_ctx.lbl_book_title = lv_label_create(hb); lv_obj_align(g_ctx.lbl_book_title, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_style_text_color(g_ctx.lbl_book_title, lv_color_hex(0xE8E6E0), 0); lv_obj_set_style_text_font(g_ctx.lbl_book_title, lvgl_get_text_font(FONT_SIZE_SMALL), 0); lv_label_set_text(g_ctx.lbl_book_title, "Bible"); g_ctx.lbl_position = lv_label_create(hb); lv_obj_align(g_ctx.lbl_position, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_set_style_text_color(g_ctx.lbl_position, lv_color_hex(0x6A6A8A), 0); lv_obj_set_style_text_font(g_ctx.lbl_position, lvgl_get_text_font(FONT_SIZE_SMALL), 0); lv_label_set_text(g_ctx.lbl_position, "1 / 1"); // ── Bottom controls (minimal) ── g_ctx.ctrl_bar = lv_obj_create(parent); lv_obj_t* cb = g_ctx.ctrl_bar; lv_obj_set_size(cb, LV_PCT(100), 56); lv_obj_align(cb, LV_ALIGN_BOTTOM_MID, 0, 0); lv_obj_set_style_radius(cb, 0, 0); lv_obj_set_style_bg_color(cb, lv_color_hex(0x15151F), 0); lv_obj_set_style_bg_opa(cb, LV_OPA_COVER, 0); lv_obj_set_style_border_width(cb, 1, 0); lv_obj_set_style_border_side(cb, LV_BORDER_SIDE_TOP, 0); lv_obj_set_style_border_color(cb, lv_color_hex(0x232338), 0); lv_obj_set_style_pad_all(cb, 6, 0); lv_obj_set_style_pad_gap(cb, 4, 0); lv_obj_set_flex_flow(cb, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(cb, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_remove_flag(cb, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* btn; lv_obj_t* lbl; g_ctx.btn_close = lv_button_create(cb); lv_obj_set_size(g_ctx.btn_close, 38, 38); lv_obj_set_style_radius(g_ctx.btn_close, 10, 0); lv_obj_set_style_bg_color(g_ctx.btn_close, lv_color_hex(0x1E1E2E), 0); lv_obj_set_style_text_color(g_ctx.btn_close, lv_color_hex(0x8C8CB0), 0); lbl = lv_label_create(g_ctx.btn_close); lv_label_set_text(lbl, LV_SYMBOL_CLOSE); lv_obj_center(lbl); lv_obj_add_event_cb(g_ctx.btn_close, on_close_click, LV_EVENT_CLICKED, &g_ctx); g_ctx.btn_prev = lv_button_create(cb); lv_obj_set_size(g_ctx.btn_prev, 44, 38); lv_obj_set_style_radius(g_ctx.btn_prev, 10, 0); lv_obj_set_style_bg_color(g_ctx.btn_prev, lv_color_hex(0x252538), 0); lv_obj_set_style_text_color(g_ctx.btn_prev, lv_color_hex(0xC4C4D4), 0); lbl = lv_label_create(g_ctx.btn_prev); lv_label_set_text(lbl, LV_SYMBOL_PREV); lv_obj_center(lbl); lv_obj_add_event_cb(g_ctx.btn_prev, on_prev_click, LV_EVENT_CLICKED, &g_ctx); g_ctx.btn_play_pause = lv_button_create(cb); lv_obj_set_size(g_ctx.btn_play_pause, 76, 38); lv_obj_set_style_radius(g_ctx.btn_play_pause, 19, 0); lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x2A2A4A), 0); lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0xE8E6E0), 0); lbl = lv_label_create(g_ctx.btn_play_pause); lv_label_set_text(lbl, LV_SYMBOL_PAUSE); lv_obj_center(lbl); lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx); g_ctx.btn_next = lv_button_create(cb); lv_obj_set_size(g_ctx.btn_next, 44, 38); lv_obj_set_style_radius(g_ctx.btn_next, 10, 0); lv_obj_set_style_bg_color(g_ctx.btn_next, lv_color_hex(0x252538), 0); lv_obj_set_style_text_color(g_ctx.btn_next, lv_color_hex(0xC4C4D4), 0); lbl = lv_label_create(g_ctx.btn_next); lv_label_set_text(lbl, LV_SYMBOL_NEXT); lv_obj_center(lbl); lv_obj_add_event_cb(g_ctx.btn_next, on_next_click, LV_EVENT_CLICKED, &g_ctx); g_ctx.btn_highlight = lv_button_create(cb); lv_obj_set_size(g_ctx.btn_highlight, 44, 38); lv_obj_set_style_radius(g_ctx.btn_highlight, 10, 0); lv_obj_set_style_bg_color(g_ctx.btn_highlight, lv_color_hex(0x252538), 0); lv_obj_set_style_text_color(g_ctx.btn_highlight, lv_color_hex(0x8C8CB0), 0); lbl = lv_label_create(g_ctx.btn_highlight); lv_label_set_text(lbl, LV_SYMBOL_DUMMY); lv_obj_center(lbl); lv_obj_add_event_cb(g_ctx.btn_highlight, on_highlight_click, LV_EVENT_CLICKED, &g_ctx); btn = lv_button_create(cb); lv_obj_set_size(btn, 38, 38); lv_obj_set_style_radius(btn, 10, 0); lv_obj_set_style_bg_color(btn, lv_color_hex(0x1E1E2E), 0); lv_obj_set_style_text_color(btn, lv_color_hex(0x6A6A8A), 0); lbl = lv_label_create(btn); lv_label_set_text(lbl, LV_SYMBOL_AUDIO); lv_obj_center(lbl); lv_obj_add_event_cb(btn, on_memo_click, LV_EVENT_CLICKED, &g_ctx); set_chrome_visible(&g_ctx, false); show_current_verse(&g_ctx); g_ctx.auto_timer = lv_timer_create(on_auto_timer, AUTO_ADV_MS, &g_ctx); } static void onHideApp(AppHandle app, void* data) { (void)app; (void)data; if (g_ctx.auto_timer) { lv_timer_delete(g_ctx.auto_timer); g_ctx.auto_timer = NULL; } save_progress(&g_ctx); unload_book(&g_ctx); // UI objects are deleted by LVGL parent cleanup memset(&g_ctx, 0, sizeof(g_ctx)); g_ctx.loaded_bnumber = -1; } int main(int argc, char* argv[]) { tt_app_register((AppRegistration){ .onShow = onShowApp, .onHide = onHideApp }); return 0; }