diff --git a/CMakeLists.txt b/CMakeLists.txt index 60a8828..2c9547d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,8 +14,18 @@ cmake_minimum_required(VERSION 3.16) if(DEFINED ENV{ADF_PATH}) set(ADF_COMPONENTS "$ENV{ADF_PATH}/components") if(EXISTS "${ADF_COMPONENTS}") - list(APPEND EXTRA_COMPONENT_DIRS "${ADF_COMPONENTS}") - message(STATUS "Lisael Box: ESP-ADF components from ${ADF_COMPONENTS}") + # On n'ajoute QUE les composants audio nécessaires (pas tout l'arbre ADF) : + # `audio_board` épingle esp_lcd_ili9341 ^1 et entre en conflit avec le BSP + # esp-box-3 (qui veut ^2.0.1). On exclut donc audio_board / display_service + # et on laisse le BSP gérer écran/tactile/SD/codec. + set(ADF_AUDIO_COMPONENTS + esp-adf-libs audio_pipeline audio_stream audio_sal esp_dispatcher) + foreach(_adf_c IN LISTS ADF_AUDIO_COMPONENTS) + if(EXISTS "${ADF_COMPONENTS}/${_adf_c}") + list(APPEND EXTRA_COMPONENT_DIRS "${ADF_COMPONENTS}/${_adf_c}") + endif() + endforeach() + message(STATUS "Lisael Box: ESP-ADF audio components from ${ADF_COMPONENTS}") else() message(WARNING "Lisael Box: ADF_PATH set but ${ADF_COMPONENTS} not found") endif() diff --git a/docs/superpowers/plans/2026-06-14-lisael-box-histoires-vignettes.md b/docs/superpowers/plans/2026-06-14-lisael-box-histoires-vignettes.md new file mode 100644 index 0000000..3a94476 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-lisael-box-histoires-vignettes.md @@ -0,0 +1,867 @@ +# Écran « Histoires » à vignettes — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remplacer les menus « Histoires » et « Sons » par un seul écran à vignettes où l'on choisit une histoire par sa pochette (grille 2×2 paginée → liste d'épisodes → lecture plein écran). + +**Architecture:** Le contenu (audio + pochettes `.bin` LVGL + `manifest.json` v2 groupé par show) vit sur la carte SD. Un parseur pur (testable sur l'hôte) charge le manifeste ; un écran LVGL à 3 niveaux l'affiche ; les pochettes sont lues depuis la SD via le driver de fichiers LVGL stdio ; la lecture réutilise le dispatch existant `.m4a → aac_player` / `.mp3 → sd_player`. + +**Tech Stack:** ESP-IDF v5.4, LVGL 9.5 (esp-box-3 BSP), cJSON, FATFS (LFN activé), `LVGLImage.py` (pochettes → `.bin`). + +**Notes d'exécution :** +- **Vérification :** ce firmware n'a pas de harnais de test UI. Le seul test unitaire hôte est celui du parseur (Task 1). Pour le reste, la vérification par tâche = `idf.py build` réussit (attrape erreurs de type/compilation) ; la validation finale est l'acceptation sur la vraie carte (Task 8). +- **Commits :** locaux uniquement, **ne pas pousser** (politique du repo : push sur demande explicite). Messages en anglais, sujet ≤ 50 car., pas d'attribution IA, pas de `_` dans le scope. +- **Activation IDF** (chaque shell) : `. /Users/electron/esp/esp-idf/export.sh`. Port box : `/dev/cu.usbmodem1101`. + +--- + +### Task 1 : Parseur de manifeste v2 (pur, testé sur l'hôte) + +**Files:** +- Create: `main/audio/podcast_manifest.h` +- Create: `main/audio/podcast_manifest.c` +- Test: `test/host/test_podcast_manifest.c` + +- [ ] **Step 1 : Écrire le test hôte (échoue d'abord)** + +`test/host/test_podcast_manifest.c` : +```c +// Host unit test for the v2 podcast manifest parser. +#define LISAEL_HOST_TEST +#include "audio/podcast_manifest.h" +#include +#include +#include + +static const char *SAMPLE = +"[{\"show\":\"Une histoire et Oli\",\"cover\":\"oli.bin\",\"episodes\":[" + "{\"file\":\"oli_01.m4a\",\"title\":\"Concert p.1\"}," + "{\"file\":\"oli_02.m4a\",\"title\":\"Concert p.2\"}]}," + "{\"show\":\"Bestioles\",\"cover\":\"bestioles.bin\",\"episodes\":[" + "{\"file\":\"bestioles_01.mp3\",\"title\":\"La gerboise\"}]}," + "{\"show\":\"Vide\",\"episodes\":[]}]"; + +int main(void) { + lisael_show_t shows[LISAEL_MAX_SHOWS]; + int n = lisael_podcast_parse(SAMPLE, shows, LISAEL_MAX_SHOWS); + assert(n == 2); // empty show skipped + assert(strcmp(shows[0].show, "Une histoire et Oli") == 0); + assert(strcmp(shows[0].cover, "oli.bin") == 0); + assert(shows[0].n_eps == 2); + assert(strcmp(shows[0].eps[1].file, "oli_02.m4a") == 0); + assert(strcmp(shows[0].eps[0].title, "Concert p.1") == 0); + assert(strcmp(shows[1].show, "Bestioles") == 0); + assert(shows[1].n_eps == 1); + assert(lisael_podcast_parse("not json", shows, LISAEL_MAX_SHOWS) == -1); + printf("OK: %d shows\n", n); + return 0; +} +``` + +- [ ] **Step 2 : Écrire le header** + +`main/audio/podcast_manifest.h` : +```c +// Podcast manifest v2 (grouped by show) for the Lisael Box "Histoires" screen. +// /sdcard/podcasts/manifest.json = JSON array of shows, each with a cover and +// a list of episodes. ASCII file names; UTF-8 show/title strings. +#pragma once +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LISAEL_MAX_SHOWS 12 +#define LISAEL_MAX_EPISODES 24 + +typedef struct { + char file[64]; // audio basename under /sdcard/podcasts/ (e.g. "oli_01.m4a") + char title[80]; // UTF-8 display title +} lisael_episode_t; + +typedef struct { + char show[48]; // UTF-8 show name + char cover[40]; // cover basename ("oli.bin"), "" if none + lisael_episode_t eps[LISAEL_MAX_EPISODES]; + int n_eps; +} lisael_show_t; + +// Pure parser: JSON string -> shows[]. Skips shows with zero episodes. +// Returns show count (>=0), or -1 if the JSON is not a valid array. +int lisael_podcast_parse(const char *json, lisael_show_t *shows, int max_shows); + +// Read /sdcard/podcasts/manifest.json and parse it. Returns count or -1. +int lisael_podcast_load(lisael_show_t *shows, int max_shows); + +#ifdef __cplusplus +} +#endif +``` + +- [ ] **Step 3 : Vérifier que le test échoue (pas d'implémentation)** + +Run : +```bash +cc -DLISAEL_HOST_TEST -I/tmp/lisael-box/main \ + -I/Users/electron/esp/esp-idf/components/json/cJSON \ + /tmp/lisael-box/test/host/test_podcast_manifest.c \ + /tmp/lisael-box/main/audio/podcast_manifest.c \ + /Users/electron/esp/esp-idf/components/json/cJSON/cJSON.c \ + -o /tmp/test_podcast_manifest 2>&1 | head +``` +Expected : ÉCHEC de link/compile (`podcast_manifest.c` n'existe pas encore / `lisael_podcast_parse` indéfini). + +- [ ] **Step 4 : Implémenter le parseur** + +`main/audio/podcast_manifest.c` : +```c +// See podcast_manifest.h. The pure parser is host-testable (no ESP-IDF deps); +// the SD loader is compiled only on-target (#ifndef LISAEL_HOST_TEST). +#include "audio/podcast_manifest.h" +#include "cJSON.h" +#include +#include +#include + +int lisael_podcast_parse(const char *json, lisael_show_t *shows, int max_shows) +{ + if (!json || !shows || max_shows <= 0) { + return -1; + } + cJSON *root = cJSON_Parse(json); + if (!cJSON_IsArray(root)) { + cJSON_Delete(root); + return -1; + } + + int n = 0; + cJSON *jshow; + cJSON_ArrayForEach(jshow, root) { + if (n >= max_shows) { + break; + } + cJSON *jname = cJSON_GetObjectItem(jshow, "show"); + cJSON *jcover = cJSON_GetObjectItem(jshow, "cover"); + cJSON *jeps = cJSON_GetObjectItem(jshow, "episodes"); + if (!cJSON_IsString(jname) || !cJSON_IsArray(jeps)) { + continue; + } + lisael_show_t *s = &shows[n]; + memset(s, 0, sizeof(*s)); + snprintf(s->show, sizeof(s->show), "%s", jname->valuestring); + if (cJSON_IsString(jcover)) { + snprintf(s->cover, sizeof(s->cover), "%s", jcover->valuestring); + } + cJSON *jep; + cJSON_ArrayForEach(jep, jeps) { + if (s->n_eps >= LISAEL_MAX_EPISODES) { + break; + } + cJSON *jf = cJSON_GetObjectItem(jep, "file"); + cJSON *jt = cJSON_GetObjectItem(jep, "title"); + if (!cJSON_IsString(jf)) { + continue; + } + lisael_episode_t *e = &s->eps[s->n_eps]; + snprintf(e->file, sizeof(e->file), "%s", jf->valuestring); + snprintf(e->title, sizeof(e->title), "%s", + cJSON_IsString(jt) ? jt->valuestring : jf->valuestring); + s->n_eps++; + } + if (s->n_eps > 0) { + n++; // skip shows with no playable episode + } + } + cJSON_Delete(root); + return n; +} + +#ifndef LISAEL_HOST_TEST +#include "esp_log.h" +int lisael_podcast_load(lisael_show_t *shows, int max_shows) +{ + const char *path = "/sdcard/podcasts/manifest.json"; + FILE *f = fopen(path, "r"); + if (!f) { + return -1; + } + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + if (sz <= 0 || sz > 64 * 1024) { + fclose(f); + return -1; + } + char *buf = malloc((size_t)sz + 1); + if (!buf) { + fclose(f); + return -1; + } + size_t rd = fread(buf, 1, (size_t)sz, f); + buf[rd] = '\0'; + fclose(f); + int n = lisael_podcast_parse(buf, shows, max_shows); + free(buf); + ESP_LOGI("podcast", "loaded %d show(s) from %s", n, path); + return n; +} +#endif +``` + +- [ ] **Step 5 : Vérifier que le test passe** + +Run (même commande qu'au Step 3) : +```bash +cc -DLISAEL_HOST_TEST -I/tmp/lisael-box/main \ + -I/Users/electron/esp/esp-idf/components/json/cJSON \ + /tmp/lisael-box/test/host/test_podcast_manifest.c \ + /tmp/lisael-box/main/audio/podcast_manifest.c \ + /Users/electron/esp/esp-idf/components/json/cJSON/cJSON.c \ + -o /tmp/test_podcast_manifest && /tmp/test_podcast_manifest +``` +Expected : `OK: 2 shows`. + +- [ ] **Step 6 : Commit (local)** +```bash +git add main/audio/podcast_manifest.h main/audio/podcast_manifest.c test/host/test_podcast_manifest.c +git commit -m "feat(histoires): podcast manifest v2 parser + host test" +``` + +--- + +### Task 2 : Activer le driver de fichiers LVGL (stdio) pour lire les `.bin` de la SD + +**Files:** +- Modify: `sdkconfig` (section LVGL FS) +- Modify: `main/CMakeLists.txt` (ajout de `podcast_manifest.c` aux SRCS) + +- [ ] **Step 1 : Ajouter `podcast_manifest.c` aux sources** + +Dans `main/CMakeLists.txt`, ajouter `"audio/podcast_manifest.c"` à la liste `SRCS` (à côté des autres `audio/*.c`). + +- [ ] **Step 2 : Activer le driver FS stdio dans `sdkconfig`** + +Repérer la ligne `# CONFIG_LV_USE_FS_STDIO is not set` (ou son absence) et la remplacer / ajouter, dans la section LVGL : +``` +CONFIG_LV_USE_FS_STDIO=y +CONFIG_LV_FS_STDIO_LETTER=83 +CONFIG_LV_FS_STDIO_PATH="" +CONFIG_LV_FS_STDIO_CACHE_SIZE=0 +``` +(`83` = ASCII `'S'` → on adressera les pochettes par `"S:/sdcard/podcasts/.bin"`, que LVGL ouvre via `fopen("/sdcard/podcasts/.bin")`.) + +- [ ] **Step 3 : Build (vérifie la prise en compte + que rien ne casse)** + +Run : +```bash +cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1 && idf.py build 2>&1 | tail -5 +grep -E "LV_USE_FS_STDIO|LV_FS_STDIO_LETTER" sdkconfig +``` +Expected : `Project build complete.` et `CONFIG_LV_USE_FS_STDIO=y` + `CONFIG_LV_FS_STDIO_LETTER=83`. + +- [ ] **Step 4 : Commit (local)** +```bash +git add sdkconfig main/CMakeLists.txt +git commit -m "build(histoires): enable LVGL stdio FS for SD cover images" +``` + +--- + +### Task 3 : Écran « Histoires » à vignettes (3 niveaux) — `modes/histoires.c` + +**Files:** +- Create: `main/modes/histoires.c` + +Cet écran gère lui-même ses 3 sous-écrans LVGL (grille / liste / lecteur) et navigue entre eux avec `lv_screen_load_anim`. Seul le niveau grille utilise `lisael_ui_add_back_button` (→ Accueil) ; liste et lecteur ont leur propre bouton retour vers le niveau parent. + +- [ ] **Step 1 : Écrire `main/modes/histoires.c`** + +```c +// "Histoires" — Merlin-style vignette browser for the Lisael Box. +// +// 3 levels, each its own LVGL screen, navigated with lv_screen_load_anim: +// 1. paged 2x2 grid of podcast covers (lisael_screen_histoires()) +// 2. episode list for the chosen show +// 3. full-screen player (cover + title + transport) +// +// Covers are RGB565 .bin images on the SD, loaded via the LVGL stdio FS driver +// ("S:" -> /sdcard). Playback dispatches by extension: .m4a -> aac_player, +// otherwise -> sd_player (MP3). One audio source at a time. + +#include "ui/ui.h" +#include "audio/podcast_manifest.h" +#include "audio/aac_player.h" +#include "audio/sd_player.h" +#include "audio/audio_player.h" + +#include +#include +#include +#include +#include + +#include "esp_log.h" + +static const char *TAG = "ui.histoires"; + +static lisael_show_t s_shows[LISAEL_MAX_SHOWS]; +static int s_n_shows; +static int s_cur_show = -1; // show currently drilled into +static int s_cur_ep = -1; // episode currently in the player + +static lv_obj_t *s_grid_screen; // cached level-1 screen (owned by ui.c cache) +static lv_obj_t *s_list_screen; // rebuilt per show +static lv_obj_t *s_player_screen; // rebuilt per episode + +static const uint32_t s_fallback_colors[6] = { + 0xE8590C, 0x1971C2, 0x2F9E44, 0x9C36B5, 0xE8A92C, 0xC2255C, +}; + +// --- a cover image (or a coloured fallback tile if no/unreadable .bin) ------- +static lv_obj_t *make_cover(lv_obj_t *parent, int show_idx, int px) +{ + lisael_show_t *s = &s_shows[show_idx]; + if (s->cover[0]) { + char real[64]; + snprintf(real, sizeof(real), "/sdcard/podcasts/%s", s->cover); + struct stat st; + if (stat(real, &st) == 0 && st.st_size > 0) { + char src[72]; + snprintf(src, sizeof(src), "S:/sdcard/podcasts/%s", s->cover); + lv_obj_t *img = lv_image_create(parent); + lv_image_set_src(img, src); + lv_image_set_scale(img, (uint32_t)(px * 256 / 160)); // stored at 160px + return img; + } + } + // fallback: rounded coloured square with the show's first letter + lv_obj_t *box = lv_obj_create(parent); + lv_obj_remove_style_all(box); + lv_obj_set_size(box, px, px); + lv_obj_set_style_radius(box, 14, 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(box, lv_color_hex(s_fallback_colors[show_idx % 6]), 0); + lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t *l = lv_label_create(box); + char init[2] = { s->show[0] ? s->show[0] : '?', '\0' }; + lv_label_set_text(l, init); + lv_obj_set_style_text_color(l, lv_color_white(), 0); + lv_obj_center(l); + return box; +} + +// --- level 3: player --------------------------------------------------------- +static void play_current(void) +{ + lisael_show_t *s = &s_shows[s_cur_show]; + char abs[96]; + snprintf(abs, sizeof(abs), "/sdcard/podcasts/%s", s->eps[s_cur_ep].file); + size_t len = strlen(abs); + if (len > 4 && strcasecmp(abs + len - 4, ".m4a") == 0) { + lisael_aac_play_file(abs); + } else { + lisael_sd_play_file(abs); + } + ESP_LOGI(TAG, "play %s", abs); +} + +static void player_back_cb(lv_event_t *e) +{ + (void)e; + lisael_audio_stop_all(); + lv_screen_load_anim(s_list_screen, LV_SCR_LOAD_ANIM_MOVE_RIGHT, 220, 0, false); +} + +static void player_toggle_cb(lv_event_t *e) +{ + lv_obj_t *lbl = lv_obj_get_child(lv_event_get_target(e), 0); + if (lisael_aac_is_playing() || lisael_sd_is_playing()) { + lisael_audio_stop_all(); + lv_label_set_text(lbl, LV_SYMBOL_PLAY); + } else { + play_current(); + lv_label_set_text(lbl, LV_SYMBOL_STOP); + } +} + +static void player_step_cb(lv_event_t *e) +{ + int dir = (int)(intptr_t)lv_event_get_user_data(e); + int n = s_shows[s_cur_show].n_eps; + s_cur_ep = (s_cur_ep + dir + n) % n; + lisael_audio_stop_all(); + lv_obj_t *scr = lisael_screen_histoires(); // ensure shows loaded; no-op rebuild + (void)scr; + // rebuild the player for the new episode + extern lv_obj_t *lisael_histoires_open_episode(int ep); // fwd (same file) + lisael_histoires_open_episode(s_cur_ep); +} + +lv_obj_t *lisael_histoires_open_episode(int ep) +{ + s_cur_ep = ep; + s_player_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_player_screen, lv_color_hex(0x0E1726), 0); + + lv_obj_t *back = lv_button_create(s_player_screen); + lv_obj_add_style(back, lisael_ui_style_button(), 0); + lv_obj_set_size(back, 64, 52); + lv_obj_align(back, LV_ALIGN_TOP_LEFT, 6, 6); + lv_obj_add_event_cb(back, player_back_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t *bl = lv_label_create(back); + lv_label_set_text(bl, LV_SYMBOL_LEFT); + lv_obj_center(bl); + + make_cover(s_player_screen, s_cur_show, 110); + lv_obj_t *cover_holder = lv_obj_get_child(s_player_screen, lv_obj_get_child_count(s_player_screen) - 1); + lv_obj_align(cover_holder, LV_ALIGN_TOP_MID, 0, 12); + + lv_obj_t *title = lv_label_create(s_player_screen); + lv_label_set_text(title, s_shows[s_cur_show].eps[s_cur_ep].title); + lv_obj_set_style_text_color(title, lv_color_white(), 0); + lv_obj_set_width(title, 300); + lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(title, LV_LABEL_LONG_WRAP); + lv_obj_align(title, LV_ALIGN_CENTER, 0, 30); + + // transport: ⏮ ▶/⏹ ⏭ + lv_obj_t *bar = lv_obj_create(s_player_screen); + lv_obj_remove_style_all(bar); + lv_obj_set_size(bar, 240, 60); + lv_obj_align(bar, LV_ALIGN_BOTTOM_MID, 0, -10); + lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *prev = lv_button_create(bar); + lv_obj_set_size(prev, 60, 52); + lv_obj_add_event_cb(prev, player_step_cb, LV_EVENT_CLICKED, (void *)(intptr_t)-1); + lv_obj_center(lv_label_create(prev)); + lv_label_set_text(lv_obj_get_child(prev, 0), LV_SYMBOL_PREV); + + lv_obj_t *toggle = lv_button_create(bar); + lv_obj_set_size(toggle, 72, 52); + lv_obj_add_event_cb(toggle, player_toggle_cb, LV_EVENT_CLICKED, NULL); + lv_obj_center(lv_label_create(toggle)); + lv_label_set_text(lv_obj_get_child(toggle, 0), LV_SYMBOL_STOP); + + lv_obj_t *next = lv_button_create(bar); + lv_obj_set_size(next, 60, 52); + lv_obj_add_event_cb(next, player_step_cb, LV_EVENT_CLICKED, (void *)(intptr_t)+1); + lv_obj_center(lv_label_create(next)); + lv_label_set_text(lv_obj_get_child(next, 0), LV_SYMBOL_NEXT); + + lv_screen_load_anim(s_player_screen, LV_SCR_LOAD_ANIM_MOVE_LEFT, 220, 0, true); + play_current(); + return s_player_screen; +} + +// --- level 2: episode list --------------------------------------------------- +static void ep_cb(lv_event_t *e) +{ + int ep = (int)(intptr_t)lv_event_get_user_data(e); + lisael_histoires_open_episode(ep); +} + +static void list_back_cb(lv_event_t *e) +{ + (void)e; + lv_screen_load_anim(s_grid_screen, LV_SCR_LOAD_ANIM_MOVE_RIGHT, 220, 0, false); +} + +static lv_obj_t *open_show(int show_idx) +{ + s_cur_show = show_idx; + lisael_show_t *s = &s_shows[show_idx]; + + s_list_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_list_screen, lv_color_hex(0xEBFBEE), 0); + + lv_obj_t *back = lv_button_create(s_list_screen); + lv_obj_add_style(back, lisael_ui_style_button(), 0); + lv_obj_set_size(back, 64, 52); + lv_obj_align(back, LV_ALIGN_TOP_LEFT, 6, 6); + lv_obj_add_event_cb(back, list_back_cb, LV_EVENT_CLICKED, NULL); + lv_obj_center(lv_label_create(back)); + lv_label_set_text(lv_obj_get_child(back, 0), LV_SYMBOL_LEFT); + + lv_obj_t *title = lv_label_create(s_list_screen); + lv_label_set_text(title, s->show); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 18); + + lv_obj_t *list = lv_list_create(s_list_screen); + lv_obj_set_size(list, LV_PCT(96), 168); + lv_obj_align(list, LV_ALIGN_BOTTOM_MID, 0, -4); + lv_obj_set_style_bg_color(list, lv_color_white(), 0); + lv_obj_set_style_radius(list, 12, 0); + + for (int i = 0; i < s->n_eps; i++) { + lv_obj_t *btn = lv_list_add_button(list, NULL, s->eps[i].title); + lv_obj_add_event_cb(btn, ep_cb, LV_EVENT_CLICKED, (void *)(intptr_t)i); + } + + lv_screen_load_anim(s_list_screen, LV_SCR_LOAD_ANIM_MOVE_LEFT, 220, 0, true); + return s_list_screen; +} + +// --- level 1: paged 2x2 cover grid ------------------------------------------- +static lv_obj_t *s_dots[ (LISAEL_MAX_SHOWS + 3) / 4 ]; +static int s_n_pages; + +static void cover_cb(lv_event_t *e) +{ + int idx = (int)(intptr_t)lv_event_get_user_data(e); + open_show(idx); +} + +static void tv_scroll_cb(lv_event_t *e) +{ + lv_obj_t *tv = lv_event_get_target(e); + int w = lv_obj_get_width(tv); + int page = (w > 0) ? (lv_obj_get_scroll_x(tv) + w / 2) / w : 0; + for (int i = 0; i < s_n_pages; i++) { + lv_obj_set_style_bg_opa(s_dots[i], i == page ? LV_OPA_COVER : LV_OPA_30, 0); + } +} + +lv_obj_t *lisael_screen_histoires(void) +{ + if (s_grid_screen) { + return s_grid_screen; + } + s_n_shows = lisael_podcast_load(s_shows, LISAEL_MAX_SHOWS); + + s_grid_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_grid_screen, lv_color_hex(0xCDE8FF), 0); + lv_obj_set_style_bg_grad_color(s_grid_screen, lv_color_hex(0xF6FBFF), 0); + lv_obj_set_style_bg_grad_dir(s_grid_screen, LV_GRAD_DIR_VER, 0); + + lisael_ui_add_back_button(s_grid_screen); + + lv_obj_t *title = lv_label_create(s_grid_screen); + lv_label_set_text(title, "Histoires"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 18); + + if (s_n_shows <= 0) { + lv_obj_t *info = lv_label_create(s_grid_screen); + lv_label_set_text(info, + "Aucune histoire pour l'instant.\nBranche la box en USB\npour en ajouter."); + lv_obj_set_style_text_align(info, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(info); + return s_grid_screen; + } + + lv_obj_t *tv = lv_tileview_create(s_grid_screen); + lv_obj_set_size(tv, LV_PCT(100), 168); + lv_obj_align(tv, LV_ALIGN_TOP_MID, 0, 50); + lv_obj_set_style_bg_opa(tv, LV_OPA_TRANSP, 0); + lv_obj_add_event_cb(tv, tv_scroll_cb, LV_EVENT_SCROLL_END, NULL); + + s_n_pages = (s_n_shows + 3) / 4; + for (int p = 0; p < s_n_pages; p++) { + lv_obj_t *page = lv_tileview_add_tile(tv, p, 0, LV_DIR_HOR); + lv_obj_set_flex_flow(page, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(page, LV_FLEX_ALIGN_SPACE_EVENLY, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(page, 6, 0); + for (int k = 0; k < 4; k++) { + int idx = p * 4 + k; + if (idx >= s_n_shows) { + break; + } + lv_obj_t *cell = lv_button_create(page); + lv_obj_remove_style_all(cell); + lv_obj_set_size(cell, 150, 96); + lv_obj_set_flex_flow(cell, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(cell, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_add_event_cb(cell, cover_cb, LV_EVENT_CLICKED, (void *)(intptr_t)idx); + make_cover(cell, idx, 64); + lv_obj_t *cap = lv_label_create(cell); + lv_label_set_text(cap, s_shows[idx].show); + lv_label_set_long_mode(cap, LV_LABEL_LONG_DOT); + lv_obj_set_width(cap, 144); + lv_obj_set_style_text_align(cap, LV_TEXT_ALIGN_CENTER, 0); + } + } + + // page dots (drawn objects, no font dependency) + if (s_n_pages > 1) { + lv_obj_t *dotrow = lv_obj_create(s_grid_screen); + lv_obj_remove_style_all(dotrow); + lv_obj_set_size(dotrow, LV_PCT(60), 18); + lv_obj_align(dotrow, LV_ALIGN_BOTTOM_MID, 0, -6); + 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 < s_n_pages; i++) { + lv_obj_t *d = lv_obj_create(dotrow); + lv_obj_remove_style_all(d); + lv_obj_set_size(d, 10, 10); + lv_obj_set_style_radius(d, 5, 0); + lv_obj_set_style_margin_left(d, 4, 0); + lv_obj_set_style_margin_right(d, 4, 0); + lv_obj_set_style_bg_opa(d, i == 0 ? LV_OPA_COVER : LV_OPA_30, 0); + lv_obj_set_style_bg_color(d, lv_color_hex(0x1971C2), 0); + s_dots[i] = d; + } + } + return s_grid_screen; +} +``` + +- [ ] **Step 2 : Build (vérifie la compilation de l'écran)** + +> ⚠️ `histoires.c` ne sera **lié** qu'après la Task 4 (ajout aux SRCS + retrait de podcasts.c/soundbox.c). Pour vérifier la compilation isolée maintenant : +```bash +cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1 +xtensa-esp32s3-elf-gcc -fsyntax-only \ + -I main -I managed_components/lvgl__lvgl/src -I managed_components/lvgl__lvgl \ + -I build/config -DLV_LVGL_H_INCLUDE_SIMPLE=1 main/modes/histoires.c 2>&1 | head -20 || true +``` +Expected : pas d'erreur de syntaxe (les warnings d'include LVGL résolus à la Task 4 via le composant ; si `-fsyntax-only` peine sur les includes, considérer le build complet de la Task 4 comme la vraie vérification). + +- [ ] **Step 3 : Commit (local)** +```bash +git add main/modes/histoires.c +git commit -m "feat(histoires): vignette grid + episode list + player screen" +``` + +--- + +### Task 4 : Câblage UI — router, fusion des tuiles, suppression des anciens écrans + +**Files:** +- Modify: `main/ui/ui.h` +- Modify: `main/ui/ui.c:75` (case du routeur) +- Modify: `main/modes/home.c:148-158` (tuiles) +- Modify: `main/CMakeLists.txt` (SRCS) +- Delete: `main/modes/podcasts.c`, `main/modes/soundbox.c` + +- [ ] **Step 1 : `ui.h` — remplacer les déclarations d'écran** + +Dans `main/ui/ui.h`, **supprimer** les lignes : +```c +lv_obj_t *lisael_screen_soundbox(void); +``` +et +```c +lv_obj_t *lisael_screen_podcasts(void); // "Histoires" — Radio France podcasts +``` +**Ajouter** à la place (près des autres déclarations d'écran) : +```c +lv_obj_t *lisael_screen_histoires(void); // "Histoires" — vignettes podcasts +``` +Et dans l'enum, **supprimer** la ligne `LISAEL_MODE_SOUNDBOX,` (les autres modes décalent, sans impact car ils sont utilisés symboliquement). + +- [ ] **Step 2 : `ui.c` — router HISTOIRES vers le nouvel écran, retirer SOUNDBOX** + +Dans `main/ui/ui.c`, fonction `build_screen` : +- **Supprimer** la ligne `case LISAEL_MODE_SOUNDBOX: return lisael_screen_soundbox();` +- **Remplacer** `case LISAEL_MODE_HISTOIRES:return lisael_screen_podcasts();` + par `case LISAEL_MODE_HISTOIRES:return lisael_screen_histoires();` + +- [ ] **Step 3 : `home.c` — fusionner les tuiles (6 → 5)** + +Dans `main/modes/home.c`, le tableau `tiles[]` : **supprimer** la ligne +```c + { &lisael_ic_sound, "Sons", LISAEL_MODE_SOUNDBOX, {0} }, +``` +Le tableau passe à 5 entrées (Histoires, Radio, Calme, Horloge, Jeux). Le tableau `colors[]` a 6 couleurs — il en reste assez (la boucle n'en lit que 5). La grille flex se réagence seule. + +- [ ] **Step 4 : `CMakeLists.txt` — sources** + +Dans `main/CMakeLists.txt` : **retirer** `"modes/podcasts.c"` et `"modes/soundbox.c"` de `SRCS`, **ajouter** `"modes/histoires.c"` (si pas déjà fait). `podcast_manifest.c` doit y être (Task 2). + +- [ ] **Step 5 : Supprimer les anciens écrans** +```bash +cd /tmp/lisael-box && git rm main/modes/podcasts.c main/modes/soundbox.c +``` +> Note : `main/net/podcast.c` (téléchargement RSS on-device) n'est PLUS référencé par l'UI. Le laisser tel quel (inoffensif) ou le retirer aussi des SRCS si présent — vérifier `grep -n podcast main/CMakeLists.txt`. Ne PAS supprimer `audio/podcast_manifest.c`. + +- [ ] **Step 6 : Build complet (la vraie vérification de l'écran)** +```bash +cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1 && idf.py build 2>&1 | tail -8 +``` +Expected : `Project build complete.` (résout aussi les includes LVGL de `histoires.c`). + +- [ ] **Step 7 : Commit (local)** +```bash +git add -A +git commit -m "feat(histoires): route Histoires to vignette screen, drop old menus" +``` + +--- + +### Task 5 : Outil Mac — pochettes `.bin` + manifeste v2 + +**Files:** +- Create: `/tmp/lisael_episodes/gen_covers.py` + +Réutilise les épisodes déjà téléchargés dans `/tmp/lisael_episodes` (`oli_01.m4a`, `odyssees_*`, `salutinfo_*`, `bestioles_*`). Récupère l'`itunes:image` de chaque flux, redimensionne en 160×160 PNG, convertit en `.bin` LVGL, et écrit `manifest.json` v2 groupé par show. + +- [ ] **Step 1 : Écrire `gen_covers.py`** + +```python +#!/usr/bin/env python3 +# Build v2 manifest (grouped by show) + LVGL .bin covers for the Lisael Box. +import json, os, re, subprocess, sys, urllib.request, xml.etree.ElementTree as ET +from PIL import Image +import io + +OUT = "/tmp/lisael_episodes" +LVGLIMG = "/tmp/lisael-box/managed_components/lvgl__lvgl/scripts/LVGLImage.py" +UA = {"User-Agent": "Mozilla/5.0"} +NS = {"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"} + +# key -> (show title, RSS url) +FEEDS = { + "oli": ("Une histoire et Oli", "https://radiofrance-podcast.net/podcast09/rss_19721.xml"), + "odyssees": ("Les Odyssees", "https://radiofrance-podcast.net/podcast09/rss_20108.xml"), + "salutinfo": ("Salut l'info", "https://radiofrance-podcast.net/podcast09/rss_20689.xml"), + "bestioles": ("Bestioles", + "https://radiofrance-podcast.net/podcast09/35099478-7c72-4f9e-a6de-1b928400e9e5/" + "podcast_a80ecbd5-df3d-4c9d-bee7-4e3d9efc1974.xml"), +} + +def fetch(url, t=60): + return urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=t).read() + +def cover_url(root): + el = root.find(".//channel/itunes:image", NS) + if el is not None and el.get("href"): + return el.get("href") + el = root.find(".//channel/image/url") + return el.text if el is not None else None + +manifest = [] +for key, (show, rss) in FEEDS.items(): + # episodes already downloaded for this show, from the existing flat manifest if present + eps = [] + flat = os.path.join(OUT, "manifest.json.v1") + # derive episodes from files on disk + titles from a saved v1 manifest if available + titles = {} + if os.path.exists(flat): + for e in json.load(open(flat, encoding="utf-8")): + titles[e["file"]] = e["title"] + for f in sorted(os.listdir(OUT)): + if re.match(rf"^{key}_\d+\.(m4a|mp3)$", f): + eps.append({"file": f, "title": titles.get(f, f)}) + if not eps: + print(f"[{key}] no episodes on disk, skip"); continue + + cover_name = "" + try: + root = ET.fromstring(fetch(rss)) + cu = cover_url(root) + if cu: + img = Image.open(io.BytesIO(fetch(cu))).convert("RGB").resize((160, 160)) + png = os.path.join(OUT, f"{key}.png") + img.save(png) + subprocess.run([sys.executable, LVGLIMG, "--ofmt", "BIN", "--cf", "RGB565", + "-o", OUT, png], check=True) + cover_name = f"{key}.bin" + print(f"[{key}] cover -> {cover_name}") + except Exception as ex: + print(f"[{key}] cover failed: {ex}") + + manifest.append({"show": show, "cover": cover_name, "episodes": eps}) + +json.dump(manifest, open(os.path.join(OUT, "manifest.json"), "w", encoding="utf-8"), + ensure_ascii=False, indent=2) +print(f"manifest.json: {len(manifest)} shows") +``` + +> Préparer les titres : si l'ancien manifeste plat existe, le sauver d'abord sous `manifest.json.v1` (sinon les titres retombent sur le nom de fichier) : +> ```bash +> [ -f /tmp/lisael_episodes/manifest.json ] && cp /tmp/lisael_episodes/manifest.json /tmp/lisael_episodes/manifest.json.v1 +> ``` + +- [ ] **Step 2 : Lancer l'outil** +```bash +cd /tmp/lisael_episodes +[ -f manifest.json ] && cp manifest.json manifest.json.v1 +/Users/electron/.espressif/python_env/idf5.4_py3.14_env/bin/python gen_covers.py +echo "=== .bin produits ==="; ls -la *.bin +echo "=== manifest v2 ==="; cat manifest.json +``` +Expected : un `.bin` par show (`oli.bin`, `odyssees.bin`, `salutinfo.bin`, `bestioles.bin`) + `manifest.json` v2 listant 4 shows avec leurs épisodes et `cover`. + +- [ ] **Step 3 : Commit (local — l'outil seulement ; pas de podcasts ni binaires)** +```bash +cd /tmp/lisael-box +mkdir -p tools && cp /tmp/lisael_episodes/gen_covers.py tools/gen_covers.py +git add tools/gen_covers.py +git commit -m "tools: generate v2 manifest + LVGL cover images from RF feeds" +``` + +--- + +### Task 6 : Charger la SD (USB-MSC) + flasher + recette sur la vraie carte + +**Files:** (aucun — procédure) + +- [ ] **Step 1 : Passer la box en mode disque USB** + +Flasher le firmware MSC (`/tmp/lisael-msc`, déjà construit) après BOOT+RESET : +```bash +# (demander à l'utilisateur de faire BOOT+RESET, attendre le port) +cd /tmp/lisael-msc && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1 +python -m esptool --chip esp32s3 -p /dev/cu.usbmodem1101 -b 460800 \ + --before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m \ + 0x0 build/bootloader/bootloader.bin 0x8000 build/partition_table/partition-table.bin 0x10000 build/lisael_msc.bin +``` +Attendre que `/Volumes/POT_04` monte (`diskutil list external physical`). + +- [ ] **Step 2 : Copier pochettes + manifeste v2 sur la SD** +```bash +cp -v /tmp/lisael_episodes/*.bin /tmp/lisael_episodes/manifest.json /Volumes/POT_04/PODCASTS/ +sync +rm -f /Volumes/POT_04/PODCASTS/._* /Volumes/POT_04/PODCASTS/.DS_Store +ls -la /Volumes/POT_04/PODCASTS/ +diskutil eject /Volumes/POT_04 +``` +Expected : `oli.bin`…`bestioles.bin` + `manifest.json` (v2) présents ; éjection propre. + +- [ ] **Step 3 : Reflasher le firmware normal** + +Après BOOT+RESET (la box est en MSC, pas de port sinon) : +```bash +cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1 +python -m esptool --chip esp32s3 -p /dev/cu.usbmodem1101 -b 460800 \ + --before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m \ + 0x0 build/bootloader/bootloader.bin 0x8000 build/partition_table/partition-table.bin 0x10000 build/lisael_box.bin +python -m esptool --chip esp32s3 -p /dev/cu.usbmodem1101 --after hard_reset run +``` + +- [ ] **Step 4 : Recette sur la carte (demander à l'utilisateur de vérifier)** + 1. Accueil : 5 tuiles, « Histoires » ouvre la grille. + 2. Grille 2×2 : les **vraies pochettes** des 4 podcasts (pagination/points si > 4). + 3. Taper une pochette → liste des épisodes du bon show. + 4. Taper un épisode → lecture plein écran avec pochette ; **son OK en .m4a ET .mp3** ; ⏮/⏭ changent d'épisode ; ▶/⏹ ; retour à chaque niveau. + 5. (Optionnel) carte sans pochette → tuile de repli colorée, pas de crash. + +- [ ] **Step 5 : Mettre à jour la mémoire projet** + +Ajouter au fichier `project_lisael_box_hw_bringup_2026_06_14.md` une note « Session 5 : écran Histoires à vignettes livré » (composants, format manifeste v2, driver FS LVGL, statut recette). + +--- + +## Auto-revue du plan + +**1. Couverture de la spec :** +- Navigation 3 niveaux → Task 3 (grille/liste/lecteur). ✅ +- Grille 2×2 paginée + carrousel/points → Task 3 (`lv_tileview` + dots objets). ✅ +- Pochettes `.bin` SD via driver FS → Task 2 (config) + Task 3 (`make_cover`/`S:`). ✅ +- Manifeste v2 par show → Task 1 (parseur) + Task 5 (générateur). ✅ +- Fusion tuiles + remplacement podcasts.c/soundbox.c → Task 4. ✅ +- Lecture m4a/mp3 → Task 3 (`play_current`). ✅ +- Contenu initial via MSC → Task 6. ✅ +- Gestion d'erreurs (pas de SD/manifeste, pochette manquante) → Task 3 (placeholder + fallback). ✅ +- **Écart assumé vs spec :** la **barre de progression** du lecteur est **différée** (les lecteurs audio n'exposent pas la position de lecture ; l'ajouter exigerait une API de position — propre follow-up). Le lecteur affiche pochette + titre + transport sans barre live. + +**2. Placeholders :** aucun TODO/TBD ; tout le code est fourni. + +**3. Cohérence des types :** `lisael_show_t`/`lisael_episode_t` (Task 1) utilisés tels quels en Task 3 ; `lisael_screen_histoires()` déclarée (Task 4 ui.h) / définie (Task 3) / routée (Task 4 ui.c) ; `make_cover(parent, idx, px)` cohérent ; pochettes stockées 160px, mises à l'échelle via `px*256/160`. diff --git a/docs/superpowers/specs/2026-06-14-lisael-box-histoires-vignettes-design.md b/docs/superpowers/specs/2026-06-14-lisael-box-histoires-vignettes-design.md new file mode 100644 index 0000000..8bc0d68 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-lisael-box-histoires-vignettes-design.md @@ -0,0 +1,133 @@ +# Lisael Box — Écran « Histoires » à vignettes (design) + +**Date:** 2026-06-14 +**Statut:** validé (brainstorming), prêt pour le plan d'implémentation. +**Projet:** firmware Lisael Box (ESP32-S3-BOX-3), ESP-IDF v5.4, `/tmp/lisael-box`, +repo Gitea `electron-rare/lisael-box`. + +## But + +Remplacer les menus « Histoires » (download on-device) et « Sons » (liste) par +**un seul écran « Histoires » à vignettes**, où Lisael (6 ans) choisit une histoire +**par sa pochette** (image), façon Fabrique à histoires *Merlin*. Le contenu (audio ++ pochettes + manifeste) vit sur la carte SD ; la box lit et affiche. + +## Périmètre + +- **DANS** : l'écran à vignettes (3 niveaux), le chargement des pochettes depuis la + SD, le manifeste enrichi v2, la fusion des tuiles d'accueil, le chargement initial + du contenu sur la SD côté Mac (via le flux USB-MSC déjà rodé). +- **HORS** (specs séparées) : + - **Spec A** — webapp de gestion de contenu. **Direction figée** : *Tower héberge + l'app + télécharge les épisodes + convertit les pochettes, puis pousse les fichiers + à la box en HTTP simple* (ou la box tire depuis Tower sur le tailnet). La box ne + fait **jamais** de HTTPS lourd (évite le reset de download connu). B produit + exactement le format SD que A alimentera ensuite. + - Carrousel « grande pochette », bruitages/sons courts, écran réglages. + +## Navigation (3 niveaux) + +1. **Accueil** → la tuile **« Histoires »** ouvre la grille. Les anciennes tuiles + « Histoires » + « Sons » fusionnent en cette unique tuile → **5 tuiles** à l'accueil + (6ᵉ emplacement laissé libre, futur « Réglages/Web »). +2. **Niveau 1 — grille 2×2 paginée** : 4 pochettes par page (grosses cibles tactiles), + **balayage horizontal + points indicateurs** (`●○○`) entre pages dès qu'il y a plus + de 4 podcasts. Taper une pochette → niveau 2. +3. **Niveau 2 — épisodes** : liste verticale des épisodes du podcast choisi (petite + vignette du show + titre). Bouton retour vers la grille. Taper un épisode → niveau 3. +4. **Niveau 3 — lecture plein écran** : grande pochette + titre + transport + `⏮ ⏸/▶ ⏭` + barre de progression + bouton retour. Le « Stop » global reste + disponible (`lisael_audio_stop_all`). + +## Pochettes — pipeline image (approche retenue) + +**Approche 1 : pochettes pré-converties au format binaire LVGL (RGB565) sur la SD, +lues via un driver de système de fichiers LVGL.** Choisie pour la RAM déterministe +(pas de décodeur JPEG à l'exécution — la RAM interne est tendue) et parce qu'elle +correspond au « vrai Merlin » (pochettes sur la carte) et prépare la spec A. + +- **Format** : `.bin` LVGL (`LVGLImage.py --ofmt BIN`, `cf RGB565` ou `RGB565A8`), + produit côté Mac (pipeline déjà maîtrisé pour les icônes Twemoji). **Une seule taille + stockée par pochette : 160×160** ; LVGL la **met à l'échelle** à l'affichage (≈88 px + en grille, ≈150 px en lecture, ≈48 px en liste) via `lv_image_set_scale`. +- **Driver FS LVGL** : activer `CONFIG_LV_USE_FS_STDIO=y` avec la lettre `'S'` + (mappe `S:` → `fopen`/`fread`, donc la SD FATFS). Chargement : + `lv_image_set_src(img, "S:/sdcard/podcasts/.bin")`. Le décodeur `.bin` LVGL + est toujours présent (aucun décodeur JPEG/PNG requis). +- **Cache image** : les ~4 pochettes décodées tiennent en **PSRAM** (160×160×2 ≈ 51 Ko + chacune, ~205 Ko pour 4). Acceptable. +- **Source** : artwork des flux Radio France (``), redimensionné 160×160 + côté Mac. Repli si pochette absente : une vignette générée (emoji + couleur du show). + +## Données — manifeste enrichi v2 (par show) + +`/sdcard/podcasts/manifest.json` passe d'une liste plate d'épisodes à **une liste de +shows**, chacun portant sa pochette et ses épisodes : + +```json +[ + { + "show": "Une histoire et Oli", + "cover": "oli.bin", + "episodes": [ + { "file": "oli_01.m4a", "title": "OLI en concert, p.1" }, + { "file": "oli_02.m4a", "title": "OLI en concert, p.2" } + ] + }, + { + "show": "Bestioles", + "cover": "bestioles.bin", + "episodes": [ { "file": "bestioles_01.mp3", "title": "La gerboise…" } ] + } +] +``` + +- `cover` = nom de fichier `.bin` sous `/sdcard/podcasts/`. `file` = nom audio ASCII + sous `/sdcard/podcasts/`. `show`/`title` = UTF-8 (accents ; rendus par Fredoka). +- Les noms de **fichiers** restent **ASCII** (FAT) ; le « joli » texte vit dans le JSON. + +## Composants firmware + +- **`main/modes/histoires.c`** (neuf) — l'écran à 3 niveaux (grille paginée + liste + + player). **Remplace** `main/modes/podcasts.c` *et* `main/modes/soundbox.c` (supprimés). +- **Chargeur manifeste v2** — dans `main/audio/sd_player.c` (ou un petit + `main/net/podcast.c` réutilisé) : parse le tableau de shows en structures + `lisael_show_t { char show[48]; char cover[32]; lisael_ep_t eps[N]; int n; }`. + Remplace/complète `lisael_sd_load_manifest_dir` (v1 plate, devenue inutile). +- **Driver FS LVGL** — `CONFIG_LV_USE_FS_STDIO=y` (lettre `S`), pour `lv_image` depuis SD. +- **Lecture** — inchangée : dispatch par extension `.m4a → lisael_aac_play_file`, + sinon `lisael_sd_play_file` ; `lisael_audio_stop_all` au changement/Stop. (déjà en place) +- **Accueil** — `main/modes/home.c` : tableau de tuiles 6 → 5 (retirer « Sons », garder + « Histoires » → `LISAEL_MODE_HISTOIRES`). `main/ui/ui.h` : retirer `LISAEL_MODE_SOUNDBOX`, + garder/retituler `lisael_screen_histoires`. `main/ui/ui.c` : router `LISAEL_MODE_HISTOIRES` + → `lisael_screen_histoires`. Mettre à jour `main/CMakeLists.txt` (retrait podcasts.c/ + soundbox.c, ajout histoires.c). + +## Contenu initial (pour B, maintenant) + +Stand-in manuel de ce que Tower fera plus tard (spec A) : +1. Côté Mac : pour chaque show, récupérer l'`` du flux RF → + redimensionner 160×160 → convertir en `.bin` LVGL (`LVGLImage.py`). +2. Écrire le `manifest.json` v2 (shows + pochettes + épisodes déjà téléchargés). +3. Déposer pochettes `.bin` + manifeste sur `/sdcard/podcasts/` via le **firmware + USB-MSC** (`/tmp/lisael-msc`) déjà construit, puis éjecter proprement. +4. Reflasher le firmware normal (BOOT+RESET). + +## Gestion d'erreurs + +- **Pas de SD / pas de manifeste** → écran placeholder amical (« Branche la box en USB + pour ajouter des histoires »), pas d'erreur. +- **Pochette manquante / `.bin` illisible** → vignette de repli (emoji + couleur du show). +- **Épisode introuvable à la lecture** → toast/log, on reste sur la liste. +- **0 épisode pour un show** → le show n'apparaît pas dans la grille. + +## Tests (acceptation) + +Build + flash, puis sur la vraie carte : +1. Accueil affiche 5 tuiles, « Histoires » ouvre la grille. +2. Grille 2×2 affiche les **vraies pochettes** des 4 podcasts ; pagination/points si > 4. +3. Taper une pochette → liste des épisodes du bon show. +4. Taper un épisode → lecture plein écran avec pochette ; **son OK en `.m4a` ET `.mp3`**. +5. Retour à chaque niveau + Stop fonctionnels. +6. Carte sans pochette → repli affiché, pas de crash. +``` diff --git a/main/audio/aac_player.h b/main/audio/aac_player.h new file mode 100644 index 0000000..deecf48 --- /dev/null +++ b/main/audio/aac_player.h @@ -0,0 +1,26 @@ +// Streaming AAC/M4A file player for Lisael Box (Radio France podcasts are AAC). +// +// Decodes an .m4a file from the microSD card chunk-by-chunk via +// esp_audio_codec's simple decoder and writes PCM to the BSP ES8311 codec, on a +// background task. One playback at a time; starting stops any other audio. +#pragma once + +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Start playing an .m4a file (absolute SD path). Stops any current audio, then +// streams+decodes on a background task. Returns ESP_OK once the task is started. +esp_err_t lisael_aac_play_file(const char *abs_path); + +// Stop playback (blocks briefly until the decode task has stopped). +void lisael_aac_stop(void); + +bool lisael_aac_is_playing(void); + +#ifdef __cplusplus +} +#endif diff --git a/main/audio/audio_player.c b/main/audio/audio_player.c index 0a2be9c..ebffe88 100644 --- a/main/audio/audio_player.c +++ b/main/audio/audio_player.c @@ -3,17 +3,23 @@ #include "audio/audio_player.h" #include "audio/radio_pipeline.h" #include "audio/sd_player.h" +#include "audio/aac_player.h" #include "lisael_config.h" #include "esp_log.h" #include "nvs.h" #include "bsp/esp-box-3.h" +#include "esp_codec_dev.h" +#include "driver/i2s_std.h" +#include // chmorgan/esp-audio-player (angle brackets target + // its include dir, NOT our audio/audio_player.h) static const char *TAG = "audio"; static esp_codec_dev_handle_t s_codec; static lisael_audio_source_t s_source = LISAEL_AUDIO_IDLE; static int s_volume = LISAEL_VOLUME_DEFAULT; +static bool s_player_ready; static int clamp_volume(int v) { @@ -44,6 +50,84 @@ static void persist_volume(void) } } +// --- esp-audio-player <-> BSP codec glue ------------------------------------ +// The player decodes MP3 to PCM and pushes it through these callbacks, which we +// back with the BSP's ES8311 codec device (esp_codec_dev). We do NOT touch I2S +// directly — the BSP owns it; we only (re)open the codec at the stream's rate. + +static esp_err_t ap_mute(AUDIO_PLAYER_MUTE_SETTING setting) +{ + if (s_codec) { + esp_codec_dev_set_out_mute(s_codec, setting == AUDIO_PLAYER_MUTE); + } + return ESP_OK; +} + +static esp_err_t ap_clk_set(uint32_t rate, uint32_t bits, i2s_slot_mode_t ch) +{ + if (!s_codec) { + return ESP_FAIL; + } + esp_codec_dev_close(s_codec); // harmless if not open + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = (uint8_t)bits, + .channel = (ch == I2S_SLOT_MODE_STEREO) ? 2 : 1, + .sample_rate = rate, + }; + esp_err_t err = esp_codec_dev_open(s_codec, &fs); + esp_codec_dev_set_out_vol(s_codec, (float)s_volume); // re-apply volume + return err; +} + +static esp_err_t ap_write(void *buf, size_t len, size_t *written, uint32_t timeout_ms) +{ + (void)timeout_ms; + int r = esp_codec_dev_write(s_codec, buf, (int)len); + *written = (r == ESP_CODEC_DEV_OK) ? len : 0; + return (r == ESP_CODEC_DEV_OK) ? ESP_OK : ESP_FAIL; +} + +static void ap_event_cb(audio_player_cb_ctx_t *ctx) +{ + if (ctx->audio_event == AUDIO_PLAYER_CALLBACK_EVENT_IDLE) { + // Playback finished (or was stopped): close the codec and mark idle. + if (s_codec) { + esp_codec_dev_close(s_codec); + } + if (s_source == LISAEL_AUDIO_SD) { + s_source = LISAEL_AUDIO_IDLE; + } + } +} + +esp_err_t lisael_audio_player_ensure(void) +{ + if (s_player_ready) { + return ESP_OK; + } + if (!s_codec) { + ESP_LOGE(TAG, "codec not ready — call lisael_audio_init first"); + return ESP_FAIL; + } + audio_player_config_t cfg = { + .mute_fn = ap_mute, + .clk_set_fn = ap_clk_set, + .write_fn = ap_write, + .priority = 5, + .coreID = 1, + .force_stereo = false, + }; + esp_err_t err = audio_player_new(cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "audio_player_new failed: %s", esp_err_to_name(err)); + return err; + } + audio_player_callback_register(ap_event_cb, NULL); + s_player_ready = true; + ESP_LOGI(TAG, "esp-audio-player ready"); + return ESP_OK; +} + esp_err_t lisael_audio_init(void) { // The BSP returns a ready-to-use ES8311 speaker codec device. @@ -54,6 +138,8 @@ esp_err_t lisael_audio_init(void) return ESP_FAIL; } load_volume(); + s_volume = LISAEL_VOLUME_MAX; // user request: start at maximum volume + persist_volume(); esp_codec_dev_set_out_vol(s_codec, (float)s_volume); ESP_LOGI(TAG, "codec ready, volume=%d%% (cap %d%%)", s_volume, LISAEL_VOLUME_MAX); return ESP_OK; @@ -67,8 +153,9 @@ esp_codec_dev_handle_t lisael_audio_codec(void) void lisael_audio_stop_all(void) { switch (s_source) { - case LISAEL_AUDIO_RADIO: lisael_radio_stop(); break; - case LISAEL_AUDIO_SD: lisael_sd_stop(); break; + case LISAEL_AUDIO_RADIO: lisael_radio_stop(); break; + case LISAEL_AUDIO_SD: lisael_sd_stop(); break; + case LISAEL_AUDIO_PODCAST: lisael_aac_stop(); break; default: break; } s_source = LISAEL_AUDIO_IDLE; diff --git a/main/audio/audio_player.h b/main/audio/audio_player.h index b13d6c6..d6be8f3 100644 --- a/main/audio/audio_player.h +++ b/main/audio/audio_player.h @@ -20,13 +20,19 @@ typedef enum { LISAEL_AUDIO_IDLE = 0, LISAEL_AUDIO_RADIO, LISAEL_AUDIO_SD, + LISAEL_AUDIO_PODCAST, // streaming AAC/M4A file (aac_player.c) } lisael_audio_source_t; // Initialise the BSP audio codec (ES8311 speaker). Call once after bsp_i2c_init. // Loads the persisted volume from NVS (default LISAEL_VOLUME_DEFAULT). esp_err_t lisael_audio_init(void); -// The shared codec handle, for the ESP-ADF i2s_stream / direct writes. +// Lazily create the shared esp-audio-player instance (MP3 -> PCM -> ES8311 +// codec via callbacks). Safe to call repeatedly; only the first call inits. +// Returns ESP_FAIL if the codec isn't ready (lisael_audio_init not done). +esp_err_t lisael_audio_player_ensure(void); + +// The shared codec handle, for direct esp_codec_dev writes. // Returns NULL before lisael_audio_init(). esp_codec_dev_handle_t lisael_audio_codec(void); diff --git a/main/audio/calm_tone.c b/main/audio/calm_tone.c new file mode 100644 index 0000000..10237dc --- /dev/null +++ b/main/audio/calm_tone.c @@ -0,0 +1,145 @@ +// Calm-corner tone — see calm_tone.h. +// +// Real-time synthesis: a 432 Hz sine with two soft harmonics (864, 1296 Hz), +// amplitude-shaped by a breathing envelope that matches the bubble (4 s rise +// "Inspire", 6 s fall "Souffle"). Written to the BSP codec on a task. + +#include "audio/calm_tone.h" +#include "audio/audio_player.h" + +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/idf_additions.h" +#include "esp_heap_caps.h" +#include "esp_codec_dev.h" +#include "esp_log.h" + +static const char *TAG = "calm_tone"; + +#define SR 32000 // sample rate +#define INHALE_S 4.0f +#define EXHALE_S 6.0f +#define CYCLE_S (INHALE_S + EXHALE_S) + +static volatile bool s_stop; +static volatile bool s_running; + +static inline float smoothstep(float x) +{ + if (x < 0) x = 0; + if (x > 1) x = 1; + return x * x * (3.0f - 2.0f * x); +} + +// Breathing amplitude envelope in [0.32 .. 1.0] over the 10 s cycle. +static float breath_env(float t) +{ + const float lo = 0.32f, hi = 1.0f; + if (t < INHALE_S) { + return lo + (hi - lo) * smoothstep(t / INHALE_S); // inhale: rise + } + return hi - (hi - lo) * smoothstep((t - INHALE_S) / EXHALE_S); // exhale: fall +} + +static void tone_task(void *arg) +{ + (void)arg; + s_running = true; + esp_codec_dev_handle_t codec = lisael_audio_codec(); + if (!codec) { + s_running = false; + vTaskDeleteWithCaps(NULL); + return; + } + esp_codec_dev_close(codec); + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = 1, + .sample_rate = SR, + }; + esp_codec_dev_open(codec, &fs); + esp_codec_dev_set_out_vol(codec, (float)lisael_audio_get_volume()); + + const int N = 512; + int16_t *buf = malloc(N * sizeof(int16_t)); + const float TAU = 6.2831853f; + float ph1 = 0, ph2 = 0, ph3 = 0; // fundamental + 2 harmonic phases + float t = 0; // position in the breathing cycle (s) + float gt = 0; // global time (s) for slow LFOs + + while (!s_stop && buf) { + // Slow evolving parameters — computed once per buffer (~16 ms) so the + // drone never sits on one steady pitch/timbre (less fatiguing). A gentle + // pitch drift around 432 Hz, a soft tremolo, and harmonics that swell. + float drift = 1.0f + 0.013f * sinf(TAU * 0.05f * gt); // +/-1.3% pitch wander + float trem = 0.86f + 0.14f * sinf(TAU * 0.17f * gt); // gentle tremolo + float a432 = 0.30f + 0.06f * sinf(TAU * 0.030f * gt + 1.0f); // 432 Hz swell + float h3amp = 0.07f + 0.05f * sinf(TAU * 0.021f * gt + 2.2f); // 576 Hz shimmer + // Centred on 144 Hz (deep, twice as loud) + 432 Hz (= 3x144), with a + // soft 576 Hz shimmer. ph1=144, ph2=432, ph3=576. + float f1 = 144.0f * drift; + float d1 = TAU * f1 / SR, d2 = TAU * 3.0f * f1 / SR, d3 = TAU * 4.0f * f1 / SR; + + for (int i = 0; i < N; i++) { + float env = breath_env(t) * trem; + float s = sinf(ph1) * 0.66f + sinf(ph2) * a432 + sinf(ph3) * h3amp; + int v = (int)(s * env * 6600.0f); + if (v > 32767) v = 32767; + if (v < -32768) v = -32768; + buf[i] = (int16_t)v; + + ph1 += d1; if (ph1 > TAU) ph1 -= TAU; + ph2 += d2; if (ph2 > TAU) ph2 -= TAU; + ph3 += d3; if (ph3 > TAU) ph3 -= TAU; + t += 1.0f / SR; if (t > CYCLE_S) t -= CYCLE_S; + gt += 1.0f / SR; if (gt > 600.0f) gt -= 600.0f; + } + int w = esp_codec_dev_write(codec, buf, N * sizeof(int16_t)); + if (w != ESP_CODEC_DEV_OK) { + vTaskDelay(pdMS_TO_TICKS(5)); // never busy-spin if the codec balks + } + } + + free(buf); + esp_codec_dev_close(codec); + ESP_LOGI(TAG, "stopped"); + s_running = false; + vTaskDeleteWithCaps(NULL); +} + +esp_err_t lisael_calm_tone_start(void) +{ + if (s_running) { + return ESP_OK; + } + lisael_audio_stop_all(); // exclusive with radio/SD/podcast + s_stop = false; + // Priority 3 — BELOW the LVGL task (4) so the breathing animation never + // starves: the tone must never preempt the UI. The blocking codec write + // paces this task and yields to IDLE, so the watchdog stays happy. + if (xTaskCreatePinnedToCoreWithCaps(tone_task, "calm_tone", 4096, NULL, 3, NULL, + tskNO_AFFINITY, MALLOC_CAP_SPIRAM) != pdPASS) { + return ESP_FAIL; + } + ESP_LOGI(TAG, "432 Hz breathing tone started"); + return ESP_OK; +} + +void lisael_calm_tone_stop(void) +{ + if (!s_running) { + return; + } + s_stop = true; + for (int i = 0; i < 100 && s_running; i++) { + vTaskDelay(pdMS_TO_TICKS(20)); + } +} + +bool lisael_calm_tone_is_playing(void) +{ + return s_running; +} diff --git a/main/audio/calm_tone.h b/main/audio/calm_tone.h new file mode 100644 index 0000000..47954af --- /dev/null +++ b/main/audio/calm_tone.h @@ -0,0 +1,19 @@ +// Calm-corner tone generator: a soft 432 Hz sine plus gentle harmonics with a +// slow "breathing" amplitude envelope, synthesised in real time and played +// through the BSP codec. Used by the Coin calme screen. +#pragma once + +#include "esp_err.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t lisael_calm_tone_start(void); +void lisael_calm_tone_stop(void); +bool lisael_calm_tone_is_playing(void); + +#ifdef __cplusplus +} +#endif diff --git a/main/audio/radio_pipeline.c b/main/audio/radio_pipeline.c index 1d8c9a4..8d2d43e 100644 --- a/main/audio/radio_pipeline.c +++ b/main/audio/radio_pipeline.c @@ -1,165 +1,364 @@ -// Webradio streaming pipeline (ESP-ADF) for Lisael Box. +// Webradio live-streaming pipeline for Lisael Box — NO ESP-ADF. // -// Pipeline: http_stream -> esp_decoder (auto MP3/AAC) -> i2s_stream +// Plays a live HTTP(S) MP3 webradio (Shoutcast/Icecast) straight onto the +// shared ES8311 codec, reusing the exact streaming-decode pattern of +// aac_player.c (esp_audio_simple_dec -> esp_codec_dev_write) but fed from the +// network instead of a FILE. // -// IMPORTANT (verify on hardware): -// The esp-box BSP also configures I2S/ES8311 (bsp_audio_init / -// bsp_audio_codec_speaker_init). ESP-ADF's i2s_stream historically owns the -// I2S peripheral itself. On a real BOX-3 you must make these agree on ONE -// owner of the I2S port. Two known-good approaches: -// (A) Let ADF own I2S: configure i2s_stream with the BOX-3 pins/port and -// do NOT call bsp_audio_init(); keep using the BSP codec handle only -// for volume. -// (B) Let the BSP own I2S (bsp_audio_init) and write decoded PCM to the -// codec via esp_codec_dev_write() from a raw_stream sink instead of -// i2s_stream. -// This file implements (A) — ADF i2s_stream as the output — which is the -// most common ADF idiom. The i2s_std config (pins, port) MUST be matched to -// the BOX-3 BSP values; left as TODOs below because they depend on the exact -// BSP version and are not safe to guess. Marked clearly. +// Data flow (two FreeRTOS tasks + one StreamBuffer, no busy-spin anywhere): // -// Because ADF is only present when ADF_PATH is set, the whole body is guarded -// by __has_include so a no-ADF inspection build still compiles (the functions -// then log "audio disabled"). +// [HTTP task] esp_http_client GET url +// -> HTTP_EVENT_ON_DATA callback +// -> xStreamBufferSend(net_buf) (blocks if buffer full) +// +// [decode task] xStreamBufferReceive(net_buf) (blocks if buffer empty) +// -> esp_audio_simple_dec_process (MP3 -> PCM) +// -> esp_codec_dev_write() (blocks / paces playback) +// +// The codec is the rate limiter: esp_codec_dev_write() blocks until the I2S DMA +// drains, which naturally throttles the whole chain. If the network is slower +// than playback, the StreamBuffer empties and the decode task simply blocks on +// receive (no spinning). If the network is faster, the HTTP callback blocks on +// send until the decoder has consumed room (back-pressure). A single volatile +// s_stop flag unwinds both tasks cleanly; lisael_radio_stop() waits (bounded) +// for them to exit and restores the IDLE source. #include "audio/radio_pipeline.h" #include "audio/audio_player.h" -#include "lisael_config.h" #include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/idf_additions.h" +#include "freertos/stream_buffer.h" +#include "esp_heap_caps.h" #include "esp_log.h" +#include "esp_codec_dev.h" +#include "esp_audio_simple_dec.h" +#include "esp_audio_simple_dec_default.h" +#include "esp_audio_dec_default.h" +#include "esp_http_client.h" +#include "esp_crt_bundle.h" // HTTPS CA bundle (RadioKing streams are https) +#include "net/udp_log.h" // temporary network debug static const char *TAG = "radio"; -#if defined(__has_include) -# if __has_include("audio_pipeline.h") -# define LISAEL_HAVE_ADF 1 -# endif -#endif +// --- Tuning ------------------------------------------------------------------ +#define NET_STREAM_BUF_BYTES (32 * 1024) // ~32 KB of buffered compressed MP3 +#define NET_CHUNK_TIMEOUT_MS 2000 // max wait pushing a network chunk +#define DEC_READ_TIMEOUT_MS 2000 // max wait pulling MP3 from buffer +#define DEC_READ_CHUNK 4096 // bytes pulled per decode iteration +#define DEC_OUT_CAP_INIT 8192 // initial PCM output buffer +#define HTTP_RX_BUFFER 2048 // esp_http_client receive buffer -#ifdef LISAEL_HAVE_ADF +// --- State (single radio instance; sources are mutually exclusive) ---------- +static volatile bool s_stop; // request both tasks to unwind +static volatile bool s_http_running; // HTTP task alive +static volatile bool s_dec_running; // decode task alive +static volatile bool s_playing; // public "is playing" state -#include "audio_pipeline.h" -#include "audio_element.h" -#include "http_stream.h" -#include "i2s_stream.h" -#include "esp_decoder.h" -#include "audio_mem.h" +static StreamBufferHandle_t s_net_buf; // compressed MP3 bytes: HTTP -> decoder +static TaskHandle_t s_http_task; +static TaskHandle_t s_dec_task; +static char s_url[512]; -static audio_pipeline_handle_t s_pipe; -static audio_element_handle_t s_http, s_decoder, s_i2s; -static bool s_playing; - -static esp_err_t build_pipeline(void) +// Register the MP3 frame decoder + simple-decoder container parsers once. +static void register_decoders_once(void) { - if (s_pipe) { - return ESP_OK; // already built + static bool done; + if (!done) { + esp_audio_dec_register_default(); // MP3/AAC frame decoders + esp_audio_simple_dec_register_default(); // simple-dec wrappers + done = true; } +} - audio_pipeline_cfg_t pcfg = DEFAULT_AUDIO_PIPELINE_CONFIG(); - s_pipe = audio_pipeline_init(&pcfg); - if (!s_pipe) { - return ESP_ERR_NO_MEM; +// ---------------------------------------------------------------------------- +// HTTP task: pull bytes off the network and push them into the StreamBuffer. +// ---------------------------------------------------------------------------- + +// Called by esp_http_client from inside esp_http_client_perform() for every +// received body chunk. We forward the bytes into the StreamBuffer; the send +// blocks (bounded) when the buffer is full, applying back-pressure to the TCP +// read without ever busy-spinning. Returning ESP_FAIL aborts the transfer, +// which is how a stop request unwinds the (otherwise endless) live stream. +static esp_err_t http_event(esp_http_client_event_t *evt) +{ + if (evt->event_id == HTTP_EVENT_ON_DATA && evt->data_len > 0) { + static bool first = false; + if (!first) { first = true; lisael_udp_send("RADIO: first net data, len=%d", evt->data_len); } + const uint8_t *p = (const uint8_t *)evt->data; + size_t remaining = (size_t)evt->data_len; + while (remaining > 0) { + if (s_stop) { + return ESP_FAIL; // abort perform() -> HTTP task returns + } + size_t sent = xStreamBufferSend(s_net_buf, p, remaining, + pdMS_TO_TICKS(NET_CHUNK_TIMEOUT_MS)); + p += sent; + remaining -= sent; + // If sent < remaining we timed out with the buffer full (decoder + // stalled / paused) — loop again, re-checking s_stop. No spin: the + // send call itself blocks for up to NET_CHUNK_TIMEOUT_MS. + } } - - // --- HTTP source --------------------------------------------------------- - http_stream_cfg_t http_cfg = HTTP_STREAM_CFG_DEFAULT(); - http_cfg.type = AUDIO_STREAM_READER; - s_http = http_stream_init(&http_cfg); - - // --- Auto decoder (MP3 / AAC / others) ---------------------------------- - // esp_decoder auto-detects the container/codec from the stream — perfect - // for webradios that may serve MP3 or AAC. - audio_decoder_t decoders[] = { - DEFAULT_ESP_MP3_DECODER_CONFIG(), - DEFAULT_ESP_AAC_DECODER_CONFIG(), - }; - esp_decoder_cfg_t dec_cfg = DEFAULT_ESP_DECODER_CONFIG(); - s_decoder = esp_decoder_init(&dec_cfg, decoders, - sizeof(decoders) / sizeof(decoders[0])); - - // --- I2S sink to ES8311 -------------------------------------------------- - // TODO(hardware): confirm the I2S port number and that these pins match the - // BOX-3 BSP. With ADF >= 2.7 the simplest path is i2s_stream_init with the - // default config and then i2s_stream_set_clk() once the decoder reports the - // sample rate. The codec itself (gain/mute) stays driven by the BSP - // esp_codec_dev handle (lisael_audio_codec()). - i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT(); - i2s_cfg.type = AUDIO_STREAM_WRITER; - s_i2s = i2s_stream_init(&i2s_cfg); - - audio_pipeline_register(s_pipe, s_http, "http"); - audio_pipeline_register(s_pipe, s_decoder, "dec"); - audio_pipeline_register(s_pipe, s_i2s, "i2s"); - - const char *link[] = { "http", "dec", "i2s" }; - audio_pipeline_link(s_pipe, link, 3); - - ESP_LOGI(TAG, "pipeline built: http -> dec -> i2s"); return ESP_OK; } +static void http_task(void *arg) +{ + (void)arg; + s_http_running = true; + ESP_LOGI(TAG, "http: GET %s", s_url); + + esp_http_client_config_t cfg = { + .url = s_url, + .event_handler = http_event, + .timeout_ms = 15000, + .buffer_size = HTTP_RX_BUFFER, + // Follow 3xx automatically: RadioKing /play/ endpoints redirect to + // the live listen.radioking.com mount, and the stream itself is one long + // 200 response we want to keep reading. .disable_auto_redirect = false. + .disable_auto_redirect = false, + .max_redirection_count = 5, + // HTTPS support via the certificate bundle (harmless for plain http://). + .crt_bundle_attach = esp_crt_bundle_attach, + .keep_alive_enable = true, + }; + + esp_http_client_handle_t client = esp_http_client_init(&cfg); + if (client) { + lisael_udp_send("RADIO: http perform start"); + esp_err_t err = esp_http_client_perform(client); + int status = esp_http_client_get_status_code(client); + lisael_udp_send("RADIO: http ended err=%s status=%d", + esp_err_to_name(err), status); + if (err != ESP_OK && !s_stop) { + ESP_LOGW(TAG, "http perform ended: %s", esp_err_to_name(err)); + } + esp_http_client_cleanup(client); + } else { + lisael_udp_send("RADIO: http client init FAILED"); + ESP_LOGE(TAG, "http client init failed"); + } + + // The network is gone: tell the decoder to wind down too. + s_stop = true; + s_http_running = false; + s_http_task = NULL; + ESP_LOGI(TAG, "http task exit"); + vTaskDeleteWithCaps(NULL); +} + +// ---------------------------------------------------------------------------- +// Decode task: pull MP3 from the StreamBuffer, decode to PCM, write to codec. +// ---------------------------------------------------------------------------- +static void decode_task(void *arg) +{ + (void)arg; + s_dec_running = true; + register_decoders_once(); + + esp_audio_simple_dec_cfg_t dcfg = { + .dec_type = ESP_AUDIO_SIMPLE_DEC_TYPE_MP3, // FR kids webradios are MP3 + .dec_cfg = NULL, + .cfg_size = 0, + }; + esp_audio_simple_dec_handle_t dec = NULL; + if (esp_audio_simple_dec_open(&dcfg, &dec) != ESP_AUDIO_ERR_OK) { + ESP_LOGE(TAG, "decoder open failed"); + goto done; + } + + esp_codec_dev_handle_t codec = lisael_audio_codec(); + uint8_t *inbuf = malloc(DEC_READ_CHUNK); + uint32_t out_cap = DEC_OUT_CAP_INIT; + uint8_t *out = malloc(out_cap); + bool codec_open = false; + + if (!inbuf || !out || !codec) { + ESP_LOGE(TAG, "decode buffers/codec unavailable"); + free(inbuf); + free(out); + if (dec) esp_audio_simple_dec_close(dec); + goto done; + } + + ESP_LOGI(TAG, "decode task started"); + + while (!s_stop) { + // Block until at least 1 byte is available (or timeout). When the HTTP + // task has exited and drained the buffer this returns 0 -> we leave. + size_t n = xStreamBufferReceive(s_net_buf, inbuf, DEC_READ_CHUNK, + pdMS_TO_TICKS(DEC_READ_TIMEOUT_MS)); + if (n == 0) { + // No data within the timeout. If the producer is gone, stop; + // otherwise loop and wait again (the receive blocked, no spin). + if (!s_http_running) { + break; + } + continue; + } + + esp_audio_simple_dec_raw_t raw = { + .buffer = inbuf, + .len = (uint32_t)n, + .eos = false, + }; + while (raw.len > 0 && !s_stop) { + esp_audio_simple_dec_out_t frame = { .buffer = out, .len = out_cap }; + esp_audio_err_t e = esp_audio_simple_dec_process(dec, &raw, &frame); + if (e == ESP_AUDIO_ERR_BUFF_NOT_ENOUGH) { + uint8_t *bigger = realloc(out, frame.needed_size); + if (!bigger) { s_stop = true; break; } + out = bigger; out_cap = frame.needed_size; + continue; // retry with a larger PCM buffer + } + if (e != ESP_AUDIO_ERR_OK) { + // A corrupt MP3 frame at a stream boundary is normal; skip this + // input chunk and resync on the next network read. + ESP_LOGD(TAG, "decode err %d (resync)", (int)e); + break; + } + if (frame.decoded_size > 0) { + if (!codec_open) { + // Open the codec at the rate/channels reported by the stream + // on the first decoded PCM, exactly like aac_player.c. + esp_audio_simple_dec_info_t info = {0}; + esp_audio_simple_dec_get_info(dec, &info); + ESP_LOGI(TAG, "MP3 %" PRIu32 " Hz %u ch %u bit", + info.sample_rate, info.channel, + info.bits_per_sample); + lisael_udp_send("RADIO: decoded MP3 %" PRIu32 " Hz %u ch %u bit -> opening codec", + info.sample_rate, info.channel, info.bits_per_sample); + esp_codec_dev_close(codec); + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = info.bits_per_sample, + .channel = info.channel, + .sample_rate = info.sample_rate, + }; + esp_codec_dev_open(codec, &fs); + esp_codec_dev_set_out_vol(codec, + (float)lisael_audio_get_volume()); + codec_open = true; + } + // Blocking write — this is what paces the whole pipeline. + esp_codec_dev_write(codec, frame.buffer, frame.decoded_size); + } + if (raw.consumed == 0 && frame.decoded_size == 0) { + break; // no progress on this chunk; fetch more from the network + } + raw.buffer += raw.consumed; + raw.len -= raw.consumed; + } + } + + free(inbuf); + free(out); + if (codec_open) { + esp_codec_dev_close(codec); + } + esp_audio_simple_dec_close(dec); + +done: + // Make sure the HTTP task also stops (e.g. when we exited on a decode error). + s_stop = true; + if (lisael_audio_get_source() == LISAEL_AUDIO_RADIO) { + lisael_audio_set_source(LISAEL_AUDIO_IDLE); + } + s_playing = false; + s_dec_running = false; + s_dec_task = NULL; + ESP_LOGI(TAG, "decode task exit"); + vTaskDeleteWithCaps(NULL); +} + +// ---------------------------------------------------------------------------- +// Public API +// ---------------------------------------------------------------------------- esp_err_t lisael_radio_play(const char *url) { if (!url) { return ESP_ERR_INVALID_ARG; } - // Take over the shared codec from any other source. + + // Stop whatever else is playing (and any previous radio session) first. + lisael_radio_stop(); lisael_audio_stop_all(); - esp_err_t err = build_pipeline(); - if (err != ESP_OK) { - return err; - } - - if (s_playing) { - audio_pipeline_stop(s_pipe); - audio_pipeline_wait_for_stop(s_pipe); - audio_pipeline_reset_ringbuffer(s_pipe); - audio_pipeline_reset_elements(s_pipe); - s_playing = false; - } - - audio_element_set_uri(s_http, url); - err = audio_pipeline_run(s_pipe); - if (err == ESP_OK) { - s_playing = true; - lisael_audio_set_source(LISAEL_AUDIO_RADIO); - // Re-assert the child-safe volume on the shared codec. - lisael_audio_set_volume(lisael_audio_get_volume()); - ESP_LOGI(TAG, "playing %s", url); + // Lazily create the network->decoder StreamBuffer IN PSRAM: it is ~32 KB + // and internal RAM is nearly exhausted (xStreamBufferCreate would fail with + // NO_MEM, which is exactly why the radio never started). + if (!s_net_buf) { + s_net_buf = xStreamBufferCreateWithCaps(NET_STREAM_BUF_BYTES, 1, + MALLOC_CAP_SPIRAM); + if (!s_net_buf) { + ESP_LOGE(TAG, "stream buffer alloc failed"); + lisael_udp_send("RADIO: stream buffer alloc FAILED"); + return ESP_ERR_NO_MEM; + } } else { - ESP_LOGE(TAG, "pipeline run failed: %s", esp_err_to_name(err)); + xStreamBufferReset(s_net_buf); } - return err; + + strlcpy(s_url, url, sizeof(s_url)); + s_stop = false; + s_playing = true; + lisael_audio_set_source(LISAEL_AUDIO_RADIO); + + // Priority 4 == LVGL task priority. We MUST NOT run above the UI task or a + // saturated audio path could starve the display. The codec write in the + // decode task blocks, so it yields the CPU regularly; priority 4 keeps it + // responsive without preempting LVGL's own time slice. pin to no core so + // the scheduler can balance with Wi-Fi/LVGL. + BaseType_t ok1 = xTaskCreatePinnedToCoreWithCaps(decode_task, "radio_dec", 8192, + NULL, 4, &s_dec_task, + tskNO_AFFINITY, MALLOC_CAP_SPIRAM); + BaseType_t ok2 = xTaskCreatePinnedToCoreWithCaps(http_task, "radio_http", 8192, + NULL, 4, &s_http_task, + tskNO_AFFINITY, MALLOC_CAP_SPIRAM); + if (ok1 != pdPASS || ok2 != pdPASS) { + ESP_LOGE(TAG, "task create failed"); + s_stop = true; + // Let whichever task did start unwind, then report failure. + lisael_radio_stop(); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "playing %s", url); + return ESP_OK; } void lisael_radio_stop(void) { - if (s_pipe && s_playing) { - audio_pipeline_stop(s_pipe); - audio_pipeline_wait_for_stop(s_pipe); - audio_pipeline_reset_ringbuffer(s_pipe); - audio_pipeline_reset_elements(s_pipe); - s_playing = false; - ESP_LOGI(TAG, "stopped"); + if (!s_http_running && !s_dec_running && !s_playing) { + return; // nothing to do } + + s_stop = true; + // Nudge the decode task in case it is blocked on an empty StreamBuffer. + if (s_net_buf) { + xStreamBufferReset(s_net_buf); + } + + // Wait (bounded, ~2 s) for both tasks to release the codec and exit. The + // HTTP perform may take up to its timeout to notice the abort, and the + // decode task may be mid codec write; ~100 * 20 ms covers both. + for (int i = 0; i < 100 && (s_http_running || s_dec_running); i++) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + + s_playing = false; + if (lisael_audio_get_source() == LISAEL_AUDIO_RADIO) { + lisael_audio_set_source(LISAEL_AUDIO_IDLE); + } + ESP_LOGI(TAG, "stopped"); } bool lisael_radio_is_playing(void) { return s_playing; } - -#else // !LISAEL_HAVE_ADF — inspection build without ESP-ADF - -esp_err_t lisael_radio_play(const char *url) -{ - (void)url; - ESP_LOGW(TAG, "ESP-ADF not present (ADF_PATH unset) — radio disabled"); - return ESP_ERR_NOT_SUPPORTED; -} -void lisael_radio_stop(void) {} -bool lisael_radio_is_playing(void) { return false; } - -#endif diff --git a/main/audio/radio_pipeline.h b/main/audio/radio_pipeline.h index 9a121cc..f481bb7 100644 --- a/main/audio/radio_pipeline.h +++ b/main/audio/radio_pipeline.h @@ -2,6 +2,7 @@ #pragma once #include "esp_err.h" +#include #ifdef __cplusplus extern "C" { diff --git a/main/audio/sd_player.c b/main/audio/sd_player.c index 8c19619..71dc7ba 100644 --- a/main/audio/sd_player.c +++ b/main/audio/sd_player.c @@ -1,5 +1,8 @@ -// microSD sound-clip player (ESP-ADF fatfs pipeline) for Lisael Box. -// See sd_player.h. Same BSP<->ADF I2S ownership caveat as radio_pipeline.c. +// microSD sound-clip / story player for Lisael Box. See sd_player.h. +// +// Playback uses chmorgan/esp-audio-player (MP3 -> PCM) pushed to the BSP's +// ES8311 codec through the callbacks installed in audio_player.c. No ESP-ADF, +// so no audio_board <-> esp_lcd_ili9341 version conflict with the BOX-3 BSP. #include "audio/sd_player.h" #include "audio/audio_player.h" @@ -12,6 +15,8 @@ #include "esp_log.h" #include "bsp/esp-box-3.h" #include "cJSON.h" +#include // chmorgan (angle brackets => its include dir, not + // our same-directory audio/audio_player.h) static const char *TAG = "sd"; static bool s_mounted; @@ -33,14 +38,20 @@ esp_err_t lisael_sd_mount(void) int lisael_sd_load_manifest(lisael_sound_t *out, int max) { - if (!out || max <= 0) { + return lisael_sd_load_manifest_dir("sounds", out, max); +} + +int lisael_sd_load_manifest_dir(const char *subdir, lisael_sound_t *out, int max) +{ + if (!out || max <= 0 || !subdir) { return -1; } if (!s_mounted && lisael_sd_mount() != ESP_OK) { return -1; } - const char *path = BSP_SD_MOUNT_POINT "/sounds/manifest.json"; + char path[96]; + snprintf(path, sizeof(path), "%s/%s/manifest.json", BSP_SD_MOUNT_POINT, subdir); FILE *f = fopen(path, "r"); if (!f) { ESP_LOGW(TAG, "no manifest at %s", path); @@ -78,15 +89,22 @@ int lisael_sd_load_manifest(lisael_sound_t *out, int max) } cJSON *file = cJSON_GetObjectItem(item, "file"); cJSON *title = cJSON_GetObjectItem(item, "title"); + cJSON *show = cJSON_GetObjectItem(item, "show"); cJSON *emoji = cJSON_GetObjectItem(item, "emoji"); if (!cJSON_IsString(file)) { continue; } memset(&out[n], 0, sizeof(out[n])); - strlcpy(out[n].file, file->valuestring, sizeof(out[n].file)); - strlcpy(out[n].title, - cJSON_IsString(title) ? title->valuestring : file->valuestring, - sizeof(out[n].title)); + // Absolute path so callers can play it directly regardless of subdir. + snprintf(out[n].file, sizeof(out[n].file), "%s/%s/%s", + BSP_SD_MOUNT_POINT, subdir, file->valuestring); + const char *t = cJSON_IsString(title) ? title->valuestring : file->valuestring; + if (cJSON_IsString(show) && show->valuestring[0]) { + snprintf(out[n].title, sizeof(out[n].title), "%s - %s", + show->valuestring, t); + } else { + strlcpy(out[n].title, t, sizeof(out[n].title)); + } strlcpy(out[n].emoji, cJSON_IsString(emoji) ? emoji->valuestring : "\U0001F50A", // speaker sizeof(out[n].emoji)); @@ -104,57 +122,7 @@ esp_err_t lisael_sd_play_sound(const char *basename) return lisael_sd_play_file(abs); } -// --- ADF-guarded pipeline --------------------------------------------------- -#if defined(__has_include) -# if __has_include("audio_pipeline.h") -# define LISAEL_HAVE_ADF 1 -# endif -#endif - -#ifdef LISAEL_HAVE_ADF - -#include "audio_pipeline.h" -#include "audio_element.h" -#include "fatfs_stream.h" -#include "mp3_decoder.h" -#include "i2s_stream.h" - -static audio_pipeline_handle_t s_pipe; -static audio_element_handle_t s_fatfs, s_mp3, s_i2s; -static bool s_playing; - -static esp_err_t build_pipeline(void) -{ - if (s_pipe) { - return ESP_OK; - } - audio_pipeline_cfg_t pcfg = DEFAULT_AUDIO_PIPELINE_CONFIG(); - s_pipe = audio_pipeline_init(&pcfg); - if (!s_pipe) { - return ESP_ERR_NO_MEM; - } - - fatfs_stream_cfg_t fs_cfg = FATFS_STREAM_CFG_DEFAULT(); - fs_cfg.type = AUDIO_STREAM_READER; - s_fatfs = fatfs_stream_init(&fs_cfg); - - mp3_decoder_cfg_t mp3_cfg = DEFAULT_MP3_DECODER_CONFIG(); - s_mp3 = mp3_decoder_init(&mp3_cfg); - - // TODO(hardware): match i2s port/pins to the BOX-3 BSP — same caveat as - // radio_pipeline.c (ADF vs BSP I2S ownership). - i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT(); - i2s_cfg.type = AUDIO_STREAM_WRITER; - s_i2s = i2s_stream_init(&i2s_cfg); - - audio_pipeline_register(s_pipe, s_fatfs, "file"); - audio_pipeline_register(s_pipe, s_mp3, "mp3"); - audio_pipeline_register(s_pipe, s_i2s, "i2s"); - - const char *link[] = { "file", "mp3", "i2s" }; - audio_pipeline_link(s_pipe, link, 3); - return ESP_OK; -} +// --- playback via esp-audio-player ------------------------------------------ esp_err_t lisael_sd_play_file(const char *abs_path) { @@ -164,59 +132,39 @@ esp_err_t lisael_sd_play_file(const char *abs_path) if (!s_mounted && lisael_sd_mount() != ESP_OK) { return ESP_ERR_NOT_FOUND; } - lisael_audio_stop_all(); - - esp_err_t err = build_pipeline(); + esp_err_t err = lisael_audio_player_ensure(); if (err != ESP_OK) { return err; } - if (s_playing) { - audio_pipeline_stop(s_pipe); - audio_pipeline_wait_for_stop(s_pipe); - audio_pipeline_reset_ringbuffer(s_pipe); - audio_pipeline_reset_elements(s_pipe); - s_playing = false; - } + lisael_audio_stop_all(); // mutually exclusive sources - audio_element_set_uri(s_fatfs, abs_path); - err = audio_pipeline_run(s_pipe); + FILE *fp = fopen(abs_path, "rb"); + if (!fp) { + ESP_LOGW(TAG, "cannot open %s", abs_path); + return ESP_ERR_NOT_FOUND; + } + // audio_player_play() takes ownership of fp and fclose()s it on success + // (and on playback error). Only close it ourselves if queuing failed. + err = audio_player_play(fp); if (err == ESP_OK) { - s_playing = true; lisael_audio_set_source(LISAEL_AUDIO_SD); - lisael_audio_set_volume(lisael_audio_get_volume()); ESP_LOGI(TAG, "playing %s", abs_path); } else { - ESP_LOGE(TAG, "run failed: %s", esp_err_to_name(err)); + fclose(fp); + ESP_LOGE(TAG, "play failed: %s", esp_err_to_name(err)); } return err; } void lisael_sd_stop(void) { - if (s_pipe && s_playing) { - audio_pipeline_stop(s_pipe); - audio_pipeline_wait_for_stop(s_pipe); - audio_pipeline_reset_ringbuffer(s_pipe); - audio_pipeline_reset_elements(s_pipe); - s_playing = false; + if (audio_player_get_state() != AUDIO_PLAYER_STATE_IDLE) { + audio_player_stop(); ESP_LOGI(TAG, "stopped"); } } bool lisael_sd_is_playing(void) { - return s_playing; + return audio_player_get_state() == AUDIO_PLAYER_STATE_PLAYING; } - -#else // !LISAEL_HAVE_ADF - -esp_err_t lisael_sd_play_file(const char *abs_path) -{ - (void)abs_path; - ESP_LOGW(TAG, "ESP-ADF not present — SD playback disabled"); - return ESP_ERR_NOT_SUPPORTED; -} -void lisael_sd_stop(void) {} -bool lisael_sd_is_playing(void) { return false; } - -#endif diff --git a/main/audio/sd_player.h b/main/audio/sd_player.h index 54b0aed..6ff6ccc 100644 --- a/main/audio/sd_player.h +++ b/main/audio/sd_player.h @@ -29,6 +29,13 @@ esp_err_t lisael_sd_mount(void); // LISAEL_SD_MAX_SOUNDS). Returns -1 on error (no card / no manifest). int lisael_sd_load_manifest(lisael_sound_t *out, int max); +// Generic: parse //manifest.json (a JSON array of +// {file, title, show?, emoji?}). out[i].file is filled with the ABSOLUTE path +// "//"; when "show" is present, out[i].title is composed as +// "show — title". Returns count (>=0), or -1 on error. Used for both the sound +// clips ("sounds") and the downloaded podcast episodes ("podcasts"). +int lisael_sd_load_manifest_dir(const char *subdir, lisael_sound_t *out, int max); + // Play an absolute SD path (e.g. "/sdcard/sounds/miaou.mp3"). Stops other audio. esp_err_t lisael_sd_play_file(const char *abs_path); diff --git a/main/idf_component.yml b/main/idf_component.yml index 5444411..d33c85d 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -19,6 +19,18 @@ dependencies: # esp_lcd_ili9341, button, icm42670, aht30 transitively. espressif/esp-box-3: "^3.2.0" + # Audio playback (MP3 from SD, later HTTP streams) WITHOUT ESP-ADF: a small + # high-level player that decodes to PCM and hands it to a write callback we + # back with the BSP's ES8311 codec (esp_codec_dev). Avoids the ESP-ADF + # audio_board <-> esp_lcd_ili9341 version conflict with the BOX-3 BSP. + chmorgan/esp-audio-player: "^1.0.7" + chmorgan/esp-libhelix-mp3: "^1.0.3" + + # AAC / M4A decoding (Radio France podcasts are AAC in an MP4/.m4a container). + # Espressif's audio codec lib provides a "simple decoder" that demuxes M4A and + # decodes AAC/MP3/etc -> PCM. Used to test on-device AAC playback. + espressif/esp_audio_codec: "^2.0.0" + # JSON parsing (manifest.json, open-meteo replies) uses the cJSON shipped with # ESP-IDF as the built-in "json" component — no managed dependency needed. # (REQUIRES json in main/CMakeLists.txt.) diff --git a/main/modes/balloons.c b/main/modes/balloons.c new file mode 100644 index 0000000..3203a7b --- /dev/null +++ b/main/modes/balloons.c @@ -0,0 +1,383 @@ +// "Calme ta colère" — balloon-popping game, see balloons.h. +// +// Colourful balloons (a rounded lv_obj with a vertical gradient for a 3D sheen, +// plus a thin "string" below) rise from y=240 to off-screen at y=-80 with a +// per-balloon random duration. A repeating lv_timer spawns a new balloon every +// ~0.9-1.4 s, capped at BALLOON_MAX on screen at once. Tapping a balloon makes +// it pop: the rise animation is removed, the balloon scales up while fading out +// (~200 ms), then it is deleted; a short synthesised "pop" is played. +// +// Lifetime / threading: +// * The spawn timer, rise animations and pop animations all run on the LVGL +// task, so they touch LVGL freely. +// * The pop sound runs on its own detached FreeRTOS task and touches ONLY the +// shared codec (never LVGL). A single volatile flag serialises pops so two +// tasks never fight over the codec. +// * On LV_EVENT_SCREEN_UNLOADED everything is torn down: the timer is deleted, +// every live balloon (and its rise anim) is removed, and audio is stopped. + +#include "ui/ui.h" +#include "ui/fonts/lisael_fonts.h" +#include "audio/audio_player.h" + +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/idf_additions.h" +#include "esp_heap_caps.h" +#include "esp_codec_dev.h" +#include "esp_log.h" + +static const char *TAG = "ui.balloons"; + +// --- Geometry / timing tunables --------------------------------------------- +#define SCREEN_W 320 +#define SCREEN_H 240 +#define BALLOON_W 56 // balloon body width (px) +#define BALLOON_H 66 // balloon body height (px) +#define STRING_H 22 // little string under the balloon (px) +#define Y_START SCREEN_H // spawn just below the screen +#define Y_END (-(BALLOON_H + STRING_H + 14)) // fully off the top +#define RISE_MIN_MS 4000 +#define RISE_MAX_MS 8000 +#define SPAWN_MIN_MS 900 +#define SPAWN_MAX_MS 1400 +#define BALLOON_MAX 8 // max balloons alive at once +#define POP_MS 200 // pop grow/fade duration + +// --- Pop sound (detached task) ---------------------------------------------- +// A satisfying, cathartic "pop": a short explosion (noise burst + descending +// boom) immediately followed by a bright melodic note that rings out. Each pop +// steps UP a C-major-pentatonic scale (cycling), so popping balloons in a row +// plays a cheerful rising melody — the "défouloir" effect. +#define POP_SR 32000 +#define POP_MS_SOUND 520 // longer, rewarding +#define POP_SAMPLES ((POP_SR * POP_MS_SOUND) / 1000) + +static volatile bool s_pop_running; // serialise codec access +static uint32_t s_rng = 0x1234567u; // tiny noise generator +static int s_note = 0; // ascending melody index + +// C5 D5 E5 G5 A5 C6 D6 E6 — major pentatonic, ascending. +static const float PENTA[] = { + 523.25f, 587.33f, 659.25f, 783.99f, 880.00f, 1046.50f, 1174.66f, 1318.51f, +}; + +static inline float frand(void) +{ + s_rng = s_rng * 1664525u + 1013904223u; + return ((float)((s_rng >> 9) & 0xFFFF) / 32768.0f) - 1.0f; // -1..1 +} + +static void pop_task(void *arg) +{ + int note = (int)(intptr_t)arg; + esp_codec_dev_handle_t codec = lisael_audio_codec(); + if (!codec) { + s_pop_running = false; + vTaskDeleteWithCaps(NULL); + return; + } + esp_codec_dev_close(codec); + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, .channel = 1, .sample_rate = POP_SR, + }; + esp_codec_dev_open(codec, &fs); + esp_codec_dev_set_out_vol(codec, (float)lisael_audio_get_volume()); + + const int N = 256; + int16_t *buf = malloc(N * sizeof(int16_t)); + if (buf) { + const float TAU = 6.2831853f; + const float fnote = PENTA[note % (int)(sizeof(PENTA) / sizeof(PENTA[0]))]; + float ph = 0, ph2 = 0, phb = 0; // note, octave, boom phases + int written = 0; + while (written < POP_SAMPLES) { + int n = POP_SAMPLES - written; + if (n > N) n = N; + for (int i = 0; i < n; i++) { + float t = (float)(written + i) / (float)POP_SR; // seconds + // 1) Explosion: noise + descending boom, very fast decay (~60 ms). + float burst_env = expf(-26.0f * t); + float boom_f = 60.0f + 260.0f * expf(-12.0f * t); + float burst = (frand() * 0.5f + sinf(phb) * 0.55f) * burst_env; + // 2) Melodic note: soft attack, rings out over ~400 ms. + float atk = t < 0.012f ? t / 0.012f : 1.0f; + float note_env = atk * expf(-3.4f * t); + float tone = (sinf(ph) * 0.7f + sinf(ph2) * 0.3f) * note_env; + float s = burst * 0.55f + tone * 0.55f; + int v = (int)(s * 9000.0f); + if (v > 32767) v = 32767; + if (v < -32768) v = -32768; + buf[i] = (int16_t)v; + ph += TAU * fnote / POP_SR; if (ph > TAU) ph -= TAU; + ph2 += TAU * fnote * 2.0f / POP_SR; if (ph2 > TAU) ph2 -= TAU; + phb += TAU * boom_f / POP_SR; if (phb > TAU) phb -= TAU; + } + esp_codec_dev_write(codec, buf, n * sizeof(int16_t)); + written += n; + } + free(buf); + } + esp_codec_dev_close(codec); + s_pop_running = false; + vTaskDeleteWithCaps(NULL); +} + +static void play_pop(void) +{ + if (s_pop_running) { + return; // one already ringing — don't fight over the codec + } + s_pop_running = true; + int note = s_note++; // ascend the scale on each successful pop + // Priority 3 (below the LVGL task at 4) so the pop animation stays smooth. + if (xTaskCreatePinnedToCoreWithCaps(pop_task, "balloon_pop", 4096, + (void *)(intptr_t)note, 3, NULL, + tskNO_AFFINITY, MALLOC_CAP_SPIRAM) != pdPASS) { + s_pop_running = false; + } +} + +// --- Balloon bookkeeping (all on the LVGL task) ----------------------------- +static lv_obj_t *s_root; +static lv_obj_t *s_play_area; // balloons are children of this +static lv_timer_t *s_spawn_timer; // periodic spawner, NULL when idle +static lv_obj_t *s_balloons[BALLOON_MAX]; // live balloons (NULL = free slot) + +// Vivid balloon colours (light top -> vivid bottom for a 3D look). +typedef struct { uint32_t top; uint32_t bottom; } balloon_pal_t; +static const balloon_pal_t PALETTE[] = { + { 0xFFC9C9, 0xE03131 }, // red + { 0xA5D8FF, 0x1971C2 }, // blue + { 0xB2F2BB, 0x2F9E44 }, // green + { 0xFFEC99, 0xF59F00 }, // yellow + { 0xD0BFFF, 0x7048E8 }, // purple + { 0xFFD8A8, 0xE8590C }, // orange +}; +#define PALETTE_N (sizeof(PALETTE) / sizeof(PALETTE[0])) + +static int balloon_slot(lv_obj_t *b) +{ + for (int i = 0; i < BALLOON_MAX; i++) { + if (s_balloons[i] == b) return i; + } + return -1; +} + +static void balloon_release_slot(lv_obj_t *b) +{ + int i = balloon_slot(b); + if (i >= 0) s_balloons[i] = NULL; +} + +// Rise animation: move the balloon's y; when it reaches the top, delete it. +static void rise_anim_cb(void *obj, int32_t y) +{ + lv_obj_set_y((lv_obj_t *)obj, y); +} + +static void rise_done_cb(lv_anim_t *a) +{ + lv_obj_t *b = (lv_obj_t *)lv_anim_get_user_data(a); + balloon_release_slot(b); + lv_obj_delete(b); // floated off the top of the screen +} + +// Pop: scale up + fade out, then delete asynchronously. +static void pop_scale_cb(void *obj, int32_t scale) +{ + lv_obj_set_style_transform_scale_x((lv_obj_t *)obj, scale, 0); + lv_obj_set_style_transform_scale_y((lv_obj_t *)obj, scale, 0); +} + +static void pop_opa_cb(void *obj, int32_t opa) +{ + lv_obj_set_style_opa((lv_obj_t *)obj, (lv_opa_t)opa, 0); +} + +static void pop_done_cb(lv_anim_t *a) +{ + lv_obj_t *b = (lv_obj_t *)lv_anim_get_user_data(a); + balloon_release_slot(b); + lv_obj_delete_async(b); // safe-delete after callbacks unwind +} + +static void balloon_clicked_cb(lv_event_t *e) +{ + lv_obj_t *b = lv_event_get_target_obj(e); + + play_pop(); + + // Stop the rise so the two animations don't fight over the same object. + lv_anim_delete(b, rise_anim_cb); + // Free the slot now so the spawner can reuse it during the short pop anim. + balloon_release_slot(b); + // No more taps while it pops. + lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); + // Scale around the centre so it grows in place. + lv_obj_set_style_transform_pivot_x(b, BALLOON_W / 2, 0); + lv_obj_set_style_transform_pivot_y(b, BALLOON_H / 2, 0); + + // Grow. + lv_anim_t grow; + lv_anim_init(&grow); + lv_anim_set_var(&grow, b); + lv_anim_set_exec_cb(&grow, pop_scale_cb); + lv_anim_set_values(&grow, LV_SCALE_NONE, LV_SCALE_NONE * 7 / 4); // 1.0 -> 1.75x + lv_anim_set_duration(&grow, POP_MS); + lv_anim_set_path_cb(&grow, lv_anim_path_ease_out); + lv_anim_start(&grow); + + // Fade out in parallel; this animation owns the delete. + lv_anim_t fade; + lv_anim_init(&fade); + lv_anim_set_var(&fade, b); + lv_anim_set_exec_cb(&fade, pop_opa_cb); + lv_anim_set_user_data(&fade, b); + lv_anim_set_values(&fade, LV_OPA_COVER, LV_OPA_TRANSP); + lv_anim_set_duration(&fade, POP_MS); + lv_anim_set_completed_cb(&fade, pop_done_cb); + lv_anim_start(&fade); +} + +// Build one balloon (body + string) at a random x with a random colour, and +// start its rise animation. Returns the balloon object, or NULL if full. +static lv_obj_t *spawn_balloon(void) +{ + int slot = -1; + for (int i = 0; i < BALLOON_MAX; i++) { + if (!s_balloons[i]) { slot = i; break; } + } + if (slot < 0) { + return NULL; // already at capacity + } + + const balloon_pal_t *pal = &PALETTE[lv_rand(0, PALETTE_N - 1)]; + int x = (int)lv_rand(6, SCREEN_W - BALLOON_W - 6); + + // Balloon body: rounded rectangle with a vertical gradient (light -> vivid). + lv_obj_t *b = lv_obj_create(s_play_area); + lv_obj_set_size(b, BALLOON_W, BALLOON_H); + lv_obj_set_pos(b, x, Y_START); + lv_obj_set_style_radius(b, BALLOON_W / 2, 0); // egg-ish rounded balloon + lv_obj_set_style_bg_color(b, lv_color_hex(pal->top), 0); + lv_obj_set_style_bg_grad_color(b, lv_color_hex(pal->bottom), 0); + lv_obj_set_style_bg_grad_dir(b, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_border_width(b, 0, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_pad_all(b, 0, 0); + lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(b, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(b, balloon_clicked_cb, LV_EVENT_CLICKED, NULL); + + // Little string hanging from the bottom of the balloon. + lv_obj_t *string = lv_obj_create(b); + lv_obj_set_size(string, 2, STRING_H); + lv_obj_align(string, LV_ALIGN_BOTTOM_MID, 0, STRING_H); + lv_obj_set_style_radius(string, 1, 0); + lv_obj_set_style_bg_color(string, lv_color_hex(0x868E96), 0); + lv_obj_set_style_border_width(string, 0, 0); + lv_obj_remove_flag(string, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(string, LV_OBJ_FLAG_SCROLLABLE); + + s_balloons[slot] = b; + + // Rise from the bottom to off the top, then self-delete. + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, b); + lv_anim_set_exec_cb(&a, rise_anim_cb); + lv_anim_set_user_data(&a, b); + lv_anim_set_values(&a, Y_START, Y_END); + lv_anim_set_duration(&a, (uint32_t)lv_rand(RISE_MIN_MS, RISE_MAX_MS)); + lv_anim_set_path_cb(&a, lv_anim_path_linear); + lv_anim_set_completed_cb(&a, rise_done_cb); + lv_anim_start(&a); + + return b; +} + +// Periodic spawner: add a balloon and reschedule itself with a new random gap. +static void spawn_timer_cb(lv_timer_t *t) +{ + spawn_balloon(); + lv_timer_set_period(t, lv_rand(SPAWN_MIN_MS, SPAWN_MAX_MS)); +} + +// Remove every live balloon (deleting its rise anim first) and stop spawning. +static void teardown(void) +{ + if (s_spawn_timer) { + lv_timer_delete(s_spawn_timer); + s_spawn_timer = NULL; + } + for (int i = 0; i < BALLOON_MAX; i++) { + if (s_balloons[i]) { + lv_anim_delete(s_balloons[i], rise_anim_cb); + lv_anim_delete(s_balloons[i], pop_scale_cb); + lv_anim_delete(s_balloons[i], pop_opa_cb); + lv_obj_delete(s_balloons[i]); + s_balloons[i] = NULL; + } + } +} + +static void on_screen_event(lv_event_t *e) +{ + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_SCREEN_LOADED) { + ESP_LOGI(TAG, "balloons started"); + lisael_audio_stop_all(); // silence radio / podcast / calme + // A couple of balloons already on screen feels livelier than an empty + // start, then let the timer keep them coming. + spawn_balloon(); + spawn_balloon(); + if (!s_spawn_timer) { + s_spawn_timer = lv_timer_create(spawn_timer_cb, + lv_rand(SPAWN_MIN_MS, SPAWN_MAX_MS), + NULL); + } + } else if (code == LV_EVENT_SCREEN_UNLOADED) { + ESP_LOGI(TAG, "balloons stopped"); + teardown(); + lisael_audio_stop_all(); // make sure any pop tail is cut + } +} + +lv_obj_t *lisael_screen_balloons(void) +{ + if (s_root) { + return s_root; + } + s_root = lv_obj_create(NULL); + // Soft sky background so the vivid balloons stand out. + lv_obj_set_style_bg_color(s_root, lv_color_hex(0xE3FAFC), 0); + lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0); + lv_obj_remove_flag(s_root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_LOADED, NULL); + lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_UNLOADED, NULL); + + lisael_ui_add_back_button(s_root); + + lv_obj_t *title = lv_label_create(s_root); + lv_label_set_text(title, "Éclate les ballons !"); + lv_obj_set_style_text_font(title, &lisael_font_24, 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16); + + // Full-screen transparent play area that hosts (and clips nothing of) the + // balloons. Keeping balloons in their own container makes teardown simple + // and keeps the title/back-button untouched. + s_play_area = lv_obj_create(s_root); + lv_obj_remove_style_all(s_play_area); + lv_obj_set_size(s_play_area, SCREEN_W, SCREEN_H); + lv_obj_align(s_play_area, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_remove_flag(s_play_area, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(s_play_area, LV_OBJ_FLAG_CLICKABLE); + lv_obj_move_to_index(s_play_area, 0); // behind the title + back button + + return s_root; +} diff --git a/main/modes/balloons.h b/main/modes/balloons.h new file mode 100644 index 0000000..f265ab5 --- /dev/null +++ b/main/modes/balloons.h @@ -0,0 +1,21 @@ +// "Calme ta colère" balloon-popping game for Lisael Box. +// +// Colourful balloons drift up from the bottom of the screen; tapping one makes +// it pop (grow + fade) with a short "pop" sound. A soothing way for a child to +// let off steam. LVGL-only graphics (no images); the pop sound is synthesised +// on a detached FreeRTOS task so the LVGL task is never blocked. +#pragma once + +#include "lvgl.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Build (lazily, cached) the balloon game screen and return its root object. +// Call while holding the display lock, like the other mode builders. +lv_obj_t *lisael_screen_balloons(void); + +#ifdef __cplusplus +} +#endif diff --git a/main/modes/calm.c b/main/modes/calm.c index 43aa5e8..8cd3ccf 100644 --- a/main/modes/calm.c +++ b/main/modes/calm.c @@ -7,7 +7,8 @@ #include "ui/ui.h" #include "ui/icons.h" -#include "audio/sd_player.h" +#include "ui/fonts/lisael_fonts.h" +#include "audio/calm_tone.h" #include "esp_log.h" @@ -82,10 +83,12 @@ static void on_screen_event(lv_event_t *e) if (code == LV_EVENT_SCREEN_LOADED) { ESP_LOGI(TAG, "breathing started"); start_inhale(); - // Best-effort soft ambience (optional file; ignored if absent). - lisael_sd_play_sound("calme.mp3"); + // Soft 432 Hz tone with harmonics, breathing-synced (rises on inhale, + // falls on exhale) — synthesised in real time, no SD file needed. + lisael_calm_tone_start(); } else if (code == LV_EVENT_SCREEN_UNLOADED) { lv_anim_delete(s_bubble, anim_size_cb); + lisael_calm_tone_stop(); } } @@ -95,24 +98,45 @@ lv_obj_t *lisael_screen_calm(void) return s_root; } s_root = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_root, lv_color_hex(0xE7F5FF), 0); // calm blue + // Calm vertical gradient (soft blue at the top fading to near-white). + lv_obj_set_style_bg_color(s_root, lv_color_hex(0xBFE3FF), 0); + lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0xF2FAFF), 0); + lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0); lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_LOADED, NULL); lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_UNLOADED, NULL); lisael_ui_add_back_button(s_root); + // 3D glassy bubble: vertical gradient (light top -> blue bottom), a soft + // cyan glow (shadow), and a small white specular highlight near the top. s_bubble = lv_obj_create(s_root); lv_obj_set_size(s_bubble, BUBBLE_MIN, BUBBLE_MIN); lv_obj_center(s_bubble); lv_obj_set_style_radius(s_bubble, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(s_bubble, lv_color_hex(0x74C0FC), 0); - lv_obj_set_style_bg_opa(s_bubble, LV_OPA_70, 0); + lv_obj_set_style_bg_color(s_bubble, lv_color_hex(0xD6F0FF), 0); + lv_obj_set_style_bg_grad_color(s_bubble, lv_color_hex(0x4DA9F0), 0); + lv_obj_set_style_bg_grad_dir(s_bubble, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(s_bubble, LV_OPA_90, 0); lv_obj_set_style_border_width(s_bubble, 0, 0); + // NO blurred shadow here: it is recomputed every animation frame as the + // bubble breathes (resizes), and the soft-shadow blur over a large circle + // with the small draw buffer monopolised the LVGL task -> watchdog/freeze. + // The vertical gradient + the white gloss highlight give enough depth. lv_obj_clear_flag(s_bubble, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t *gloss = lv_obj_create(s_bubble); + lv_obj_set_size(gloss, 26, 26); + lv_obj_align(gloss, LV_ALIGN_TOP_LEFT, 14, 12); + lv_obj_set_style_radius(gloss, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(gloss, lv_color_white(), 0); + lv_obj_set_style_bg_opa(gloss, LV_OPA_60, 0); + lv_obj_set_style_border_width(gloss, 0, 0); + lv_obj_clear_flag(gloss, LV_OBJ_FLAG_SCROLLABLE); + s_phase_lbl = lv_label_create(s_root); lv_label_set_text(s_phase_lbl, "Inspire…"); - lv_obj_set_style_text_font(s_phase_lbl, &lv_font_montserrat_28, 0); + lv_obj_set_style_text_font(s_phase_lbl, &lisael_font_30, 0); + lv_obj_set_style_text_color(s_phase_lbl, lv_color_hex(0x2A5B86), 0); lv_obj_align(s_phase_lbl, LV_ALIGN_BOTTOM_MID, 0, -16); return s_root; diff --git a/main/modes/games.c b/main/modes/games.c index fa520b4..3fe96b3 100644 --- a/main/modes/games.c +++ b/main/modes/games.c @@ -13,6 +13,8 @@ #include "ui/ui.h" #include "ui/icons.h" +#include "ui/assets/lisael_icons.h" +#include "ui/fonts/lisael_fonts.h" #include #include @@ -34,10 +36,15 @@ static void show_bravo(lv_obj_t *parent) lv_obj_center(toast); lv_obj_set_style_bg_color(toast, lv_color_hex(0xFFF3BF), 0); lv_obj_set_style_radius(toast, 16, 0); + lv_obj_set_flex_flow(toast, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(toast, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(toast, 8, 0); lv_obj_t *lbl = lv_label_create(toast); - lv_label_set_text(lbl, "Bravo ! " LISAEL_EMOJI_STAR); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_28, 0); - lv_obj_center(lbl); + lv_label_set_text(lbl, "Bravo !"); + lv_obj_set_style_text_font(lbl, &lisael_font_24, 0); + lv_obj_t *star = lv_image_create(toast); + lv_image_set_src(star, &lisael_ic_star); // Auto-dismiss after 1.5 s. lv_obj_delete_delayed(toast, 1500); } @@ -48,10 +55,10 @@ static void show_bravo(lv_obj_t *parent) #define MEM_PAIRS 3 #define MEM_CARDS (MEM_PAIRS * 2) -static const char *const MEM_FACES[MEM_PAIRS] = { - "\U0001F431", // 🐱 - "\U0001F436", // 🐶 - "\U0001F438", // 🐸 +static const lv_image_dsc_t *const MEM_FACES[MEM_PAIRS] = { + &lisael_ic_cat, // 🐱 + &lisael_ic_dog, // 🐶 + &lisael_ic_frog, // 🐸 }; typedef struct { @@ -65,8 +72,13 @@ typedef struct { static void mem_set_revealed(memory_state_t *st, int i, bool revealed) { - lv_obj_t *lbl = lv_obj_get_child(st->cards[i], 0); - lv_label_set_text(lbl, revealed ? MEM_FACES[st->face[i]] : "?"); + lv_obj_t *img = lv_obj_get_child(st->cards[i], 0); + if (revealed) { + lv_image_set_src(img, MEM_FACES[st->face[i]]); + lv_obj_set_style_image_opa(img, LV_OPA_COVER, 0); + } else { + lv_obj_set_style_image_opa(img, LV_OPA_TRANSP, 0); // face down + } lv_obj_set_style_bg_color(st->cards[i], revealed ? lv_color_hex(0xB2F2BB) : lv_color_hex(0x99C8FF), 0); } @@ -147,10 +159,8 @@ static void mem_build(lv_obj_t *parent) lv_obj_set_size(card, 90, 80); lv_obj_set_style_radius(card, 12, 0); lv_obj_add_event_cb(card, mem_card_cb, LV_EVENT_CLICKED, st); - lv_obj_t *lbl = lv_label_create(card); - lv_label_set_text(lbl, "?"); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_28, 0); - lv_obj_center(lbl); + lv_obj_t *img = lv_image_create(card); + lv_obj_center(img); st->cards[i] = card; mem_set_revealed(st, i, false); } @@ -192,9 +202,8 @@ static void count_new_round(count_state_t *st) // Render the objects. lv_obj_clean(st->objects_box); for (int i = 0; i < st->answer; i++) { - lv_obj_t *o = lv_label_create(st->objects_box); - lv_label_set_text(o, "\U0001F34E"); // 🍎 - lv_obj_set_style_text_font(o, &lv_font_montserrat_28, 0); + lv_obj_t *o = lv_image_create(st->objects_box); // 🍎 apple icon + lv_image_set_src(o, &lisael_ic_apple); } // Offer the right answer + two distractors in a shuffled set. @@ -294,9 +303,16 @@ static void open_game(game_builder_t builder, const char *title) ESP_LOGI(TAG, "game open: %s", title); } -static void memory_cb(lv_event_t *e) { (void)e; open_game(mem_build, LISAEL_EMOJI_GAMES " Memory"); } -static void count_cb(lv_event_t *e) { (void)e; open_game(count_build, "🔢 Compter"); } -static void color_cb(lv_event_t *e) { (void)e; open_game(build_coloring_stub, "🎨 Coloriage"); } +static void memory_cb(lv_event_t *e) { (void)e; open_game(mem_build, "Memory"); } +static void count_cb(lv_event_t *e) { (void)e; open_game(count_build, "Compter"); } +static void color_cb(lv_event_t *e) { (void)e; open_game(build_coloring_stub, "Coloriage"); } +static void balloons_cb(lv_event_t *e) +{ + (void)e; + // The balloons screen is self-contained (own back button to HOME). + lv_screen_load_anim(lisael_screen_balloons(), LV_SCR_LOAD_ANIM_MOVE_LEFT, + 250, 0, false); +} lv_obj_t *lisael_screen_games(void) { @@ -309,7 +325,8 @@ lv_obj_t *lisael_screen_games(void) lisael_ui_add_back_button(s_menu_root); lv_obj_t *title = lv_label_create(s_menu_root); - lv_label_set_text(title, LISAEL_EMOJI_GAMES " Jeux"); + lv_label_set_text(title, "Jeux"); + lv_obj_set_style_text_font(title, &lisael_font_24, 0); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16); lv_obj_t *list = lv_obj_create(s_menu_root); @@ -321,19 +338,25 @@ lv_obj_t *lisael_screen_games(void) LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(list, 10, 0); - struct { const char *txt; lv_event_cb_t cb; } games[] = { - { LISAEL_EMOJI_GAMES " Memory", memory_cb }, - { "🔢 Compter", count_cb }, - { "🎨 Coloriage (bientôt)", color_cb }, + struct { const char *txt; const lv_image_dsc_t *icon; lv_event_cb_t cb; } games[] = { + { "Memory", &lisael_ic_cat, memory_cb }, + { "Compter", &lisael_ic_apple, count_cb }, + { "Calme ta colère", &lisael_ic_balloon, balloons_cb }, + { "Coloriage", &lisael_ic_palette, color_cb }, }; for (size_t i = 0; i < sizeof(games) / sizeof(games[0]); i++) { lv_obj_t *b = lv_button_create(list); lv_obj_add_style(b, lisael_ui_style_button(), 0); - lv_obj_set_width(b, LV_PCT(80)); + lv_obj_set_width(b, LV_PCT(82)); + lv_obj_set_flex_flow(b, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(b, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(b, 12, 0); lv_obj_add_event_cb(b, games[i].cb, LV_EVENT_CLICKED, NULL); + lv_obj_t *ic = lv_image_create(b); + lv_image_set_src(ic, games[i].icon); lv_obj_t *l = lv_label_create(b); lv_label_set_text(l, games[i].txt); - lv_obj_center(l); } return s_menu_root; } diff --git a/main/modes/radio.c b/main/modes/radio.c index f76e8d8..72c7ee3 100644 --- a/main/modes/radio.c +++ b/main/modes/radio.c @@ -1,122 +1,139 @@ // Webradio mode screen for Lisael Box. // -// A scrollable column of big station buttons + a bottom bar with volume down / -// stop / volume up. Tapping a station starts the ESP-ADF http_stream pipeline. +// 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. +// +// 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). #include "ui/ui.h" -#include "ui/icons.h" -#include "modes/radios.h" +#include "ui/fonts/lisael_fonts.h" #include "audio/radio_pipeline.h" #include "audio/audio_player.h" -#include "lisael_config.h" -#include +#include #include "esp_log.h" static const char *TAG = "ui.radio"; +// --- Station table (name + live MP3 stream URL) ----------------------------- +typedef struct { + const char *name; + const char *url; +} radio_station_t; + +// 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/). +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" }, + // Radio Pomme d'Api (Bayard) — la radio des 3-7 ans. + { "Pomme d'Api", "https://listen.radioking.com/radio/361753/stream/411352" }, +}; + +#define K_STATIONS_COUNT (sizeof(k_stations) / sizeof(k_stations[0])) + static lv_obj_t *s_root; static lv_obj_t *s_status_lbl; -static lv_obj_t *s_vol_lbl; -static void refresh_volume_label(void) +static void set_status(const char *txt) { - if (s_vol_lbl) { - char buf[16]; - snprintf(buf, sizeof(buf), "%d%%", lisael_audio_get_volume()); - lv_label_set_text(s_vol_lbl, buf); + if (s_status_lbl) { + lv_label_set_text(s_status_lbl, txt); } } static void station_cb(lv_event_t *e) { - const lisael_radio_station_t *st = lv_event_get_user_data(e); + const radio_station_t *st = (const radio_station_t *)lv_event_get_user_data(e); ESP_LOGI(TAG, "station: %s", st->name); esp_err_t err = lisael_radio_play(st->url); if (s_status_lbl) { lv_label_set_text_fmt(s_status_lbl, "%s %s", - err == ESP_OK ? "Écoute :" : "Erreur :", st->name); + err == ESP_OK ? "Écoute :" : "Oups :", st->name); } } static void stop_cb(lv_event_t *e) { (void)e; - lisael_audio_stop_all(); - if (s_status_lbl) { - lv_label_set_text(s_status_lbl, "Arrêté"); - } + lisael_radio_stop(); + set_status("Silence"); } -static void vol_up_cb(lv_event_t *e) { (void)e; lisael_audio_volume_step(+5); refresh_volume_label(); } -static void vol_down_cb(lv_event_t *e) { (void)e; lisael_audio_volume_step(-5); refresh_volume_label(); } - lv_obj_t *lisael_screen_radio(void) { if (s_root) { - refresh_volume_label(); return s_root; } s_root = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_root, lv_color_hex(0xFFF4E6), 0); + + // 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); 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, LISAEL_EMOJI_RADIO " Radios"); - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16); + 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); - // Station list (scrollable column). + // 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), 130); - lv_obj_align(list, LV_ALIGN_TOP_MID, 0, 66); + 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); - for (size_t i = 0; i < LISAEL_RADIOS_COUNT; i++) { + 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 *)&LISAEL_RADIOS[i]); + (void *)&k_stations[i]); lv_obj_t *lbl = lv_label_create(btn); - lv_label_set_text_fmt(lbl, "%s %s", - LISAEL_RADIOS[i].emoji, LISAEL_RADIOS[i].name); + lv_label_set_text(lbl, k_stations[i].name); lv_obj_center(lbl); } - // Bottom control bar: vol-, stop, vol+, and a volume readout. - lv_obj_t *bar = lv_obj_create(s_root); - lv_obj_remove_style_all(bar); - lv_obj_set_size(bar, LV_PCT(100), 60); - lv_obj_align(bar, LV_ALIGN_BOTTOM_MID, 0, -2); - lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_SPACE_EVENLY, - LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - struct { const char *txt; lv_event_cb_t cb; } ctrls[] = { - { LV_SYMBOL_MINUS, vol_down_cb }, - { LV_SYMBOL_STOP, stop_cb }, - { LV_SYMBOL_PLUS, vol_up_cb }, - }; - for (size_t i = 0; i < 3; i++) { - lv_obj_t *b = lv_button_create(bar); - lv_obj_add_style(b, lisael_ui_style_button(), 0); - lv_obj_set_size(b, 64, 50); - lv_obj_add_event_cb(b, ctrls[i].cb, LV_EVENT_CLICKED, NULL); - lv_obj_t *l = lv_label_create(b); - lv_label_set_text(l, ctrls[i].txt); - lv_obj_center(l); - } - s_vol_lbl = lv_label_create(bar); - refresh_volume_label(); + // Bottom Stop button. + 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_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, -66); + lv_obj_align(s_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -64); return s_root; } diff --git a/main/net/podcast.c b/main/net/podcast.c new file mode 100644 index 0000000..651ba8f --- /dev/null +++ b/main/net/podcast.c @@ -0,0 +1,331 @@ +// Radio France youth podcasts — see podcast.h. + +#include "net/podcast.h" +#include "audio/sd_player.h" // BSP_SD_MOUNT_POINT via sd mount; reuse mount + +#include +#include +#include +#include + +#include "esp_log.h" +#include "esp_http_client.h" +#include "esp_crt_bundle.h" + +static const char *TAG = "podcast"; + +#define PODCAST_DIR "/sdcard/podcasts" + +typedef struct { + const char *id; + const char *title; + const char *rss_url; +} podcast_show_t; + +// Verified 2026-06-14: first in each feed is the latest episode, +// audio/x-m4a (AAC in MP4). +static const podcast_show_t s_shows[] = { + { "oli", "Une histoire et Oli", + "https://radiofrance-podcast.net/podcast09/rss_19721.xml" }, + { "bestioles", "Bestioles", + "https://radiofrance-podcast.net/podcast09/35099478-7c72-4f9e-a6de-1b928400e9e5/" + "podcast_a80ecbd5-df3d-4c9d-bee7-4e3d9efc1974.xml" }, +}; + +int lisael_podcast_count(void) +{ + return (int)(sizeof(s_shows) / sizeof(s_shows[0])); +} + +const char *lisael_podcast_title(int idx) +{ + return (idx >= 0 && idx < lisael_podcast_count()) ? s_shows[idx].title : ""; +} + +const char *lisael_podcast_id(int idx) +{ + return (idx >= 0 && idx < lisael_podcast_count()) ? s_shows[idx].id : ""; +} + +void lisael_podcast_local_path(int idx, char *buf, size_t n) +{ + snprintf(buf, n, "%s/%s.m4a", PODCAST_DIR, lisael_podcast_id(idx)); +} + +bool lisael_podcast_have_local(int idx) +{ + char path[96]; + lisael_podcast_local_path(idx, path, sizeof(path)); + struct stat st; + return (stat(path, &st) == 0 && st.st_size > 0); +} + +// --- RSS: stream the feed and grab the first ---------- + +typedef struct { + char buf[49152]; // accumulator (heap/PSRAM): some feeds (Bestioles) have a + // large channel header before the first + int len; + bool found; + char url[512]; +} rss_accum_t; + +static void rss_try_extract(rss_accum_t *a) +{ + char *enc = strstr(a->buf, "= sizeof(a->url)) { + n = sizeof(a->url) - 1; + } + memcpy(a->url, u, n); + a->url[n] = '\0'; + a->found = true; +} + +static esp_err_t rss_event(esp_http_client_event_t *evt) +{ + if (evt->event_id != HTTP_EVENT_ON_DATA) { + return ESP_OK; + } + rss_accum_t *a = (rss_accum_t *)evt->user_data; + if (a->found) { + return ESP_FAIL; // abort the transfer early — we have what we need + } + int space = (int)sizeof(a->buf) - 1 - a->len; + if (space > 0) { + int cp = evt->data_len < space ? evt->data_len : space; + memcpy(a->buf + a->len, evt->data, cp); + a->len += cp; + a->buf[a->len] = '\0'; + rss_try_extract(a); + } + if (a->found || space <= 0) { + return ESP_FAIL; // stop: found, or our window is full + } + return ESP_OK; +} + +esp_err_t lisael_podcast_latest_url(int idx, char *url_out, size_t url_sz) +{ + if (idx < 0 || idx >= lisael_podcast_count() || !url_out) { + return ESP_ERR_INVALID_ARG; + } + // Heap (PSRAM) — 8 KB, must not sit in internal BSS (would starve Wi-Fi). + rss_accum_t *acc = calloc(1, sizeof(*acc)); + if (!acc) { + return ESP_ERR_NO_MEM; + } + + esp_http_client_config_t cfg = { + .url = s_shows[idx].rss_url, + .event_handler = rss_event, + .user_data = acc, + .timeout_ms = 15000, + .crt_bundle_attach = esp_crt_bundle_attach, + .buffer_size = 2048, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + if (!client) { + free(acc); + return ESP_FAIL; + } + esp_http_client_perform(client); // may "fail" because we abort early + esp_http_client_cleanup(client); + + esp_err_t ret = ESP_FAIL; + if (acc->found) { + strlcpy(url_out, acc->url, url_sz); + ESP_LOGI(TAG, "[%s] latest: %s", s_shows[idx].id, url_out); + ret = ESP_OK; + } else { + ESP_LOGW(TAG, "[%s] no enclosure found in RSS", s_shows[idx].id); + } + free(acc); + return ret; +} + +// --- download the enclosure to SD ------------------------------------------- + +typedef struct { + FILE *f; + int written; + char redirect[600]; // Location header captured from the 3xx response +} dl_accum_t; + +static esp_err_t dl_event(esp_http_client_event_t *evt) +{ + dl_accum_t *d = (dl_accum_t *)evt->user_data; + if (evt->event_id == HTTP_EVENT_ON_HEADER) { + // Response headers arrive here (get_header() only reads *request* + // headers). Capture Location so we can follow the redirect manually. + if (evt->header_key && strcasecmp(evt->header_key, "Location") == 0) { + strlcpy(d->redirect, evt->header_value, sizeof(d->redirect)); + } + } else if (evt->event_id == HTTP_EVENT_ON_DATA) { + if (d->f && evt->data_len > 0) { + d->written += (int)fwrite(evt->data, 1, evt->data_len, d->f); + } + } + return ESP_OK; +} + +static void read_sidecar(const char *path, char *out, size_t n) +{ + out[0] = '\0'; + char side[160]; + snprintf(side, sizeof(side), "%s.url", path); + FILE *f = fopen(side, "r"); + if (f) { + size_t r = fread(out, 1, n - 1, f); + out[r] = '\0'; + // trim trailing newline + char *nl = strchr(out, '\n'); + if (nl) *nl = '\0'; + fclose(f); + } +} + +static void write_sidecar(const char *path, const char *url) +{ + char side[160]; + snprintf(side, sizeof(side), "%s.url", path); + FILE *f = fopen(side, "w"); + if (f) { + fputs(url, f); + fclose(f); + } +} + +esp_err_t lisael_podcast_sync(int idx) +{ + if (idx < 0 || idx >= lisael_podcast_count()) { + return ESP_ERR_INVALID_ARG; + } + if (lisael_sd_mount() != ESP_OK) { + return ESP_ERR_NOT_FOUND; + } + + char url[512]; + if (lisael_podcast_latest_url(idx, url, sizeof(url)) != ESP_OK) { + return ESP_FAIL; + } + + char path[96]; + lisael_podcast_local_path(idx, path, sizeof(path)); + + // Already have this exact episode? Skip the (large) re-download. + char stored[512]; + read_sidecar(path, stored, sizeof(stored)); + if (lisael_podcast_have_local(idx) && strcmp(stored, url) == 0) { + ESP_LOGI(TAG, "[%s] up to date", s_shows[idx].id); + return ESP_OK; + } + + mkdir(PODCAST_DIR, 0777); + FILE *f = fopen(path, "wb"); + if (!f) { + ESP_LOGE(TAG, "[%s] cannot open %s for write", s_shows[idx].id, path); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "[%s] downloading...", s_shows[idx].id); + + // proxycast.radiofrance.fr issues a 302 to media.radiofrance-podcast.net. + // Follow redirects with a FRESH client per hop (reusing one handle across a + // host change leaves it in a bad state); 3xx bodies are empty so the file + // only fills on the final 200. + dl_accum_t d = { .f = f, .written = 0 }; + char cur[600]; + strlcpy(cur, url, sizeof(cur)); + esp_err_t err = ESP_FAIL; + int status = 0; + for (int hop = 0; hop < 5; hop++) { + d.redirect[0] = '\0'; + esp_http_client_config_t cfg = { + .url = cur, + .event_handler = dl_event, + .user_data = &d, + .timeout_ms = 30000, + .crt_bundle_attach = esp_crt_bundle_attach, + .buffer_size = 8192, // media.radiofrance sends large CDN + .buffer_size_tx = 2048, // headers + long signed URLs + .disable_auto_redirect = true, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + err = esp_http_client_perform(client); + status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + if ((status == 301 || status == 302 || status == 303 || + status == 307 || status == 308) && d.redirect[0]) { + ESP_LOGI(TAG, "[%s] -> redirect to %.60s...", s_shows[idx].id, d.redirect); + strlcpy(cur, d.redirect, sizeof(cur)); + continue; + } + break; + } + fclose(f); + + if (err == ESP_OK && status == 200 && d.written > 0) { + write_sidecar(path, url); + ESP_LOGI(TAG, "[%s] downloaded %d bytes -> %s", s_shows[idx].id, d.written, path); + return ESP_OK; + } + ESP_LOGE(TAG, "[%s] download failed err=%s status=%d written=%d", + s_shows[idx].id, esp_err_to_name(err), status, d.written); + remove(path); + return ESP_FAIL; +} + +// --- list downloaded episodes on the SD (for the "Sons" browser) ------------ + +int lisael_podcast_list_local(char paths[][128], char labels[][48], int max) +{ + DIR *d = opendir(PODCAST_DIR); + if (!d) { + return 0; + } + int n = 0; + struct dirent *e; + while ((e = readdir(d)) != NULL && n < max) { + const char *name = e->d_name; + size_t len = strlen(name); + if (len < 5 || strcmp(name + len - 4, ".m4a") != 0) { + continue; // only .m4a episode files + } + snprintf(paths[n], 128, "%s/%s", PODCAST_DIR, name); + + // Base name without extension; map a show id to its nice title. + char base[48]; + size_t bl = len - 4; + if (bl >= sizeof(base)) { + bl = sizeof(base) - 1; + } + memcpy(base, name, bl); + base[bl] = '\0'; + + const char *label = base; + for (int i = 0; i < lisael_podcast_count(); i++) { + if (strcmp(base, lisael_podcast_id(i)) == 0) { + label = lisael_podcast_title(i); + break; + } + } + strlcpy(labels[n], label, 48); + n++; + } + closedir(d); + ESP_LOGI(TAG, "local episodes: %d", n); + return n; +} diff --git a/main/net/podcast.h b/main/net/podcast.h new file mode 100644 index 0000000..3ef6eff --- /dev/null +++ b/main/net/podcast.h @@ -0,0 +1,43 @@ +// Radio France youth podcasts for Lisael Box. +// +// Fetches a show's RSS feed over HTTPS, extracts the latest episode's audio +// enclosure (AAC/.m4a) and downloads it to the microSD card, where the AAC +// player (aac_player.c) plays it offline. Hybrid model: a background prefetch +// keeps the latest episode local; the Podcasts screen plays the local file. +#pragma once + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Number of configured shows, and per-show accessors. +int lisael_podcast_count(void); +const char *lisael_podcast_title(int idx); +const char *lisael_podcast_id(int idx); + +// Absolute SD path of a show's downloaded episode ("/sdcard/podcasts/.m4a"). +void lisael_podcast_local_path(int idx, char *buf, size_t n); + +// True if a downloaded episode exists locally for this show. +bool lisael_podcast_have_local(int idx); + +// Fetch the RSS feed and write the latest episode's enclosure URL into url_out. +esp_err_t lisael_podcast_latest_url(int idx, char *url_out, size_t url_sz); + +// Ensure the latest episode is downloaded to SD. Fetches the RSS, and if the +// latest enclosure differs from what's stored (or no local file), downloads it. +// Returns ESP_OK when a local file is ready to play. +esp_err_t lisael_podcast_sync(int idx); + +// List the downloaded episode files on the SD card (/sdcard/podcasts/*.m4a). +// Fills paths[i] (absolute) and labels[i] (human title). Returns the count +// (<= max). Used by the "Sons" screen to browse/play downloaded episodes. +int lisael_podcast_list_local(char paths[][128], char labels[][48], int max); + +#ifdef __cplusplus +} +#endif diff --git a/main/net/udp_log.h b/main/net/udp_log.h new file mode 100644 index 0000000..6423c17 --- /dev/null +++ b/main/net/udp_log.h @@ -0,0 +1,22 @@ +// Lightweight UDP reporter: send debug lines over the LAN (the BOX-3 +// USB-Serial-JTAG console stops flushing after boot). Called directly from +// tasks (NOT a log vprintf hook — that deadlocks: the log mutex is held while +// sendto -> lwip might re-enter the logger). Capture on a host on the same LAN +// with `nc -ulk 9999` or a UDP listener. +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Open the broadcast socket. Call once after Wi-Fi has an IP. +void lisael_udp_log_start(uint16_t port); + +// Send one line (printf-style) as a UDP broadcast datagram. Non-blocking. +void lisael_udp_send(const char *fmt, ...); + +#ifdef __cplusplus +} +#endif diff --git a/main/net/weather.c b/main/net/weather.c index a4ac710..1f1ebc5 100644 --- a/main/net/weather.c +++ b/main/net/weather.c @@ -110,8 +110,12 @@ static esp_err_t http_event(esp_http_client_event_t *evt) static bool fetch_once(const char *lat, const char *lon, lisael_weather_t *out) { char url[256]; + // Plain HTTP on purpose: weather is public, non-sensitive data, and the TLS + // handshake's transient RAM otherwise starves the Wi-Fi RX DMA buffers + // ("wifi:mem fail") now that the LVGL draw buffer lives in internal RAM. + // HTTP avoids mbedtls entirely so the fetch is reliable. snprintf(url, sizeof(url), - "https://api.open-meteo.com/v1/forecast" + "http://api.open-meteo.com/v1/forecast" "?latitude=%s&longitude=%s¤t=temperature_2m,weather_code", lat, lon); @@ -126,7 +130,6 @@ static bool fetch_once(const char *lat, const char *lon, lisael_weather_t *out) .event_handler = http_event, .user_data = &acc, .timeout_ms = 8000, - .crt_bundle_attach = esp_crt_bundle_attach, // uses the CA bundle }; esp_http_client_handle_t client = esp_http_client_init(&config); bool ok = false; diff --git a/main/ui/fonts/lisael_fonts.h b/main/ui/fonts/lisael_fonts.h new file mode 100644 index 0000000..a4b7d5c --- /dev/null +++ b/main/ui/fonts/lisael_fonts.h @@ -0,0 +1,18 @@ +// Fredoka (OFL) converted to LVGL fonts with French accents (Latin-1 range +// 0xA0-0xFF: é è à ç ù â ê î ô û ë ï ü ...). Rounded, kid-friendly. Generated +// with lv_font_conv from tools — see ui/fonts/CREDITS.md. +#pragma once + +#include "lvgl.h" + +#ifdef __cplusplus +extern "C" { +#endif + +extern const lv_font_t lisael_font_18; // body / tiles +extern const lv_font_t lisael_font_24; // sub-headers +extern const lv_font_t lisael_font_30; // clock / big + +#ifdef __cplusplus +} +#endif diff --git a/main/ui/icons.c b/main/ui/icons.c index b24a5cb..2505973 100644 --- a/main/ui/icons.c +++ b/main/ui/icons.c @@ -1,5 +1,25 @@ -// Icon helpers translation unit — currently header-only (macros in icons.h). -// Kept as a compiled unit so future icon image descriptors / converted emoji -// fonts have a home and the build file list stays stable. +// Icon helpers for the Lisael Box UI. +// +// The home tiles and weather use real colour icons (Twemoji PNGs converted to +// LVGL RGB565A8 image descriptors, in ui/assets/). This file maps open-meteo +// WMO weather codes to the matching icon. #include "ui/icons.h" +#include "ui/assets/lisael_icons.h" + +const lv_image_dsc_t *lisael_weather_code_icon(int code) +{ + switch (code) { + case 0: return &lisael_ic_wsun; // clear + case 1: case 2: case 3: return &lisael_ic_wpartly; // partly cloudy + case 45: case 48: return &lisael_ic_wfog; // fog + case 51: case 53: case 55: return &lisael_ic_wdrizzle; // drizzle + case 61: case 63: case 65: return &lisael_ic_wrain; // rain + case 66: case 67: return &lisael_ic_wsleet; // freezing rain + case 71: case 73: case 75: + case 77: case 85: case 86: return &lisael_ic_wsnow; // snow + case 80: case 81: case 82: return &lisael_ic_wrain; // showers + case 95: case 96: case 99: return &lisael_ic_wthunder; // thunderstorm + default: return &lisael_ic_wunknown; + } +} diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 84d693f..9527aa1 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -26,3 +26,15 @@ CONFIG_ESP_TLS_INSECURE=n # --- esp-box BSP ------------------------------------------------------------- # SD card mount point used throughout the firmware (see audio/sd_player.c). CONFIG_BSP_SD_MOUNT_POINT="/sdcard" + +# Polices LVGL utilisées par l'UI Lisael Box (titres date/heure, gros boutons) +CONFIG_LV_FONT_MONTSERRAT_28=y +CONFIG_LV_FONT_MONTSERRAT_48=y + +# GCC 14 (esp 14.2) ajoute des warnings -Werror agressifs (array-bounds faux positifs) +CONFIG_COMPILER_DISABLE_GCC14_WARNINGS=y + +# Diagnostic : console primaire sur USB-Serial-JTAG (logs boot+app sur l'USB-C), +# car l'UART0 par défaut n'est pas exposé sur le port USB de la carte. +CONFIG_ESP_CONSOLE_UART_DEFAULT=n +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y