Files
lisael-box/docs/superpowers/plans/2026-06-14-lisael-box-histoires-vignettes.md
T
Clément Saillant e35058f53d chore(box): commit firmware base + feature docs
Backfills the working firmware that had stayed uncommitted across the
build sessions, so the branch is self-contained.

- audio: audio_player, radio_pipeline, sd_player, calm_tone, aac_player
- net: podcast (RSS), udp_log
- modes: balloons, plus calm/games/radio tweaks
- ui: fonts header, icons; build files (CMakeLists, idf_component.yml,
  sdkconfig.defaults)
- docs/superpowers: Histoires design + plan

Note: sdkconfig stays gitignored (Wi-Fi creds + tuned LFN/FS/mbedtls
settings); a fresh checkout must reconfigure those.
2026-06-15 00:20:47 +02:00

868 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# É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 <assert.h>
#include <stdio.h>
#include <string.h>
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 <stdbool.h>
#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 <string.h>
#include <stdio.h>
#include <stdlib.h>
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/<x>.bin"`, que LVGL ouvre via `fopen("/sdcard/podcasts/<x>.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 <stdint.h>
#include <string.h>
#include <strings.h>
#include <stdio.h>
#include <sys/stat.h>
#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`.