From 3bce9e772cfbf3606c0ab81bb6bb2a76e755c16f Mon Sep 17 00:00:00 2001 From: Brent Perteet Date: Fri, 21 Nov 2025 09:08:12 -0600 Subject: [PATCH] Add dark color scheme, backlight timeout, and L/R swap feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Invert UI colors: black background with white text, yellow highlights - Add 30-second backlight timeout with auto-wake on button press - Add Swap L/R menu option with checkbox to swap audio channels - Update volume page styling (yellow bar, full-screen container) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- main/bt_app.c | 13 +++- main/gui.c | 187 +++++++++++++++++++++++++++++++++++++++++++------- main/system.c | 26 +++++++ main/system.h | 7 ++ 4 files changed, 206 insertions(+), 27 deletions(-) diff --git a/main/bt_app.c b/main/bt_app.c index 8b98144..e1090d8 100644 --- a/main/bt_app.c +++ b/main/bt_app.c @@ -35,7 +35,7 @@ /* device name */ #define TARGET_DEVICE_NAME "ESP_SPEAKER" -#define LOCAL_DEVICE_NAME "ESP_A2DP_SRC" +#define LOCAL_DEVICE_NAME "SOUNDSHOT" /* AVRCP used transaction label */ #define APP_RC_CT_TL_GET_CAPS (0) @@ -798,6 +798,8 @@ void generate_exp(uint8_t *buf, int len, float balance) //float rate_hz = MIN_RATE_HZ * powf(MAX_RATE_HZ / MIN_RATE_HZ, abs_balance); float samples_per_click = SAMPLE_RATE / rate_hz; + bool swap_lr = system_getSwapLR(); + for (int i = 0; i < samples_needed; i++) { int16_t left = 0; int16_t right = 0; @@ -814,8 +816,13 @@ void generate_exp(uint8_t *buf, int len, float balance) click_timer -= 1.0f; - samples[i * 2 + 0] = left; - samples[i * 2 + 1] = right; + if (swap_lr) { + samples[i * 2 + 0] = right; + samples[i * 2 + 1] = left; + } else { + samples[i * 2 + 0] = left; + samples[i * 2 + 1] = right; + } } } diff --git a/main/gui.c b/main/gui.c index ef6733f..da41b8a 100644 --- a/main/gui.c +++ b/main/gui.c @@ -65,6 +65,8 @@ static void createBubble(lv_obj_t * scr); static void build_scrollable_menu(void); static void currentFocusIndex(menu_context_t *ctx); static lv_obj_t * addMenuItem(lv_obj_t *page, const char *text); +static lv_obj_t * addMenuItemWithCheckbox(lv_obj_t *page, const char *text, bool checked, lv_obj_t **checkbox_out); +static void update_checkbox_focus_style(lv_obj_t *cb, bool focused); static lv_obj_t* create_menu_container(void); static void show_bt_device_list(void); static void show_volume_control(void); @@ -103,6 +105,19 @@ static lv_obj_t *_currentPage = NULL; static GuiMode_t _mode = GUI_BUBBLE; +// Backlight timeout +#define BACKLIGHT_TIMEOUT_MS 30000 +static TickType_t _lastButtonPress = 0; +static bool _backlightOn = true; + +static void backlight_reset_timeout(void) { + _lastButtonPress = xTaskGetTickCount(); + if (!_backlightOn) { + gpio_set_level(PIN_NUM_BK_LIGHT, 1); + _backlightOn = true; + } +} + // Menu navigation stack #define MAX_MENU_STACK_SIZE 8 typedef struct { @@ -137,6 +152,9 @@ static int _current_volume = 50; // Default volume (0-100) static lv_obj_t* _calibration_page = NULL; static lv_timer_t* _calibration_timer = NULL; +// Swap L/R checkbox +static lv_obj_t* _swapLR_checkbox = NULL; + // Hide *all* headers/back buttons that LVGL may show for this menu static void menu_hide_headers(lv_obj_t *menu) { if (!menu) return; @@ -200,6 +218,7 @@ static lv_obj_t* create_main_page(void) { addMenuItem(main_page, "Calibration"); addMenuItem(main_page, "Volume"); + addMenuItemWithCheckbox(main_page, "Swap L/R", system_getSwapLR(), &_swapLR_checkbox); //addMenuItem(main_page, "About"); addMenuItem(main_page, "Exit"); @@ -238,6 +257,7 @@ static void cleanup_menu(void) { _volume_page = NULL; _volume_bar = NULL; _volume_label = NULL; + _swapLR_checkbox = NULL; if (_calibration_timer) { lv_timer_del(_calibration_timer); _calibration_timer = NULL; @@ -257,7 +277,9 @@ static void create_lvgl_demo(void) lvgl_port_lock(0); // Create a screen with black background lv_obj_t *scr = lv_scr_act(); - + lv_obj_set_style_bg_color(scr, lv_color_black(), 0); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + createBubble(scr); // Menu will be created lazily when needed @@ -462,6 +484,14 @@ static void btn_click_cb(lv_event_t * e) { menu_stack_push(_currentPage); show_volume_control(); UNLOCK(); + } else if (strcmp(txt, "Swap L/R") == 0) { + // Toggle the swap L/R state + system_toggleSwapLR(); + // Update the checkbox visual (now a label showing "X" or empty) + if (_swapLR_checkbox) { + lv_label_set_text(_swapLR_checkbox, system_getSwapLR() ? "X" : ""); + } + ESP_LOGI(TAG, "Swap L/R toggled to: %s", system_getSwapLR() ? "ON" : "OFF"); } else if (strcmp(txt, "Back") == 0) { LOCK(); menu_go_back(); @@ -498,18 +528,18 @@ static void refresh_highlight(void) { if (lv_obj_has_flag(child, LV_OBJ_FLAG_USER_1)) { - lv_obj_set_style_bg_color(child, lv_color_hex(0xFF8800), 0); + lv_obj_set_style_bg_color(child, lv_color_hex(0xFFFF00), 0); lv_obj_set_style_text_color(lv_obj_get_child(child,0), - lv_color_white(), 0); + lv_color_black(), 0); selected = child; ESP_LOGI(TAG, "Selected"); } - else + else { - lv_obj_set_style_bg_color(child, lv_color_white(), 0); + lv_obj_set_style_bg_color(child, lv_color_black(), 0); lv_obj_set_style_text_color(lv_obj_get_child(child,0), - lv_color_black(), 0); + lv_color_white(), 0); } index++; @@ -560,6 +590,12 @@ static void menuInc(int inc) // Update scroll on old focused item (stop scrolling) update_label_scroll(_menuContext.obj, false); + // Update checkbox style on old focused item (if it has one) + lv_obj_t *old_cb = lv_obj_get_user_data(_menuContext.obj); + if (old_cb) { + update_checkbox_focus_style(old_cb, false); + } + lv_obj_add_state(next, LV_STATE_FOCUSED); lv_obj_clear_state(_menuContext.obj, LV_STATE_FOCUSED); lv_obj_scroll_to_view(next, LV_ANIM_ON); @@ -567,6 +603,12 @@ static void menuInc(int inc) // Update scroll on new focused item (start scrolling) update_label_scroll(next, true); + // Update checkbox style on new focused item (if it has one) + lv_obj_t *new_cb = lv_obj_get_user_data(next); + if (new_cb) { + update_checkbox_focus_style(new_cb, true); + } + _menuContext.obj = next; _menuContext.selected = search_index; } @@ -626,6 +668,83 @@ static void label_scroll_focus_cb(lv_event_t * e) } } +static lv_obj_t * addMenuItemWithCheckbox(lv_obj_t *page, const char *text, bool checked, lv_obj_t **checkbox_out) +{ + if (!page || !text) { + ESP_LOGE(TAG, "Null parameters in addMenuItemWithCheckbox"); + return NULL; + } + + // Ensure styles are initialized + ensure_menu_styles(); + + lv_obj_t * btn = lv_btn_create(page); + if (!btn) { + ESP_LOGE(TAG, "Failed to create button in addMenuItemWithCheckbox"); + return NULL; + } + + lv_obj_set_size(btn, LV_PCT(100), ROW_H); + + lv_obj_add_style(btn, &_styleUnfocusedBtn, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_style(btn, &_styleFocusedBtn, LV_PART_MAIN | LV_STATE_FOCUSED); + + lv_obj_add_state(btn, LV_STATE_DEFAULT); + + lv_obj_set_style_radius(btn, 0, LV_PART_MAIN); + lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); + lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + + // Label on the left (same as regular menu item) + lv_obj_t * lbl = lv_label_create(btn); + if (!lbl) { + ESP_LOGE(TAG, "Failed to create label in addMenuItemWithCheckbox"); + lv_obj_del(btn); + return NULL; + } + + lv_label_set_text(lbl, text); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP); + lv_obj_set_width(lbl, LV_PCT(80)); + lv_obj_set_style_radius(lbl, 0, LV_PART_MAIN | LV_STATE_ANY); + lv_obj_set_style_border_width(lbl, 0, LV_PART_MAIN | LV_STATE_ANY); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 0, 0); + + // Label on the right to act as checkbox (shows "X" when checked, empty when not) + lv_obj_t * cb = lv_label_create(btn); + lv_label_set_text(cb, checked ? "X" : ""); + lv_obj_set_style_text_color(cb, lv_color_white(), 0); // Unfocused: white + lv_obj_align(cb, LV_ALIGN_RIGHT_MID, -4, 0); + + if (checkbox_out) { + *checkbox_out = cb; + } + + // Add focus event handler to control scrolling + lv_obj_add_event_cb(btn, label_scroll_focus_cb, LV_EVENT_ALL, NULL); + + // click callback + lv_obj_add_event_cb(btn, btn_click_cb, LV_EVENT_CLICKED, NULL); + + // Store checkbox in user data for focus styling updates + lv_obj_set_user_data(btn, cb); + + return btn; +} + +// Update checkbox label color based on parent focus state +static void update_checkbox_focus_style(lv_obj_t *cb, bool focused) { + if (!cb) return; + + if (focused) { + // Focused: black text (on yellow background) + lv_obj_set_style_text_color(cb, lv_color_black(), 0); + } else { + // Unfocused: white text (on black background) + lv_obj_set_style_text_color(cb, lv_color_white(), 0); + } +} + static lv_obj_t * addMenuItem(lv_obj_t *page, const char *text) { if (!page || !text) { @@ -719,12 +838,12 @@ static void ensure_menu_styles(void) { lv_style_init(&_styleFocusedBtn); lv_style_init(&_styleUnfocusedBtn); - lv_style_set_bg_color(&_styleUnfocusedBtn, lv_color_make(0xff,0xff,0xff)); // white bg - lv_style_set_text_color(&_styleUnfocusedBtn, lv_color_black()); // black text + lv_style_set_bg_color(&_styleUnfocusedBtn, lv_color_make(0x00,0x00,0x00)); // black bg + lv_style_set_text_color(&_styleUnfocusedBtn, lv_color_white()); // white text //lv_style_set_text_font(&_styleUnfocusedBtn, &lv_font_unscii_16); // larger font - lv_style_set_bg_color(&_styleFocusedBtn, lv_color_make(0x33,0x99,0xFF)); // blue bg - lv_style_set_text_color(&_styleFocusedBtn, lv_color_white()); // white text + lv_style_set_bg_color(&_styleFocusedBtn, lv_color_make(0xFF,0xFF,0x00)); // bright yellow bg + lv_style_set_text_color(&_styleFocusedBtn, lv_color_black()); // black text //lv_style_set_text_font(&_styleFocusedBtn, &lv_font_unscii_16); // larger font styles_initialized = true; @@ -1066,9 +1185,9 @@ static lv_obj_t* create_bt_device_page(bool start_discovery) { lv_label_set_text(_bt_status_item, "Devices:"); lv_obj_set_width(_bt_status_item, lv_pct(100)); lv_obj_set_height(_bt_status_item, 16); // Thinner header - lv_obj_set_style_bg_color(_bt_status_item, lv_color_make(0xE0, 0xE0, 0xE0), 0); + lv_obj_set_style_bg_color(_bt_status_item, lv_color_make(0x00, 0x00, 0x00), 0); lv_obj_set_style_bg_opa(_bt_status_item, LV_OPA_COVER, 0); - lv_obj_set_style_text_color(_bt_status_item, lv_color_black(), 0); + lv_obj_set_style_text_color(_bt_status_item, lv_color_white(), 0); lv_obj_set_style_pad_left(_bt_status_item, 5, 0); lv_obj_set_style_pad_top(_bt_status_item, 2, 0); lv_obj_set_style_text_align(_bt_status_item, LV_TEXT_ALIGN_LEFT, 0); @@ -1086,7 +1205,7 @@ static lv_obj_t* create_bt_device_page(bool start_discovery) { lv_obj_set_style_outline_width(_bt_device_container, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(_bt_device_container, 0, LV_PART_MAIN); lv_obj_set_style_pad_row(_bt_device_container, 0, LV_PART_MAIN); - lv_obj_set_style_bg_color(_bt_device_container, lv_color_white(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_bt_device_container, lv_color_black(), LV_PART_MAIN); lv_obj_set_style_bg_opa(_bt_device_container, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_flex_flow(_bt_device_container, LV_FLEX_FLOW_COLUMN); lv_obj_set_scrollbar_mode(_bt_device_container, LV_SCROLLBAR_MODE_AUTO); @@ -1235,9 +1354,11 @@ static lv_obj_t* create_volume_page(void) { // Create a container to center everything vertically lv_obj_t* container = lv_obj_create(_volume_page); - lv_obj_set_size(container, lv_pct(100), LV_SIZE_CONTENT); - lv_obj_set_style_bg_opa(container, LV_OPA_TRANSP, 0); + lv_obj_set_size(container, lv_pct(100), lv_pct(100)); + lv_obj_set_style_bg_color(container, lv_color_black(), 0); + lv_obj_set_style_bg_opa(container, LV_OPA_COVER, 0); lv_obj_set_style_border_width(container, 0, 0); + lv_obj_set_style_radius(container, 0, 0); lv_obj_set_style_pad_all(container, 0, 0); lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); @@ -1247,7 +1368,7 @@ static lv_obj_t* create_volume_page(void) { lv_obj_t* title = lv_label_create(container); lv_label_set_text(title, "Volume Control"); lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_text_color(title, lv_color_black(), 0); + lv_obj_set_style_text_color(title, lv_color_white(), 0); lv_obj_set_style_pad_bottom(title, 8, 0); // Create volume bar (progress bar) @@ -1255,6 +1376,8 @@ static lv_obj_t* create_volume_page(void) { lv_obj_set_size(_volume_bar, 120, 20); lv_bar_set_range(_volume_bar, 0, 100); lv_bar_set_value(_volume_bar, _current_volume, LV_ANIM_OFF); + lv_obj_set_style_bg_color(_volume_bar, lv_color_make(0x40, 0x40, 0x40), LV_PART_MAIN); // Dark gray background + lv_obj_set_style_bg_color(_volume_bar, lv_color_make(0xFF, 0xFF, 0x00), LV_PART_INDICATOR); // Yellow indicator lv_obj_set_style_pad_top(_volume_bar, 5, 0); lv_obj_set_style_pad_bottom(_volume_bar, 5, 0); @@ -1264,6 +1387,7 @@ static lv_obj_t* create_volume_page(void) { snprintf(volume_text, sizeof(volume_text), "%d%%", _current_volume); lv_label_set_text(_volume_label, volume_text); lv_obj_set_style_text_align(_volume_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(_volume_label, lv_color_white(), 0); lv_obj_set_style_pad_top(_volume_label, 5, 0); return _volume_page; @@ -1438,7 +1562,7 @@ static void calibration_click_cb(lv_event_t * e) { // Show "Calibrated" message centered lv_obj_t* label = lv_label_create(container); lv_label_set_text(label, "Calibrated"); - lv_obj_set_style_text_color(label, lv_color_black(), 0); + lv_obj_set_style_text_color(label, lv_color_white(), 0); lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0); lv_obj_center(label); } @@ -1467,7 +1591,7 @@ static void calibration_click_cb(lv_event_t * e) { // Show "Cleared" message centered lv_obj_t* label = lv_label_create(container); lv_label_set_text(label, "Cleared"); - lv_obj_set_style_text_color(label, lv_color_black(), 0); + lv_obj_set_style_text_color(label, lv_color_white(), 0); lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0); lv_obj_center(label); } @@ -1556,11 +1680,11 @@ static void build_scrollable_menu(void) { lv_style_init(&_styleFocusedBtn); lv_style_init(&_styleUnfocusedBtn); - lv_style_set_bg_color(&_styleUnfocusedBtn, lv_color_make(0xff,0xff,0xff)); // gray bg - lv_style_set_text_color(&_styleUnfocusedBtn, lv_color_hex(0xFF8800)); + lv_style_set_bg_color(&_styleUnfocusedBtn, lv_color_make(0x00,0x00,0x00)); // black bg + lv_style_set_text_color(&_styleUnfocusedBtn, lv_color_white()); - lv_style_set_bg_color(&_styleFocusedBtn, lv_color_make(0x33,0x99,0xFF)); // blue bg - lv_style_set_text_color(&_styleUnfocusedBtn,lv_color_black()); + lv_style_set_bg_color(&_styleFocusedBtn, lv_color_make(0xFF,0xFF,0x00)); // bright yellow bg + lv_style_set_text_color(&_styleFocusedBtn, lv_color_black()); // 2) Inside it, create the lv_menu and hide its sidebar/header @@ -1653,13 +1777,28 @@ static void gui_task(void *pvParameters) lv_obj_remove_flag(_bubble, LV_OBJ_FLAG_HIDDEN); UNLOCK(); + // Initialize backlight timeout + _lastButtonPress = xTaskGetTickCount(); + _backlightOn = true; + ESP_LOGI(TAG, "Start GUI Task..."); while (1) { - + // Check backlight timeout + if (_backlightOn) { + TickType_t elapsed = xTaskGetTickCount() - _lastButtonPress; + if (elapsed > pdMS_TO_TICKS(BACKLIGHT_TIMEOUT_MS)) { + gpio_set_level(PIN_NUM_BK_LIGHT, 0); + _backlightOn = false; + ESP_LOGI(TAG, "Backlight off due to timeout"); + } + } - if (xQueueReceive(q, &ev, pdMS_TO_TICKS(10)) == pdTRUE) + if (xQueueReceive(q, &ev, pdMS_TO_TICKS(10)) == pdTRUE) { + // Reset backlight timeout on any button press + backlight_reset_timeout(); + switch (ev) { #if 0 case (KEY_UP << KEY_LONG_PRESS): diff --git a/main/system.c b/main/system.c index d1a0430..e60faef 100644 --- a/main/system.c +++ b/main/system.c @@ -23,6 +23,7 @@ void system_init(void) _systemState.primaryAxis = PRIMARY_AXIS; _systemState.pairedDeviceCount = 0; _systemState.isCharging = false; + _systemState.swapLR = false; _systemEvent = xEventGroupCreate(); @@ -74,6 +75,31 @@ bool system_getChargeStatus(void) return charging; } +void system_setSwapLR(bool swap) +{ + xSemaphoreTake(_eventManager.mutex, portMAX_DELAY); + _systemState.swapLR = swap; + xSemaphoreGive(_eventManager.mutex); + ESP_LOGI("system", "Swap L/R: %s", swap ? "ON" : "OFF"); +} + +bool system_getSwapLR(void) +{ + bool swap; + xSemaphoreTake(_eventManager.mutex, portMAX_DELAY); + swap = _systemState.swapLR; + xSemaphoreGive(_eventManager.mutex); + return swap; +} + +void system_toggleSwapLR(void) +{ + xSemaphoreTake(_eventManager.mutex, portMAX_DELAY); + _systemState.swapLR = !_systemState.swapLR; + ESP_LOGI("system", "Swap L/R toggled: %s", _systemState.swapLR ? "ON" : "OFF"); + xSemaphoreGive(_eventManager.mutex); +} + void system_setZeroAngle(void) { xSemaphoreTake(_eventManager.mutex, portMAX_DELAY); diff --git a/main/system.h b/main/system.h index cdc87e2..a21d251 100644 --- a/main/system.h +++ b/main/system.h @@ -55,6 +55,9 @@ typedef struct SystemState_s // Charge status bool isCharging; + // Swap L/R audio channels + bool swapLR; + } SystemState_t; @@ -90,6 +93,10 @@ float system_getAngle(void); void system_setChargeStatus(bool charging); bool system_getChargeStatus(void); +void system_setSwapLR(bool swap); +bool system_getSwapLR(void); +void system_toggleSwapLR(void); + void system_setZeroAngle(void); void system_clearZeroAngle(void); float system_getZeroAngle(void);