feat(box): station carousel for radio menu

Match Histoires and home: replace the column of station buttons with the
Lunii-style horizontal carousel (one big station card per slide, radio icon +
name, swipe/arrows, a dot per station, tap = play). Keep the Stop button and
status line at the bottom.
This commit is contained in:
Clément Saillant
2026-06-15 09:46:15 +02:00
parent 7c8630abbe
commit ce78cad14c
+154 -46
View File
@@ -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/<slug> 302-redirects to the
// live listen.radioking.com/<id> MP3 mount (our HTTP client follows 3xx).
// - Zeno.FM: https://stream.zeno.fm/<mount> is a direct MP3 stream.
// Confirm with `curl -IL <url>` 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/<slug>. 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 <stddef.h>
#include <stdint.h>
#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/<slug> pages are HTML — these are the real
// listen.radioking.com mounts. If a stream changes, re-read it from
// play.radioking.io/<slug> (grep listen.radioking.com/radio/<id>/stream/<id>).
// 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;
}