Sprint 7 Retro: Animations & Transitions
Goal: Snappy, crisp visual transitions powered by LVGL’s animation system. Slide between screens, pulse on track changes, smooth label swaps. Everything fast and intentional, 200ms max.
Verdict: We built an animation system so well-tuned that you can’t see it. That’s either a success or a failure depending on how you squint. The DESIGN.md said “snappy and crisp, minimal overshoot,” and we delivered exactly that. The real drama wasn’t the animations. It was a heap fragmentation bug that ate TLS alive, a screen manager that refused to load its first screen, and the lesson that 8KB is the difference between “works great” and “total memory annihilation” on ESP32. Classic ╰(°▽°)╯
What We Built
Animation Conventions Module
anim_config.h is the tiniest module in the project and possibly the most important for Sprint 8+. Three timing constants (ANIM_FAST = 150ms, ANIM_NORMAL = 200ms, ANIM_SLOW = 300ms), a global toggle, and a helper function that returns 0 when animations are disabled. Every animation in the project flows through animDuration().
The toggle exists for the future Settings menu. Flip it off and every transition snaps instant. Flip it on and the screen breathes again. Zero conditional logic at the call sites.
Screen Manager
iPod carousel style. Register screens by ID, transition between them with directional slides. lv_screen_load_anim() handles the heavy lifting, we just manage state.
screenMgrInit();
screenMgrRegister(SCREEN_NOW_PLAYING, nowPlayingGetScreen());
screenMgrGoTo(SCREEN_NOW_PLAYING, SLIDE_NONE);
Right now it only has one screen. That changes in Sprint 8 when the menu system plugs in. The infrastructure is ready: SLIDE_LEFT, SLIDE_RIGHT, re-entry guards, transition-complete callbacks, serial logging.
The guard against duplicate transitions was too eager on day one. We initialized currentScreen to SCREEN_NOW_PLAYING, then tried to GoTo… SCREEN_NOW_PLAYING. Guard said “you’re already here” and returned. Black screen. Fixed by initializing to SCREEN_COUNT (invalid sentinel) so the first GoTo always goes through.
Art Border Pulse
When new album art arrives, a 2px purple border flashes on the art zone and fades to transparent over 300ms with ease-out easing. It’s the kind of thing you feel more than see. A brief “something changed” acknowledgment that doesn’t demand your attention.
Doesn’t fire on nullptr (placeholder) or during loading state. The animation sets border width to 2, animates border_opa from COVER to TRANSP, then resets border width to 0 in the completion callback. Clean state, no leftover styling.
Label Fade
The headline feature nobody noticed. On track change, title/artist/album labels fade out (100ms), text swaps at the bottom of the fade, then fade back in (100ms). 200ms total, within DESIGN.md budget.
One animation drives all three labels via a shared labelOpaCb that sets text_opa on all three simultaneously. The fade-out completion callback chains the fade-in. lv_anim_delete() at the start of every new fade handles rapid skipping.
The trick: pre-cache the new text values in the track change handler so the dirty-checking below doesn’t fire instant text swaps underneath the fade. The deferred text only appears when labelFadeOutEndCb calls lv_label_set_text().
Progress Bar Smooth Reset
The smallest change in the sprint. One flag (barResetPending), one style property (lv_obj_set_style_anim_time(progressBar, ANIM_FAST, 0)), one conditional (LV_ANIM_ON vs LV_ANIM_OFF). 84 bytes of flash. The bar glides to the new position on track change instead of jumping. Normal per-frame interpolation stays instant.
The Heap Wars
Act I: String Fragmentation
After a few minutes of use, TLS connections started failing with SSL - Memory allocation failed (-32512). The heap monitor told the story: free heap was fine (~100KB), but the largest contiguous block was shrinking. TLS needs ~40KB contiguous for its buffers. Fragmentation was eating it.
The culprit: three static String objects for the label fade’s pending text. Arduino’s String class allocates from the heap on every assignment. Track change after track change, each String allocation would free-and-reallocate in slightly different sizes, fragmenting the heap into Swiss cheese.
Fix: char[128] fixed buffers. 384 bytes of static RAM, zero heap allocations, zero fragmentation. Heap stabilized at 47KB largest block across extended sessions.
Act II: The 56KB Gamble
One album art JPEG (“Bittersweet Fruit” by Isaac Delusion) was 50,616 bytes. Our JPEG buffer was 49,152 bytes (48KB). Off by 1,464 bytes. Painfully close.
Bumped the buffer to 56KB. Boot, connect, first track loads, then:
Free: 50704 Largest block: 2676
2.6KB largest contiguous block. The heap was atomized. The 56KB allocation consumed the one large DRAM region that everything else depended on. TLS, LVGL, everything fighting over scraps.
Reverted to 48KB in about 30 seconds. The occasional oversized JPEG gets a placeholder. That’s better than a dead device.
The lesson: ESP32 DRAM isn’t one big pool. It’s multiple regions, and the contiguous layout matters as much as the total. You can’t just “add 8KB” without understanding where it lands. The ceiling isn’t the total free heap. It’s the geometry of what’s left.
Things That Just Worked
- LVGL’s
lv_anim_tAPI. Init, set var, set values, set duration, set callbacks, start. Chaining animations via completion callbacks is clean. Canceling vialv_anim_delete(var, exec_cb)is surgical. lv_screen_load_anim(). One function call handles screen transitions, old screen preservation (auto_del = false), and animation timing. Sprint 8’s menu slides are essentially free.lv_bar_set_value()withLV_ANIM_ON. LVGL’s bar widget has built-in value animation. Setanim_timeonce, toggle betweenLV_ANIM_ONandLV_ANIM_OFFper call. Done.- Heap diagnostics.
ESP.getFreeHeap(),heap_caps_get_largest_free_block(), andESP.getMinFreeHeap()logged every 30 seconds made the fragmentation bug immediately obvious. Kept the monitor in for future debugging.
By the Numbers
| Metric | Value |
|---|---|
| Flash usage | 97.2% (1,274 KB / 1.3 MB) |
| RAM usage | 18.1% (59.2 KB / 327 KB) |
| Flash increase from Sprint 6 | +0.1% (+1,768 bytes) |
| RAM increase from Sprint 6 | +64 bytes (label fade char buffers) |
| Heap free (steady state) | ~100 KB |
| Heap largest block | ~47 KB |
| Label fade duration | 200ms total (100ms out + 100ms in) |
| Border pulse duration | 300ms |
| Progress bar reset | 150ms |
| Max animation duration | 300ms (border pulse) |
| Files created | 4 (anim_config.h/.cpp, screen_mgr.h/.cpp) |
| Files modified | 4 (now_playing.cpp, volume_overlay.cpp, ui.cpp, main.cpp) |
| Bugs shipped and fixed | 2 (screen manager guard, String fragmentation) |
What’s Next
Sprint 8: Menu System & Settings. Long-press the encoder, slide left into a menu. Now-playing is no longer the only screen. The screen manager gets its second customer. Settings, device info, maybe a queue view. The encoder becomes a navigation device, not just a volume knob.
Sprint 7 complete. We built animations so subtle they’re invisible, broke the heap twice, and learned that 8KB is either nothing or everything depending on where the allocator puts it. The screen doesn’t look different. It feels different. And when it doesn’t feel different, that means we did it right. (⌐■_■)