feat: redesign BookPlayer picker controls

This commit is contained in:
Adolfo Reyna
2026-09-02 10:30:35 -04:00
parent 52bf3e1870
commit e42036a044
2 changed files with 303 additions and 60 deletions
+8
View File
@@ -63,3 +63,11 @@ Each book subfolder must contain a `manifest.json` file. Here is a sample format
- **Images**: PNG or JPG format. Recommended size is `320×240` pixels (or scaled to match standard aspect ratios).
- **Audio**: Mono `MP3` or uncompressed standard `WAV` files. For ESP32-S3 systems, lower sampling rates (e.g. 16 kHz mono) are recommended for memory efficiency.
## Book Selection and Playback
The picker displays the selected book's first page image full-screen with its title.
Tap the left or right side of the cover, or swipe right or left, to choose the
previous or next book. Tap the Play button to begin page-one narration through the
same playback flow used by the in-book player. Once playing, the existing
previous, pause/play, next, progress, and auto-advance controls remain unchanged.
+295 -60
View File
@@ -5,6 +5,7 @@
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include <lvgl/fonts.h>
#include <string.h>
#include <stdlib.h>
@@ -31,6 +32,8 @@
#define MAX_PATH 256
#define MAX_TITLE 128
#define MAX_AUTHOR 128
#define MAX_LVGL_IMAGE_PATH 1024
#define PICKER_COVER_LOAD_DELAY_MS 250
typedef enum {
STATE_IDLE,
@@ -53,6 +56,10 @@ typedef struct {
// Book picker list data
BookMetadata books[MAX_BOOKS];
int book_count;
int selected_book;
lv_coord_t picker_drag_start_x;
bool picker_dragging;
bool picker_dragged;
// Currently loaded book details
char current_book_slug[MAX_PATH];
@@ -64,8 +71,18 @@ typedef struct {
// UI elements
AppHandle app;
// Keep the decoded cover on a dedicated back sibling. The picker controls
// are a separate transparent sibling above it, mirroring the player image
// first / chrome second composition without invalidating the cover for UI
// updates.
lv_obj_t* picker_background;
lv_obj_t* picker_wrapper;
lv_obj_t* lst_books;
lv_obj_t* picker_cover;
lv_obj_t* picker_touch_area;
lv_obj_t* lbl_picker_title;
lv_obj_t* lbl_picker_author;
lv_obj_t* btn_picker_play;
lv_obj_t* lbl_picker_status;
lv_obj_t* player_wrapper;
lv_obj_t* header_bar;
@@ -80,6 +97,9 @@ typedef struct {
// Audio State
char current_audio_path[512];
char picker_cover_path[MAX_LVGL_IMAGE_PATH];
char player_image_path[MAX_LVGL_IMAGE_PATH];
lv_timer_t* picker_cover_timer;
uint8_t* audio_buf; // Shared MP3 input and WAV buffer
mp3d_sample_t* pcm_buf; // MP3 decoded pcm buffer
@@ -123,6 +143,11 @@ static void audio_playback_task(void* arg);
static void play_mp3(AppCtx* ctx);
static void play_wav(AppCtx* ctx);
static void scan_books(AppCtx* ctx);
static bool select_picker_book(AppCtx* ctx, int index);
static void open_selected_book(AppCtx* ctx, bool start_audio);
static void clear_picker_cover(AppCtx* ctx);
static void schedule_picker_cover_load(AppCtx* ctx);
static void cancel_picker_cover_load(AppCtx* ctx);
/* ─── Audio-stream helpers ─── */
// The flashed firmware exports the legacy device_find_* lookup API; the newer
@@ -280,6 +305,36 @@ static void wait_for_playback_task_to_exit(AppCtx* ctx, bool keep_stream_open) {
}
/* ─── Load Page Content (Image, Caption, Audio path) ─── */
static void load_image_from_page(AppCtx* ctx, cJSON* page, lv_obj_t* image,
char* lv_img_path, size_t lv_img_path_size) {
cJSON* img_item = cJSON_GetObjectItem(page, "image");
if (lv_img_path && lv_img_path_size > 0) lv_img_path[0] = '\0';
if (img_item && img_item->valuestring) {
char img_path[512];
snprintf(img_path, sizeof(img_path), "/sdcard/books/%s/%s", ctx->current_book_slug, img_item->valuestring);
FILE* img_file = fopen(img_path, "rb");
if (img_file) {
fclose(img_file);
if (!lv_img_path || lv_img_path_size == 0) {
lv_image_set_src(image, LV_SYMBOL_IMAGE);
return;
}
#ifdef ESP_PLATFORM
snprintf(lv_img_path, lv_img_path_size, "A:%s", img_path);
#else
snprintf(lv_img_path, lv_img_path_size, "A:/%s", img_path);
#endif
lv_image_set_src(image, lv_img_path);
return;
}
ESP_LOGW(TAG, "Image file not found: %s", img_path);
}
lv_image_set_src(image, LV_SYMBOL_IMAGE);
}
static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
wait_for_playback_task_to_exit(ctx, true);
@@ -290,31 +345,10 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
cJSON* page = cJSON_GetArrayItem(ctx->pages_array, page_index);
if (!page) return;
cJSON* img_item = cJSON_GetObjectItem(page, "image");
cJSON* snd_item = cJSON_GetObjectItem(page, "audio");
// Load Image file
if (img_item && img_item->valuestring) {
char img_path[512];
snprintf(img_path, sizeof(img_path), "/sdcard/books/%s/%s", ctx->current_book_slug, img_item->valuestring);
FILE* img_file = fopen(img_path, "rb");
if (img_file) {
fclose(img_file);
char lv_img_path[1024];
#ifdef ESP_PLATFORM
snprintf(lv_img_path, sizeof(lv_img_path), "A:%s", img_path);
#else
snprintf(lv_img_path, sizeof(lv_img_path), "A:/%s", img_path);
#endif
lv_image_set_src(ctx->img_page, lv_img_path);
} else {
ESP_LOGW(TAG, "Image file not found: %s", img_path);
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
}
} else {
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
}
load_image_from_page(ctx, page, ctx->img_page,
ctx->player_image_path, sizeof(ctx->player_image_path));
// Update Page index indicator
char ind_buf[32];
@@ -363,6 +397,7 @@ static void return_to_picker(AppCtx* ctx) {
}
lv_obj_add_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->picker_background, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
}
@@ -684,13 +719,19 @@ static void play_wav(AppCtx* ctx) {
vTaskDelete(NULL);
}
/* ─── Picker events ─── */
static void on_book_selected(lv_event_t* e) {
int index = (int)(intptr_t)lv_event_get_user_data(e);
AppCtx* ctx = &g_ctx;
/* ─── Full-screen book picker ─── */
static bool select_picker_book(AppCtx* ctx, int index) {
if (index < 0 || index >= ctx->book_count) return false;
if (ctx->manifest_root) {
cJSON_Delete(ctx->manifest_root);
ctx->manifest_root = NULL;
ctx->pages_array = NULL;
}
BookMetadata* book = &ctx->books[index];
strncpy(ctx->current_book_slug, book->slug, sizeof(ctx->current_book_slug) - 1);
ctx->current_book_slug[sizeof(ctx->current_book_slug) - 1] = '\0';
char manifest_path[512];
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", book->slug);
@@ -699,7 +740,7 @@ static void on_book_selected(lv_event_t* e) {
if (!json_str) {
const char* buttons[] = {"OK"};
tt_app_alertdialog_start("Read Error", "Failed to open the book manifest.", buttons, 1);
return;
return false;
}
ctx->manifest_root = cJSON_Parse(json_str);
@@ -708,7 +749,7 @@ static void on_book_selected(lv_event_t* e) {
if (!ctx->manifest_root) {
const char* buttons[] = {"OK"};
tt_app_alertdialog_start("JSON Error", "The book manifest is not formatted correctly.", buttons, 1);
return;
return false;
}
ctx->pages_array = cJSON_GetObjectItem(ctx->manifest_root, "pages");
@@ -718,7 +759,7 @@ static void on_book_selected(lv_event_t* e) {
cJSON_Delete(ctx->manifest_root);
ctx->manifest_root = NULL;
ctx->pages_array = NULL;
return;
return false;
}
ctx->page_count = cJSON_GetArraySize(ctx->pages_array);
@@ -728,30 +769,149 @@ static void on_book_selected(lv_event_t* e) {
cJSON_Delete(ctx->manifest_root);
ctx->manifest_root = NULL;
ctx->pages_array = NULL;
return;
return false;
}
ctx->selected_book = index;
lv_label_set_text(ctx->lbl_picker_title, book->title);
lv_label_set_text(ctx->lbl_picker_author, book->author[0] ? book->author : "");
char picker_status[64];
snprintf(picker_status, sizeof(picker_status), "%d / %d - Tap sides or swipe", index + 1, ctx->book_count);
lv_label_set_text(ctx->lbl_picker_status, picker_status);
schedule_picker_cover_load(ctx);
return true;
}
static void clear_picker_cover(AppCtx* ctx) {
if (!ctx->picker_cover) return;
// Release the previous file-backed source before its stable path buffer is
// reused. The symbol fallback has no file decoder or SD resource attached.
lv_image_set_src(ctx->picker_cover, LV_SYMBOL_IMAGE);
ctx->picker_cover_path[0] = '\0';
}
static void on_picker_cover_timer(lv_timer_t* timer) {
AppCtx* ctx = (AppCtx*)lv_timer_get_user_data(timer);
if (!ctx) return;
// The timer is one-shot and LVGL will dispose it after this callback.
// Clear the stored pointer first so a subsequent selection cannot delete a
// timer that is already being finalized.
ctx->picker_cover_timer = NULL;
if (!ctx->pages_array || !ctx->picker_cover) return;
cJSON* first_page = cJSON_GetArrayItem(ctx->pages_array, 0);
if (!first_page) return;
load_image_from_page(ctx, first_page, ctx->picker_cover,
ctx->picker_cover_path, sizeof(ctx->picker_cover_path));
}
static void schedule_picker_cover_load(AppCtx* ctx) {
cancel_picker_cover_load(ctx);
clear_picker_cover(ctx);
// Loading a full SD PNG from onShow has rebooted the S3 before LVGL's first
// event-loop pass. The player performs this same load from a user event;
// defer the picker equivalent until its UI is live and stable.
ctx->picker_cover_timer = lv_timer_create(on_picker_cover_timer, PICKER_COVER_LOAD_DELAY_MS, ctx);
if (ctx->picker_cover_timer) {
lv_timer_set_repeat_count(ctx->picker_cover_timer, 1);
} else {
ESP_LOGE(TAG, "Failed to schedule picker cover image load");
}
}
static void cancel_picker_cover_load(AppCtx* ctx) {
if (ctx->picker_cover_timer) {
lv_timer_delete(ctx->picker_cover_timer);
ctx->picker_cover_timer = NULL;
}
}
static void open_selected_book(AppCtx* ctx, bool start_audio) {
if (!ctx->manifest_root || ctx->page_count <= 0) return;
BookMetadata* book = &ctx->books[ctx->selected_book];
lv_label_set_text(ctx->lbl_book_title, book->title);
// Switch views
cancel_picker_cover_load(ctx);
lv_obj_add_flag(ctx->picker_background, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->bar_progress, LV_OBJ_FLAG_HIDDEN);
load_page(ctx, 0, false);
load_page(ctx, 0, start_audio);
}
static void change_picker_book(AppCtx* ctx, int index) {
if (index < 0 || index >= ctx->book_count || index == ctx->selected_book) return;
select_picker_book(ctx, index);
}
static void on_picker_touch(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
lv_event_code_t code = lv_event_get_code(e);
lv_indev_t* indev = lv_indev_active();
if (!ctx || !indev || ctx->book_count == 0) return;
lv_point_t point;
lv_indev_get_point(indev, &point);
if (code == LV_EVENT_PRESSED) {
ctx->picker_drag_start_x = point.x;
ctx->picker_dragging = true;
ctx->picker_dragged = false;
} else if (code == LV_EVENT_PRESSING && ctx->picker_dragging) {
int dx = point.x - ctx->picker_drag_start_x;
const int threshold = 28;
if (abs(dx) >= threshold) {
int steps = dx / threshold;
int next_index = ctx->selected_book - steps; // swipe left advances
if (next_index < 0) next_index = 0;
if (next_index >= ctx->book_count) next_index = ctx->book_count - 1;
if (next_index != ctx->selected_book) {
change_picker_book(ctx, next_index);
ctx->picker_dragged = true;
}
ctx->picker_drag_start_x = point.x;
}
} else if (code == LV_EVENT_RELEASED || code == LV_EVENT_PRESS_LOST) {
ctx->picker_dragging = false;
} else if (code == LV_EVENT_CLICKED) {
if (ctx->picker_dragged) {
ctx->picker_dragged = false;
return;
}
if (point.x < lv_obj_get_width(ctx->picker_touch_area) / 2) {
change_picker_book(ctx, ctx->selected_book - 1);
} else {
change_picker_book(ctx, ctx->selected_book + 1);
}
}
}
static void on_picker_play_click(lv_event_t* e) {
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
open_selected_book(ctx, true);
}
static void on_picker_close_click(lv_event_t* e) {
(void)e;
// Follow the normal external-app lifecycle so onHide releases the pending
// cover timer, file-backed image sources, manifest, and audio buffers.
tt_app_stop();
}
/* ─── Scan Books ─── */
static void scan_books(AppCtx* ctx) {
ctx->book_count = 0;
lv_obj_clean(ctx->lst_books);
DIR* dir = opendir("/sdcard/books");
if (!dir) {
ESP_LOGE(TAG, "Failed to scan books: /sdcard/books folder missing.");
lv_list_add_text(ctx->lst_books, "No SD card or books directory found.");
lv_label_set_text(ctx->lbl_picker_status, "No SD card or books directory found.");
return;
}
@@ -794,7 +954,7 @@ static void scan_books(AppCtx* ctx) {
closedir(dir);
if (ctx->book_count == 0) {
lv_list_add_text(ctx->lst_books, "No book manifest.json files found.");
lv_label_set_text(ctx->lbl_picker_status, "No book manifest.json files found.");
return;
}
@@ -809,17 +969,8 @@ static void scan_books(AppCtx* ctx) {
}
}
// Add items to screen list
for (int i = 0; i < ctx->book_count; ++i) {
char label_text[512];
if (ctx->books[i].author[0] != '\0') {
snprintf(label_text, sizeof(label_text), "%s\nby %s", ctx->books[i].title, ctx->books[i].author);
} else {
snprintf(label_text, sizeof(label_text), "%s", ctx->books[i].title);
}
lv_obj_t* btn = lv_list_add_button(ctx->lst_books, LV_SYMBOL_DIRECTORY, label_text);
lv_obj_add_event_cb(btn, on_book_selected, LV_EVENT_CLICKED, (void*)(intptr_t)i);
}
lv_obj_remove_flag(ctx->btn_picker_play, LV_OBJ_FLAG_HIDDEN);
select_picker_book(ctx, 0);
}
/* ─── Player Control Events ─── */
@@ -897,26 +1048,103 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
ESP_LOGE(TAG, "audio-stream device not found! Tried 'audio-stream' name and AUDIO_STREAM_TYPE");
}
// Create dual layouts
// 1. Picker Screen wrapper
// Create dual layouts.
// 1. The picker cover lives in its own back sibling. Keep all picker UI
// and touch objects in the transparent foreground sibling above so changing
// a title/status/button does not force the PNG-backed image through that
// overlay's redraw path.
g_ctx.picker_background = lv_obj_create(parent);
lv_obj_set_size(g_ctx.picker_background, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_border_width(g_ctx.picker_background, 0, 0);
lv_obj_set_style_pad_all(g_ctx.picker_background, 0, 0);
lv_obj_set_style_pad_gap(g_ctx.picker_background, 0, 0);
lv_obj_set_style_bg_color(g_ctx.picker_background, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_bg_opa(g_ctx.picker_background, LV_OPA_COVER, 0);
lv_obj_remove_flag(g_ctx.picker_background, LV_OBJ_FLAG_SCROLLABLE);
g_ctx.picker_cover = lv_image_create(g_ctx.picker_background);
lv_obj_align(g_ctx.picker_cover, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_style_bg_opa(g_ctx.picker_cover, LV_OPA_TRANSP, 0);
lv_obj_set_style_pad_all(g_ctx.picker_cover, 0, 0);
lv_obj_set_style_border_width(g_ctx.picker_cover, 0, 0);
lv_obj_remove_flag(g_ctx.picker_cover, LV_OBJ_FLAG_SCROLLABLE);
g_ctx.picker_wrapper = lv_obj_create(parent);
lv_obj_set_size(g_ctx.picker_wrapper, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_border_width(g_ctx.picker_wrapper, 0, 0);
lv_obj_set_style_pad_all(g_ctx.picker_wrapper, 0, 0);
lv_obj_set_style_pad_gap(g_ctx.picker_wrapper, 0, 0);
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_bg_opa(g_ctx.picker_wrapper, LV_OPA_TRANSP, 0);
lv_obj_remove_flag(g_ctx.picker_wrapper, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(g_ctx.picker_wrapper, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
g_ctx.picker_touch_area = lv_obj_create(g_ctx.picker_wrapper);
lv_obj_set_size(g_ctx.picker_touch_area, LV_PCT(100), LV_PCT(100));
lv_obj_align(g_ctx.picker_touch_area, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_style_bg_opa(g_ctx.picker_touch_area, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(g_ctx.picker_touch_area, 0, 0);
lv_obj_set_style_pad_all(g_ctx.picker_touch_area, 0, 0);
lv_obj_remove_flag(g_ctx.picker_touch_area, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(g_ctx.picker_touch_area, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(g_ctx.picker_touch_area, on_picker_touch, LV_EVENT_PRESSED, &g_ctx);
lv_obj_add_event_cb(g_ctx.picker_touch_area, on_picker_touch, LV_EVENT_PRESSING, &g_ctx);
lv_obj_add_event_cb(g_ctx.picker_touch_area, on_picker_touch, LV_EVENT_RELEASED, &g_ctx);
lv_obj_add_event_cb(g_ctx.picker_touch_area, on_picker_touch, LV_EVENT_PRESS_LOST, &g_ctx);
lv_obj_add_event_cb(g_ctx.picker_touch_area, on_picker_touch, LV_EVENT_CLICKED, &g_ctx);
g_ctx.lst_books = lv_list_create(g_ctx.picker_wrapper);
lv_obj_set_width(g_ctx.lst_books, LV_PCT(100));
lv_obj_align_to(g_ctx.lst_books, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
int32_t toolbar_height = lv_obj_get_height(toolbar);
int32_t parent_height = lv_obj_get_content_height(parent);
lv_obj_set_height(g_ctx.lst_books, parent_height - toolbar_height);
lv_obj_set_style_bg_color(g_ctx.lst_books, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_border_color(g_ctx.lst_books, lv_color_hex(0x313244), 0);
lv_obj_t* picker_title_panel = lv_obj_create(g_ctx.picker_wrapper);
lv_obj_set_size(picker_title_panel, LV_PCT(100), 68);
lv_obj_align(picker_title_panel, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_bg_color(picker_title_panel, lv_color_hex(0x11111B), 0);
lv_obj_set_style_bg_opa(picker_title_panel, LV_OPA_60, 0);
lv_obj_set_style_border_width(picker_title_panel, 0, 0);
lv_obj_set_style_pad_all(picker_title_panel, 0, 0);
lv_obj_remove_flag(picker_title_panel, LV_OBJ_FLAG_SCROLLABLE);
g_ctx.lbl_picker_title = lv_label_create(picker_title_panel);
lv_obj_set_width(g_ctx.lbl_picker_title, LV_PCT(86));
lv_obj_align(g_ctx.lbl_picker_title, LV_ALIGN_TOP_MID, 0, 10);
lv_obj_set_style_text_align(g_ctx.lbl_picker_title, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_picker_title, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_font(g_ctx.lbl_picker_title, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
lv_label_set_long_mode(g_ctx.lbl_picker_title, LV_LABEL_LONG_WRAP);
g_ctx.lbl_picker_author = lv_label_create(picker_title_panel);
lv_obj_set_width(g_ctx.lbl_picker_author, LV_PCT(86));
lv_obj_align(g_ctx.lbl_picker_author, LV_ALIGN_TOP_MID, 0, 42);
lv_obj_set_style_text_align(g_ctx.lbl_picker_author, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_picker_author, lv_color_hex(0xE0E0E8), 0);
lv_label_set_long_mode(g_ctx.lbl_picker_author, LV_LABEL_LONG_DOT);
g_ctx.lbl_picker_status = lv_label_create(g_ctx.picker_wrapper);
lv_obj_set_width(g_ctx.lbl_picker_status, LV_PCT(86));
lv_obj_align(g_ctx.lbl_picker_status, LV_ALIGN_BOTTOM_MID, 0, -54);
lv_obj_set_style_text_align(g_ctx.lbl_picker_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_picker_status, lv_color_hex(0xF5F5FA), 0);
g_ctx.btn_picker_play = lv_button_create(g_ctx.picker_wrapper);
lv_obj_set_size(g_ctx.btn_picker_play, 58, 42);
lv_obj_align(g_ctx.btn_picker_play, LV_ALIGN_BOTTOM_MID, 0, -10);
lv_obj_set_style_radius(g_ctx.btn_picker_play, 21, 0);
lv_obj_set_style_bg_color(g_ctx.btn_picker_play, lv_color_hex(0x89B4FA), 0);
lv_obj_set_style_text_color(g_ctx.btn_picker_play, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_picker_play = lv_label_create(g_ctx.btn_picker_play);
lv_label_set_text(lbl_picker_play, LV_SYMBOL_PLAY);
lv_obj_center(lbl_picker_play);
lv_obj_add_event_cb(g_ctx.btn_picker_play, on_picker_play_click, LV_EVENT_CLICKED, &g_ctx);
lv_obj_add_flag(g_ctx.btn_picker_play, LV_OBJ_FLAG_HIDDEN);
// A dedicated side exit control leaves the center cover, top title panel,
// and bottom play target clear while reserving its own small touch area.
lv_obj_t* btn_picker_close = lv_button_create(g_ctx.picker_wrapper);
lv_obj_set_size(btn_picker_close, 42, 42);
lv_obj_align(btn_picker_close, LV_ALIGN_RIGHT_MID, -8, 0);
lv_obj_set_style_radius(btn_picker_close, 21, 0);
lv_obj_set_style_bg_color(btn_picker_close, lv_color_hex(0xD64545), 0);
lv_obj_set_style_text_color(btn_picker_close, lv_color_hex(0xFFFFFF), 0);
lv_obj_t* lbl_picker_close = lv_label_create(btn_picker_close);
lv_label_set_text(lbl_picker_close, LV_SYMBOL_CLOSE);
lv_obj_center(lbl_picker_close);
lv_obj_add_event_cb(btn_picker_close, on_picker_close_click, LV_EVENT_CLICKED, &g_ctx);
// 2. Player Screen wrapper (hidden on start)
g_ctx.player_wrapper = lv_obj_create(parent);
@@ -1031,6 +1259,13 @@ static void onHideApp(AppHandle app, void* data) {
wait_for_playback_task_to_exit(&g_ctx, false);
close_stream_if_open(&g_ctx);
cancel_picker_cover_load(&g_ctx);
clear_picker_cover(&g_ctx);
if (g_ctx.img_page) {
lv_image_set_src(g_ctx.img_page, LV_SYMBOL_IMAGE);
g_ctx.player_image_path[0] = '\0';
}
if (g_ctx.manifest_root) {
cJSON_Delete(g_ctx.manifest_root);
g_ctx.manifest_root = NULL;