feat(ImmichElias): add person selector like Bible books, 4 persons Elias/Grace/Alicia/Adolfo, picker UI with toolbar, player bottom-only, random pagination, cache, fullscreen

This commit is contained in:
Adolfo
2026-07-25 16:29:24 -04:00
parent d06f8ddbb5
commit 6a1fe5dda6
+138 -21
View File
@@ -38,6 +38,20 @@ static const char* TAG = "ImmichElias";
#define FALLBACK_W 320 #define FALLBACK_W 320
#define FALLBACK_H 240 #define FALLBACK_H 240
typedef struct {
char id[64];
char name[32];
char count_str[16];
} PersonInfo;
static const PersonInfo PERSONS[] = {
{"cbece6a9-720f-482a-a945-c9ba5ca41fd4", "Elias", "10739"},
{"ad40f024-c34b-4ec6-a19e-2fa157b259e5", "Grace", ""},
{"6d9e4ae6-143d-43c9-bd42-437e3edfe2df", "Alicia", ""},
{"3ddbc731-e857-473c-8226-8af97e5cb86e", "Adolfo", ""},
};
#define PERSONS_COUNT (sizeof(PERSONS)/sizeof(PERSONS[0]))
typedef struct { typedef struct {
char id[64]; char id[64];
char thumbnail_url[256]; char thumbnail_url[256];
@@ -150,7 +164,8 @@ static void shuffle_photos(void) {
ESP_LOGI(TAG, "Photos shuffled random, count %d", photo_count); ESP_LOGI(TAG, "Photos shuffled random, count %d", photo_count);
} }
static int fetch_person_assets(void) { static int fetch_person_assets(const char* person_id) {
if (!person_id || person_id[0]=='\0') person_id = PERSONS[0].id;
char* json_buf = heap_caps_malloc(JSON_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); char* json_buf = heap_caps_malloc(JSON_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!json_buf) json_buf = heap_caps_malloc(JSON_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT); if (!json_buf) json_buf = heap_caps_malloc(JSON_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT);
if (!json_buf) return -1; if (!json_buf) return -1;
@@ -158,14 +173,21 @@ static int fetch_person_assets(void) {
char url[256]; char url[256];
snprintf(url, sizeof(url), "%s/api/search/metadata", IMMICH_BASE_URL); snprintf(url, sizeof(url), "%s/api/search/metadata", IMMICH_BASE_URL);
// Pick random page to get variety from 10k+ photos // Pick random page to get variety from 10k+ photos
// Estimate total photos 10739, with MAX_PHOTOS 100 => ~107 pages
int total_estimate = 10739; int total_estimate = 10739;
// Try to use count from PERSONS if available
for (int pi=0; pi<PERSONS_COUNT; pi++) {
if (strcmp(PERSONS[pi].id, person_id)==0 && PERSONS[pi].count_str[0]!='\0') {
total_estimate = atoi(PERSONS[pi].count_str);
if (total_estimate<=0) total_estimate=10739;
break;
}
}
int max_page = total_estimate / MAX_PHOTOS; int max_page = total_estimate / MAX_PHOTOS;
if (max_page < 1) max_page = 1; if (max_page < 1) max_page = 1;
int random_page = (esp_random() % max_page) + 1; int random_page = (esp_random() % max_page) + 1;
char body[320]; char body[320];
snprintf(body, sizeof(body), "{\"personIds\":[\"%s\"],\"size\":%d,\"page\":%d}", IMMICH_PERSON_ID, MAX_PHOTOS, random_page); snprintf(body, sizeof(body), "{\"personIds\":[\"%s\"],\"size\":%d,\"page\":%d}", person_id, MAX_PHOTOS, random_page);
ESP_LOGI(TAG, "Fetching person assets page %d/%d size %d", random_page, max_page, MAX_PHOTOS); ESP_LOGI(TAG, "Fetching person %s assets page %d/%d size %d", person_id, random_page, max_page, MAX_PHOTOS);
int status = http_post_json(url, body, &hb); int status = http_post_json(url, body, &hb);
if (status != 200) { heap_caps_free(json_buf); return -1; } if (status != 200) { heap_caps_free(json_buf); return -1; }
if (hb.len >= JSON_BUF_SIZE) hb.len = JSON_BUF_SIZE - 1; if (hb.len >= JSON_BUF_SIZE) hb.len = JSON_BUF_SIZE - 1;
@@ -247,6 +269,10 @@ typedef struct {
AppHandle app; AppHandle app;
volatile bool visible; volatile bool visible;
volatile bool stop_requested; volatile bool stop_requested;
// Picker (persons) like Bible book selector
lv_obj_t* picker_wrapper;
lv_obj_t* lst_persons;
// Player (photos)
lv_obj_t* player_wrapper; lv_obj_t* player_wrapper;
lv_obj_t* header_bar; lv_obj_t* header_bar;
lv_obj_t* ctrl_bar; lv_obj_t* ctrl_bar;
@@ -275,6 +301,9 @@ typedef struct {
int disp_h; int disp_h;
int img_w; int img_w;
int img_h; int img_h;
// Current person
char current_person_id[64];
char current_person_name[32];
} AppCtx; } AppCtx;
static bool get_cache_dir_path(AppCtx* ctx, char* out_dir, size_t out_size) { static bool get_cache_dir_path(AppCtx* ctx, char* out_dir, size_t out_size) {
@@ -624,9 +653,11 @@ static void fetch_task_fn(void* arg) {
if (!ctx) { vTaskDelete(NULL); return; } if (!ctx) { vTaskDelete(NULL); return; }
if (ctx->stop_requested) { ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL); return; } if (ctx->stop_requested) { ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL); return; }
ctx->fetch_in_progress=true; ctx->fetch_in_progress=true;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Fetching %s photos...", PERSON_NAME); const char* fetch_name = ctx->current_person_name[0] ? ctx->current_person_name : PERSON_NAME;
const char* fetch_id = ctx->current_person_id[0] ? ctx->current_person_id : PERSONS[0].id;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Fetching %s photos...", fetch_name);
ui_update_all(ctx); ui_update_all(ctx);
int n = fetch_person_assets(); int n = fetch_person_assets(fetch_id);
if (ctx->stop_requested) { ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL); return; } if (ctx->stop_requested) { ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL); return; }
if (n>0) { if (n>0) {
ctx->current_idx=0; ctx->current_idx=0;
@@ -720,13 +751,32 @@ static void reload_event_cb(lv_event_t* e) {
snprintf(ctx->status_text, sizeof(ctx->status_text), "Reloading %dx%d...", ctx->img_w, ctx->img_h); snprintf(ctx->status_text, sizeof(ctx->status_text), "Reloading %dx%d...", ctx->img_w, ctx->img_h);
ui_update_all(ctx); show_chrome(ctx); start_fetch_task(ctx); ui_update_all(ctx); show_chrome(ctx); start_fetch_task(ctx);
} }
static void back_event_cb(lv_event_t* e) { (void)e; tt_app_stop(); } static void show_picker(AppCtx* ctx);
static void show_player(AppCtx* ctx);
static void back_event_cb(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (!ctx) { tt_app_stop(); return; }
// If in player, go back to person picker like Bible book selector
if (ctx->picker_wrapper && ctx->player_wrapper) {
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) { tt_app_stop(); return; }
bool player_visible = !lv_obj_has_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
tt_lvgl_unlock();
if (player_visible) {
show_picker(ctx);
return;
}
}
tt_app_stop();
}
static void* create_data(void) { static void* create_data(void) {
AppCtx* ctx = calloc(1, sizeof(AppCtx)); AppCtx* ctx = calloc(1, sizeof(AppCtx));
if (ctx) { if (ctx) {
ctx->current_idx=0; ctx->disp_w=FALLBACK_W; ctx->disp_h=FALLBACK_H; ctx->img_w=FALLBACK_W; ctx->img_h=FALLBACK_H; ctx->current_idx=0; ctx->disp_w=FALLBACK_W; ctx->disp_h=FALLBACK_H; ctx->img_w=FALLBACK_W; ctx->img_h=FALLBACK_H;
snprintf(ctx->status_text, sizeof(ctx->status_text), "Init %s proxy %s (random)", IMMICH_BASE_URL, RESIZE_PROXY_URL); strncpy(ctx->current_person_id, PERSONS[0].id, sizeof(ctx->current_person_id)-1);
strncpy(ctx->current_person_name, PERSONS[0].name, sizeof(ctx->current_person_name)-1);
snprintf(ctx->status_text, sizeof(ctx->status_text), "Init %s proxy %s (random) person %s", IMMICH_BASE_URL, RESIZE_PROXY_URL, ctx->current_person_name);
ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!ctx->thumb_buf) ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT); if (!ctx->thumb_buf) ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT);
} }
@@ -742,9 +792,73 @@ static void destroy_data(void* data) {
if (ctx->thumb_buf) { heap_caps_free(ctx->thumb_buf); ctx->thumb_buf=NULL; } if (ctx->thumb_buf) { heap_caps_free(ctx->thumb_buf); ctx->thumb_buf=NULL; }
free(ctx); free(ctx);
} }
static void on_create(AppHandle app, void* data) { AppCtx* ctx=(AppCtx*)data; if(ctx) ctx->app=app; ESP_LOGI(TAG,"onCreate proxy=%s random %dx%d", RESIZE_PROXY_URL, ctx?ctx->img_w:0, ctx?ctx->img_h:0); } static void on_create(AppHandle app, void* data) { AppCtx* ctx=(AppCtx*)data; if(ctx) ctx->app=app; ESP_LOGI(TAG,"onCreate proxy=%s random %dx%d person %s", RESIZE_PROXY_URL, ctx?ctx->img_w:0, ctx?ctx->img_h:0, ctx?ctx->current_person_name:"?"); }
static void on_destroy(AppHandle app, void* data) { (void)app;(void)data; } static void on_destroy(AppHandle app, void* data) { (void)app;(void)data; }
static void person_selected_cb(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
if (!ctx) return;
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
if (idx < 0 || idx >= PERSONS_COUNT) return;
const PersonInfo* p = &PERSONS[idx];
strncpy(ctx->current_person_id, p->id, sizeof(ctx->current_person_id)-1);
strncpy(ctx->current_person_name, p->name, sizeof(ctx->current_person_name)-1);
ESP_LOGI(TAG, "Person selected %s (%s)", p->name, p->id);
photo_count = 0;
ctx->current_idx = 0;
ctx->thumb_len = 0;
ctx->has_image = false;
show_player(ctx);
start_fetch_task(ctx);
}
static void build_person_picker(AppCtx* ctx, lv_obj_t* parent) {
ctx->picker_wrapper = lv_obj_create(parent);
lv_obj_set_size(ctx->picker_wrapper, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_border_width(ctx->picker_wrapper, 0, 0);
lv_obj_set_style_pad_all(ctx->picker_wrapper, 0, 0);
lv_obj_set_style_bg_color(ctx->picker_wrapper, lv_color_hex(0x121212), LV_PART_MAIN);
lv_obj_set_style_bg_opa(ctx->picker_wrapper, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(ctx->picker_wrapper, ctx->app);
ctx->lst_persons = lv_list_create(ctx->picker_wrapper);
lv_obj_set_width(ctx->lst_persons, LV_PCT(100));
lv_obj_align_to(ctx->lst_persons, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
// Calculate height
int32_t th = 44;
// Use parent height minus toolbar
lv_obj_set_height(ctx->lst_persons, LV_PCT(90));
lv_obj_set_style_bg_color(ctx->lst_persons, lv_color_hex(0x121212), LV_PART_MAIN);
for (int i=0;i<PERSONS_COUNT;i++) {
const PersonInfo* person = &PERSONS[i];
char label[64];
if (person->count_str[0]) snprintf(label, sizeof(label), "%s (%s)", person->name, person->count_str);
else snprintf(label, sizeof(label), "%s", person->name);
lv_obj_t* btn = lv_list_add_button(ctx->lst_persons, LV_SYMBOL_DIRECTORY, label);
lv_obj_set_user_data(btn, (void*)(intptr_t)i);
lv_obj_add_event_cb(btn, person_selected_cb, LV_EVENT_CLICKED, ctx);
}
}
static void show_picker(AppCtx* ctx) {
if (!ctx) return;
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) return;
if (ctx->player_wrapper) lv_obj_add_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
if (ctx->picker_wrapper) lv_obj_remove_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
tt_lvgl_unlock();
}
static void show_player(AppCtx* ctx) {
if (!ctx) return;
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) return;
if (ctx->picker_wrapper) lv_obj_add_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
if (ctx->player_wrapper) lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
tt_lvgl_unlock();
show_chrome(ctx);
}
static void on_show(AppHandle app, void* data, lv_obj_t* parent) { static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
AppCtx* ctx=(AppCtx*)data; if(!ctx) return; AppCtx* ctx=(AppCtx*)data; if(!ctx) return;
ctx->visible=true; ctx->stop_requested=false; ctx->app=app; ctx->visible=true; ctx->stop_requested=false; ctx->app=app;
@@ -756,6 +870,9 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_set_style_bg_color(parent, lv_color_hex(0x121212), LV_PART_MAIN); lv_obj_set_style_bg_color(parent, lv_color_hex(0x121212), LV_PART_MAIN);
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, LV_PART_MAIN);
// Picker for persons (like Bible book selector)
build_person_picker(ctx, parent);
// Fullscreen player like Bible app - image background, only bottom controls // Fullscreen player like Bible app - image background, only bottom controls
ctx->player_wrapper = lv_obj_create(parent); ctx->player_wrapper = lv_obj_create(parent);
lv_obj_set_size(ctx->player_wrapper, LV_PCT(100), LV_PCT(100)); lv_obj_set_size(ctx->player_wrapper, LV_PCT(100), LV_PCT(100));
@@ -765,6 +882,7 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_set_style_bg_color(ctx->player_wrapper, lv_color_hex(0x121212), LV_PART_MAIN); lv_obj_set_style_bg_color(ctx->player_wrapper, lv_color_hex(0x121212), LV_PART_MAIN);
lv_obj_set_style_bg_opa(ctx->player_wrapper, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_opa(ctx->player_wrapper, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
// Image covers entire background // Image covers entire background
ctx->img_page = lv_image_create(ctx->player_wrapper); ctx->img_page = lv_image_create(ctx->player_wrapper);
@@ -777,7 +895,6 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_add_flag(ctx->img_page, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(ctx->img_page, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(ctx->img_page, on_image_click, LV_EVENT_CLICKED, ctx); lv_obj_add_event_cb(ctx->img_page, on_image_click, LV_EVENT_CLICKED, ctx);
// Bottom-only controls like Bible app - no top header, no info/status text
ctx->header_bar = NULL; ctx->header_bar = NULL;
ctx->lbl_book_title = NULL; ctx->lbl_book_title = NULL;
ctx->lbl_indicator = NULL; ctx->lbl_indicator = NULL;
@@ -798,12 +915,11 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_set_flex_align(ctx->ctrl_bar, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_flex_align(ctx->ctrl_bar, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_SCROLLABLE);
// Left close button (like Bible app X)
ctx->btn_back = lv_btn_create(ctx->ctrl_bar); ctx->btn_back = lv_btn_create(ctx->ctrl_bar);
lv_obj_set_size(ctx->btn_back, 44, 32); lv_obj_set_size(ctx->btn_back, 44, 32);
lv_obj_set_style_radius(ctx->btn_back, 8, 0); lv_obj_set_style_radius(ctx->btn_back, 8, 0);
lv_obj_set_style_bg_color(ctx->btn_back, lv_color_hex(0x252538), LV_PART_MAIN); lv_obj_set_style_bg_color(ctx->btn_back, lv_color_hex(0x252538), LV_PART_MAIN);
lv_obj_t* lbl_back = lv_label_create(ctx->btn_back); lv_label_set_text(lbl_back, LV_SYMBOL_CLOSE); lv_obj_center(lbl_back); lv_obj_t* lbl_back = lv_label_create(ctx->btn_back); lv_label_set_text(lbl_back, LV_SYMBOL_LEFT); lv_obj_center(lbl_back);
lv_obj_add_event_cb(ctx->btn_back, back_event_cb, LV_EVENT_CLICKED, ctx); lv_obj_add_event_cb(ctx->btn_back, back_event_cb, LV_EVENT_CLICKED, ctx);
ctx->btn_prev = lv_btn_create(ctx->ctrl_bar); ctx->btn_prev = lv_btn_create(ctx->ctrl_bar);
@@ -828,24 +944,25 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_t* ln = lv_label_create(ctx->btn_next); lv_label_set_text(ln, LV_SYMBOL_NEXT); lv_obj_center(ln); lv_obj_t* ln = lv_label_create(ctx->btn_next); lv_label_set_text(ln, LV_SYMBOL_NEXT); lv_obj_center(ln);
lv_obj_add_event_cb(ctx->btn_next, next_event_cb, LV_EVENT_CLICKED, ctx); lv_obj_add_event_cb(ctx->btn_next, next_event_cb, LV_EVENT_CLICKED, ctx);
// Timers: slideshow every 60s, autohide chrome after 3s
// Only slideshow timer (60s), no autohide for stability - chrome toggles on tap like BookPlayer
if (tt_lvgl_lock(pdMS_TO_TICKS(500))) { if (tt_lvgl_lock(pdMS_TO_TICKS(500))) {
if (ctx->slideshow_timer) { lv_timer_delete(ctx->slideshow_timer); ctx->slideshow_timer=NULL; } if (ctx->slideshow_timer) { lv_timer_delete(ctx->slideshow_timer); ctx->slideshow_timer=NULL; }
ctx->slideshow_timer = lv_timer_create(slideshow_timer_cb, 60000, ctx); ctx->slideshow_timer = lv_timer_create(slideshow_timer_cb, 60000, ctx);
if (ctx->autohide_timer) { lv_timer_delete(ctx->autohide_timer); ctx->autohide_timer=NULL; } if (ctx->autohide_timer) { lv_timer_delete(ctx->autohide_timer); ctx->autohide_timer=NULL; }
// Autohide disabled - user taps to hide/show like BookPlayer
ctx->autohide_timer = NULL; ctx->autohide_timer = NULL;
tt_lvgl_unlock(); tt_lvgl_unlock();
} }
if (photo_count>0) { // Always show person picker first like Bible book selector
if(ctx->current_idx>=photo_count) ctx->current_idx=0; show_picker(ctx);
snprintf(ctx->status_text,sizeof(ctx->status_text),"Cached %d random %dx%d",photo_count, ctx->img_w, ctx->img_h); // If we already have photos for current person, user can still see them after selecting
ui_update_all(ctx); show_chrome(ctx); start_thumb_task(ctx); // Don't auto-start fetch here; wait for person selection to fetch fresh random page
// But if we have cached photos from previous session for current person, we could show player directly
// For now, just show picker and let user choose person
if (photo_count>0 && ctx->current_person_id[0]!='\0') {
// Optionally prefetch in background
snprintf(ctx->status_text,sizeof(ctx->status_text),"Cached %d for %s - select person", photo_count, ctx->current_person_name);
} else { } else {
snprintf(ctx->status_text,sizeof(ctx->status_text),"Fetching %s random %dx%d via %s disp %dx%d",PERSON_NAME, ctx->img_w, ctx->img_h, RESIZE_PROXY_URL, ctx->disp_w, ctx->disp_h); snprintf(ctx->status_text,sizeof(ctx->status_text),"Select a person");
ui_update_all(ctx); show_chrome(ctx); start_fetch_task(ctx);
} }
} }