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

34 KiB
Raw Blame History

É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 :

// 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 :

// 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 :

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 :

// 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) :

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)
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 :

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)
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
// "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 :

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)
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 :

lv_obj_t *lisael_screen_soundbox(void);

et

lv_obj_t *lisael_screen_podcasts(void);  // "Histoires" — Radio France podcasts

Ajouter à la place (près des autres déclarations d'écran) :

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

        { &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
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)
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)
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
#!/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) :

[ -f /tmp/lisael_episodes/manifest.json ] && cp /tmp/lisael_episodes/manifest.json /tmp/lisael_episodes/manifest.json.v1
  • Step 2 : Lancer l'outil
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)
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 :

# (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
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.binbestioles.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) :

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.