e35058f53d
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.
868 lines
34 KiB
Markdown
868 lines
34 KiB
Markdown
# É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`.
|