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:
+154
-46
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user