diff --git a/main/modes/radio.c b/main/modes/radio.c index 72c7ee3..f202ded 100644 --- a/main/modes/radio.c +++ b/main/modes/radio.c @@ -1,30 +1,28 @@ // Webradio mode screen for Lisael Box. // -// A soft-gradient screen with a "← Accueil" back button, a "Radio" title, a -// column of big station buttons (one tap = lisael_radio_play(url)), and a Stop -// button (lisael_radio_stop()). A small status label echoes the current action. +// Soft-gradient screen with a "← Accueil" back button, a "Radio" title, a +// Lunii-style horizontal carousel of station cards (one big card per slide, +// swipe/arrows to browse, tap = lisael_radio_play(url)), a Stop button +// (lisael_radio_stop()) and a small status label echoing the current action. // // The station table below is local on purpose (radio_pipeline.h is the only // audio contract we depend on). Each entry is a live HTTP/HTTPS MP3 stream. // // ⚠️ STREAM URLs — VERIFY ON HARDWARE BEFORE SHIPPING ⚠️ // French kids' webradios are mostly hosted on RadioKing / Zeno.FM, whose direct -// stream endpoints are loaded dynamically by their JS players and are not -// publicly documented in a stable text form. The URLs below use each platform's -// documented direct-play form: -// - RadioKing: https://www.radioking.com/play/ 302-redirects to the -// live listen.radioking.com/ MP3 mount (our HTTP client follows 3xx). -// - Zeno.FM: https://stream.zeno.fm/ is a direct MP3 stream. -// Confirm with `curl -IL ` that the final response is audio/mpeg. If a -// station's slug/mount changed, update it here. All are HTTPS (handled by the -// certificate bundle in radio_pipeline.c). +// stream endpoints are loaded dynamically by their JS players. The URLs below +// use the real listen.radioking.com MP3 mounts (content-type audio/mpeg). If a +// station changes, re-read it from play.radioking.io/. All HTTPS (handled +// by the certificate bundle in radio_pipeline.c). #include "ui/ui.h" #include "ui/fonts/lisael_fonts.h" +#include "ui/assets/lisael_icons.h" #include "audio/radio_pipeline.h" #include "audio/audio_player.h" #include +#include #include "esp_log.h" static const char *TAG = "ui.radio"; @@ -37,8 +35,7 @@ typedef struct { // DIRECT MP3 stream mounts (content-type audio/mpeg), verified 2026-06-14 from // the RadioKing players. The /play/ pages are HTML — these are the real -// listen.radioking.com mounts. If a stream changes, re-read it from -// play.radioking.io/ (grep listen.radioking.com/radio//stream/). +// listen.radioking.com mounts. static const radio_station_t k_stations[] = { // Radio Doudou — comptines & berceuses pour les tout-petits. { "Radio Doudou", "https://listen.radioking.com/radio/11565/stream/22961" }, @@ -46,10 +43,19 @@ static const radio_station_t k_stations[] = { { "Pomme d'Api", "https://listen.radioking.com/radio/361753/stream/411352" }, }; -#define K_STATIONS_COUNT (sizeof(k_stations) / sizeof(k_stations[0])) +#define K_STATIONS_COUNT ((int)(sizeof(k_stations) / sizeof(k_stations[0]))) + +static const uint32_t k_colors[] = { + 0xFFC078, 0xFFA8A8, 0xFFD43B, 0x99E9F2, 0xB197FC, 0x8CE99A, +}; + +#define RADIO_SLIDE_W 320 static lv_obj_t *s_root; static lv_obj_t *s_status_lbl; +static lv_obj_t *s_track; +static lv_obj_t *s_dots[8]; +static int s_cur; static void set_status(const char *txt) { @@ -58,6 +64,53 @@ static void set_status(const char *txt) } } +// --- carousel mechanics ------------------------------------------------------ +static void dots_update(int idx) +{ + for (int i = 0; i < K_STATIONS_COUNT; i++) { + if (s_dots[i]) { + lv_obj_set_style_bg_opa(s_dots[i], i == idx ? LV_OPA_COVER : LV_OPA_30, 0); + } + } +} + +static void scroll_cb(lv_event_t *e) +{ + lv_obj_t *t = lv_event_get_target(e); + int idx = (lv_obj_get_scroll_x(t) + RADIO_SLIDE_W / 2) / RADIO_SLIDE_W; + if (idx < 0) idx = 0; + if (idx >= K_STATIONS_COUNT) idx = K_STATIONS_COUNT - 1; + s_cur = idx; + dots_update(idx); +} + +static void arrow_cb(lv_event_t *e) +{ + int idx = s_cur + (int)(intptr_t)lv_event_get_user_data(e); + if (idx < 0) idx = 0; + if (idx >= K_STATIONS_COUNT) idx = K_STATIONS_COUNT - 1; + s_cur = idx; + lv_obj_scroll_to_x(s_track, idx * RADIO_SLIDE_W, LV_ANIM_ON); + dots_update(idx); +} + +static void make_arrow(lv_obj_t *parent, const char *sym, lv_align_t al, int dir) +{ + lv_obj_t *b = lv_button_create(parent); + lv_obj_remove_style_all(b); + lv_obj_set_size(b, 38, 60); + lv_obj_set_style_radius(b, 19, 0); + lv_obj_set_style_bg_opa(b, LV_OPA_40, 0); + lv_obj_set_style_bg_color(b, lv_color_white(), 0); + lv_obj_align(b, al, (al == LV_ALIGN_LEFT_MID) ? 2 : -2, -10); + lv_obj_add_event_cb(b, arrow_cb, LV_EVENT_CLICKED, (void *)(intptr_t)dir); + lv_obj_t *l = lv_label_create(b); + lv_label_set_text(l, sym); + lv_obj_set_style_text_color(l, lv_color_hex(0x8A4B16), 0); + lv_obj_center(l); +} + +// --- playback --------------------------------------------------------------- static void station_cb(lv_event_t *e) { const radio_station_t *st = (const radio_station_t *)lv_event_get_user_data(e); @@ -76,64 +129,119 @@ static void stop_cb(lv_event_t *e) set_status("Silence"); } +static void add_station_slide(lv_obj_t *track, const radio_station_t *st, uint32_t color) +{ + lv_obj_t *slide = lv_obj_create(track); + lv_obj_remove_style_all(slide); + lv_obj_set_size(slide, RADIO_SLIDE_W, LV_PCT(100)); + lv_obj_clear_flag(slide, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(slide, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(slide, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *card = lv_button_create(slide); + lv_obj_add_style(card, lisael_ui_style_tile(), 0); + lv_obj_set_style_bg_color(card, lv_color_lighten(lv_color_hex(color), 90), 0); + lv_obj_set_style_bg_grad_color(card, lv_color_hex(color), 0); + lv_obj_set_style_translate_y(card, 4, LV_STATE_PRESSED); + lv_obj_set_style_shadow_ofs_y(card, 3, LV_STATE_PRESSED); + lv_obj_set_size(card, 188, 116); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(card, 6, 0); + lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(card, station_cb, LV_EVENT_CLICKED, (void *)st); + + lv_obj_t *icon = lv_image_create(card); + lv_image_set_src(icon, &lisael_ic_radio); + lv_obj_set_size(icon, 56, 56); // scale-to-fit by size (reliable) + lv_image_set_inner_align(icon, LV_IMAGE_ALIGN_CONTAIN); + + lv_obj_t *name = lv_label_create(card); + lv_label_set_text(name, st->name); + lv_obj_set_style_text_font(name, &lisael_font_24, 0); +} + lv_obj_t *lisael_screen_radio(void) { if (s_root) { return s_root; } s_root = lv_obj_create(NULL); - - // Soft warm vertical gradient (peach at the top fading to near-white), in - // the same gentle spirit as the calm screen. lv_obj_set_style_bg_color(s_root, lv_color_hex(0xFFE3C2), 0); lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0xFFF7EE), 0); lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0); + lv_obj_clear_flag(s_root, LV_OBJ_FLAG_SCROLLABLE); lisael_ui_add_back_button(s_root); - // Title in the kid-friendly Fredoka 24 px font. lv_obj_t *title = lv_label_create(s_root); lv_label_set_text(title, "Radio"); lv_obj_set_style_text_font(title, &lisael_font_24, 0); lv_obj_set_style_text_color(title, lv_color_hex(0x8A4B16), 0); - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 14); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 12); - // Scrollable column of big station buttons. - lv_obj_t *list = lv_obj_create(s_root); - lv_obj_remove_style_all(list); - lv_obj_set_size(list, LV_PCT(100), 132); - lv_obj_align(list, LV_ALIGN_TOP_MID, 0, 58); - lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(list, LV_FLEX_ALIGN_START, - LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_row(list, 8, 0); + // Carousel of station cards. + s_track = lv_obj_create(s_root); + lv_obj_remove_style_all(s_track); + lv_obj_set_size(s_track, RADIO_SLIDE_W, 124); + lv_obj_align(s_track, LV_ALIGN_TOP_MID, 0, 42); + lv_obj_set_flex_flow(s_track, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_all(s_track, 0, 0); + lv_obj_set_style_pad_column(s_track, 0, 0); + lv_obj_set_scroll_dir(s_track, LV_DIR_HOR); + lv_obj_set_scroll_snap_x(s_track, LV_SCROLL_SNAP_CENTER); + lv_obj_set_scrollbar_mode(s_track, LV_SCROLLBAR_MODE_OFF); + lv_obj_add_event_cb(s_track, scroll_cb, LV_EVENT_SCROLL_END, NULL); - for (size_t i = 0; i < K_STATIONS_COUNT; i++) { - lv_obj_t *btn = lv_button_create(list); - lv_obj_add_style(btn, lisael_ui_style_button(), 0); - lv_obj_set_width(btn, LV_PCT(92)); - lv_obj_add_event_cb(btn, station_cb, LV_EVENT_CLICKED, - (void *)&k_stations[i]); - lv_obj_t *lbl = lv_label_create(btn); - lv_label_set_text(lbl, k_stations[i].name); - lv_obj_center(lbl); + for (int i = 0; i < K_STATIONS_COUNT; i++) { + add_station_slide(s_track, &k_stations[i], + k_colors[i % (int)(sizeof(k_colors) / sizeof(k_colors[0]))]); } - // Bottom Stop button. + // Dots. + lv_obj_t *dotrow = lv_obj_create(s_root); + lv_obj_remove_style_all(dotrow); + lv_obj_set_size(dotrow, LV_PCT(90), 14); + lv_obj_align(dotrow, LV_ALIGN_TOP_MID, 0, 170); + lv_obj_set_flex_flow(dotrow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(dotrow, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(dotrow, LV_OBJ_FLAG_SCROLLABLE); + for (int i = 0; i < K_STATIONS_COUNT; i++) { + lv_obj_t *d = lv_obj_create(dotrow); + lv_obj_remove_style_all(d); + lv_obj_set_size(d, 9, 9); + lv_obj_set_style_radius(d, 5, 0); + lv_obj_set_style_margin_left(d, 3, 0); + lv_obj_set_style_margin_right(d, 3, 0); + lv_obj_set_style_bg_color(d, lv_color_hex(0xE8590C), 0); + lv_obj_set_style_bg_opa(d, i == 0 ? LV_OPA_COVER : LV_OPA_30, 0); + s_dots[i] = d; + } + + if (K_STATIONS_COUNT > 1) { + make_arrow(s_root, LV_SYMBOL_LEFT, LV_ALIGN_LEFT_MID, -1); + make_arrow(s_root, LV_SYMBOL_RIGHT, LV_ALIGN_RIGHT_MID, +1); + } + + // Status line + Stop button at the bottom. + s_status_lbl = lv_label_create(s_root); + lv_obj_set_style_text_color(s_status_lbl, lv_color_hex(0x8A4B16), 0); + lv_label_set_text(s_status_lbl, ""); + lv_obj_align(s_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -60); + lv_obj_t *stop = lv_button_create(s_root); lv_obj_add_style(stop, lisael_ui_style_button(), 0); - lv_obj_set_size(stop, 120, 50); + lv_obj_set_size(stop, 130, 48); lv_obj_align(stop, LV_ALIGN_BOTTOM_MID, 0, -8); lv_obj_add_event_cb(stop, stop_cb, LV_EVENT_CLICKED, NULL); lv_obj_t *stop_lbl = lv_label_create(stop); lv_label_set_text(stop_lbl, LV_SYMBOL_STOP " Stop"); lv_obj_center(stop_lbl); - // Status line just above the Stop button. - s_status_lbl = lv_label_create(s_root); - lv_obj_set_style_text_color(s_status_lbl, lv_color_hex(0x8A4B16), 0); - lv_label_set_text(s_status_lbl, ""); - lv_obj_align(s_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -64); - + s_cur = 0; + dots_update(0); return s_root; }