Sprint 8 Retro: Menu System
Goal: An iPod-style menu system navigated entirely by the encoder. Long-press enters, rotation scrolls, click selects, long-press exits. Settings persist to NVS. Queue shows real Spotify data. The encoder stops being a one-trick pony.
Verdict: The menu works. The settings persist. The queue fetches. All of that was straightforward. The real sprint was the heap war we fought along the way. What started as “why are my Spotify responses empty?” turned into a full architectural rethink of how album art buffers live in memory. We went from Largest block: 4KB (device basically bricked) to Largest block: 77KB (more headroom than we’ve ever had). The menu was the excuse. The heap fix was the legacy. ┗(^∀^)┛
What We Built
App State Machine
Four states, one static AppState in main.cpp, zero frameworks:
APP_NOW_PLAYING ──long-press──> APP_MENU
APP_MENU ──long-press──> APP_NOW_PLAYING
APP_MENU ──click Queue──> APP_QUEUE
APP_MENU ──click Settings──> APP_SETTINGS
APP_QUEUE ──long-press──> APP_MENU
APP_SETTINGS ──long-press──> APP_MENU
Every encoder event flows through one routing block. Each state owns its interpretation: rotation is volume in now-playing, scroll in menu, value adjust in settings edit mode. Input is blocked during screen transitions via screenMgrIsTransitioning(). No races, no weirdness.
Menu Root
Three items, one purple highlight, zero scroll bars. montserrat_ext_16, full-width rows, vertically centered. Focused item gets the #9B59B6 accent background, unfocused items dim to #606060. Wrapping navigation. The iPod called, it wants its UI paradigm back.
Settings System
Five settings, one NVS namespace, instant hardware feedback:
| Setting | Range | Default | Target |
|---|---|---|---|
| Brightness | 5-255 (step 15) | 255 | setBacklight() |
| Scroll Speed | Slow/Medium/Fast | Medium | nowPlayingSetScrollSpeed() |
| Animations | On/Off | On | animationsSetEnabled() |
| Poll Rate | 1s/2s/3s/5s | 3s | spotifySetPollInterval() |
| LEDs | On/Off | Off | ledSetEnabled() |
Two visual modes: browse (purple accent on focused row) and edit (dark surface bg, accent-colored value text). Click enters edit, rotation adjusts, click confirms and saves to NVS. Long-press exits without editing. Settings load on boot and apply before the main loop starts. Power cycle the device, your brightness is right where you left it.
Arduino’s Preferences library wraps NVS with a clean key-value API. Short keys (“bright”, “scroll”, “anim”, “poll”, “leds”) because NVS has a 15-char limit. prefs.begin() with read-only for load, read-write for save, prefs.end() after each operation. Simple and bulletproof.
Queue Page
On-demand fetch, not continuous polling. Click “Queue” in the menu, Core 0 fires CMD_FETCH_QUEUE, hits /v1/me/player/queue, parses 5 tracks, stores under mutex, Core 1 consumes and populates the screen. The whole thing takes about a second.
The screen shows a numbered list with track names in primary white and artist names in secondary grey, indented underneath. Long names get dot-truncation (tiny screen, big song titles). “Loading…” while the fetch is in-flight, “Queue empty” if the queue has nothing. Same lazy lifecycle as settings: create on navigate-in, destroy after slide-out completes.
The queue API response is hilariously large. 50-52KB of JSON for 5 track names. Spotify sends the full track objects with every field imaginable. We parse 5 names and 5 artists and throw the rest away. The ESP32’s getString() somehow handles it, which brings us to…
The Heap Wars, Season 2
Sprint 7 taught us that heap fragmentation kills TLS. Sprint 8 taught us where the fragmentation actually comes from and how to eliminate it for good.
Act I: The Mystery of the Empty Responses
Settings screen worked. We celebrated. Then we noticed every Spotify poll response was 200 (0 bytes). The API was responding but http.getString() returned empty Strings. No error, no crash, just… nothing.
Heap monitor told the story:
[HEAP] Free: 49292 Largest block: 4084
Four kilobytes. The largest contiguous free block was 4KB. getString() needs to allocate a ~3KB String for the response body. With 4KB largest block, sometimes the allocation barely squeezed through. Sometimes it didn’t. No error message because Arduino’s String just returns empty on allocation failure. Silent data loss. Fun.
Act II: Three Screens Too Many
Root cause: three LVGL screens created at boot (NowPlaying, Menu, Settings). Each screen spawns dozens of small LVGL objects scattered across the heap. Three screens meant hundreds of small allocations fragmenting the remaining free space into confetti.
Fix: lazy-create infrequent screens. Settings and Queue get created when you navigate to them, destroyed after the slide-out transition completes. Deferred destroy via a flag checked against !screenMgrIsTransitioning() in the main loop. Only NowPlaying and Menu live permanently.
Result: Largest block jumped from 4KB to 7.4KB. Spotify responses flowed again. But 7KB felt uncomfortably thin.
Act III: The BUILTIN Experiment
Could we give LVGL its own memory pool so it stops fragmenting the system heap? LV_STDLIB_BUILTIN with a static pool sounded perfect.
32KB pool: Largest block: 27636. TLS died instantly. -32512 SSL - Memory allocation failed. The static array sat right in the middle of DRAM, splitting the biggest contiguous region in half.
16KB pool: Largest block: 4084. Somehow WORSE than CLIB. Same static-array-in-the-middle problem with an even tighter LVGL budget.
Reverted in 30 seconds. LV_STDLIB_BUILTIN is a trap on ESP32. The memory layout matters more than the allocation strategy.
Act IV: The Real Villain
The real heap hog was never LVGL. It was album art. Four heap_caps_malloc() calls at boot, totaling 85KB, permanently sitting on the heap:
| Buffer | Size | Lifetime |
|---|---|---|
| JPEG download | 48KB | Permanent (but only used during art fetch) |
| RGB decode | 19KB | Permanent (but only used during art fetch) |
| TJpgDec work | 4KB | Permanent (but only used during art fetch) |
| Dither output | 12.8KB | Permanent (actually needed for display) |
71KB of “permanent” allocations that were only used for a ~2-second window during each track change. Sitting in the middle of the heap. Fragmenting everything around them. For the entire runtime.
Act V: The Fix
Three changes, one philosophy: only keep what you need, only when you need it.
-
ditherBuf(12.8KB) moved to static.bss. It’s the only buffer that persists (holds the displayed art). Static allocation means it’s placed at link time, never touches the heap, never fragments anything. -
JPEG + RGB + work buffers are now temp-allocated inside
albumArtProcess().malloc()at the start,free()at the end. The heap gets all 71KB back after every art fetch. No lasting fragmentation. -
Phased allocation: only
jpegBufis allocated before the HTTP download.rgbBufandworkBufwait until after the download completes. During the TLS handshake (the most memory-hungry moment), only the JPEG buffer is on the heap. TLS gets the rest.
Before:
[HEAP] Free: 98112 Largest block: 34804 Min ever: 44912
After:
[HEAP] Free: 128328 Largest block: 77812 Min ever: 57884
77KB largest block. More contiguous heap than we’ve ever measured. No TLS failures in extended testing. The device is breathing.
Epilogue: The Ever-Growing JPEG
Spotify’s “64px” album art URL lies. It serves 300x300 JPEGs that range from 6KB to 58KB depending on the image complexity. We bumped the JPEG buffer three times this sprint:
- 48KB (original, Sprint 6): “Enigmatic” and “Fantasy” exceeded it
- 56KB (first bump): “Moonlit” by VØJ at 58,471 bytes exceeded it
- 64KB (final): covers everything we’ve seen so far
With phased allocation, the 64KB is only on the heap during the ~1-second download window. Zero permanent cost.
Things That Just Worked
- Arduino
Preferencesfor NVS.begin(),putUChar(),getUChar(),end(). Five settings, ten lines of save code, ten lines of load code. Survives power cycles. No filesystem, no JSON, no drama. - Lazy screen lifecycle. Create on navigate-in, destroy on slide-out. The pattern is three lines in the navigate handler and three lines in the deferred cleanup. Heap stays clean, screens stay fresh.
CMD_FETCH_QUEUEthrough the existing command queue. The async task infrastructure from Sprint 5 handled queue fetch with zero new synchronization primitives. Same mutex, same wake semaphore, same pattern. Just a new case in the switch.- 52KB queue responses. The Spotify queue endpoint returns absurdly large JSON. ESP32’s
getString()handled it without issues once the heap was healthy. We parse 10 fields and discard ~52,000 bytes. Efficient? No. Working? Yes.
By the Numbers
| Metric | Value |
|---|---|
| Flash usage | 42.1% (1,325 KB / 3,145 KB) |
| RAM usage | 22.2% (72.8 KB / 327 KB) |
| Heap free (steady state) | ~128 KB |
| Heap largest block | ~77 KB |
| Heap min ever (extended session) | ~57 KB |
| JPEG buffer size | 64 KB (temp-allocated) |
| NVS settings | 5 keys |
| App states | 4 (NowPlaying, Menu, Settings, Queue) |
| Registered screens | 4 (2 permanent, 2 lazy) |
| Files created | 6 (menu, settings, settings_screen, queue_screen) |
| Files modified | 9 (main, screen_mgr, ui, album_art, spotify_task, spotify_types, led, now_playing, lv_conf) |
| Heap improvement | 34KB -> 77KB largest block (+126%) |
| JPEG buffer resizes | 3 (48 -> 56 -> 64 KB) |
What’s Next
Sprint 9: Audio Features & Idle Screen. The menu’s “Audio Features” slot has been sitting empty since Sprint 8. BPM, key signature, energy, danceability from Spotify’s audio analysis endpoint. A real data visualization challenge on a 240x135 screen. The API endpoint is already wired up (spotifyGetAudioFeatures()), it just needs a screen to call home.
Sprint 8 complete. We added a menu, settings, and a queue page. We also accidentally fixed the worst architectural decision from Sprint 6 (permanent heap buffers) and gave the ESP32 more breathing room than it’s ever had. The menu system works great. But honestly? The heap fix is the headline. From 34KB to 77KB largest block, from “sometimes TLS just dies” to “steady as a rock across extended sessions.” The encoder has four gestures now. The heap has room to breathe. Everything else is gravy. (ノ◕ヮ◕)ノ*:・゚✧