◀ sprint-4

← esp32-spotify-display

Sprint 4 Retro: Now Playing Display

Goal: LVGL integrated as the UI framework. The core “now playing” screen with live Spotify data rendered on the 240x135 screen with the purple-black aesthetic.

Verdict: The ESP32 has a face now. It shows what you’re listening to, ticks the seconds, flashes when the song changes, and dims the album name when you’re vibing (you don’t need to read it, you’re busy). Is it a statement piece? Not yet. But it’s a clean skeleton wearing a purple accent, waiting for the flesh. Atari Teenage Riot sounded aggressive even in Montserrat 16 ╰(°▽°)╯


What We Built

A full LVGL-powered now-playing screen, from hardware flush callback to live Spotify data rendering.

The LVGL Foundation

  • Custom display driver bridging LVGL to our existing TFT_eSPI instance. We own the flush callback: tft.startWrite() + setAddrWindow() + pushColors() + endWrite(). LVGL renders into a 9,600-byte partial buffer (240x20 lines), we push strips to the screen. Zero flickering.
  • LV_USE_TFT_ESPI 0 because we don’t let libraries touch our hardware pins. Learned that lesson in Sprint 0 with the backlight flash. We own the TFT_eSPI instance, we own the backlight, we own the flush. LVGL just renders into a buffer and we decide what happens with it.
  • System malloc (LV_USE_STDLIB_MALLOC LV_STDLIB_CLIB) instead of LVGL’s internal 64KB memory pool. ESP32’s heap is 300KB+. Let it breathe.

The Now-Playing Screen

Structured per DESIGN.md’s layout spec:

  • Left zone (90px): 80x80 purple accent placeholder, 3px rounded corners, centered vertically. Will become dithered album art in Sprint 6.
  • Right zone (134px): Flex-column container with track title (Montserrat 16, near-white), artist (14, soft grey), album (12, dim grey), and indicator row (play/shuffle/repeat icons)
  • Bottom strip (20px): Chunky 5px progress bar with purple accent fill, elapsed/total time labels in mm:ss

The Data Pipeline

  • nowPlayingUpdate(track) takes a SpotifyTrack and updates every widget. Dirty-checked: title, artist, album are String-compared against cached values. No lv_label_set_text() (which does malloc+copy) unless the text actually changed.
  • Context-dependent density: album label dims to #404040 when playing (background info), brightens to #707070 when paused (you have time to read it). Play indicator goes purple when paused to catch your eye.
  • nowPlayingTick() runs every frame (~33ms), interpolating progress between 3-second API polls. Local millis() clock advances smoothly. Drift-aware: ignores small corrections from the API (<2s), only snaps on seek or track change.
  • nowPlayingSetNoPlayback() for the 204 case: “Not Playing” in dim text, everything zeroed and dimmed. Guarded against redundant updates.

Track Change Handling

  • Dual-layer detection: polling manager (spotify_api.cpp) logs it, UI (cachedTrackId) triggers the visual response
  • Art placeholder does an opacity flash: drops to 20% instantly, LVGL animation fades back to 100% over 200ms. Fast, decisive, per DESIGN.md’s “snappy transitions” philosophy
  • Progress cache force-reset (cachedBarPermille = -1, cachedElapsedSec = -1) ensures no stale bar values survive the transition

The Design Conversation (◞‸◟)

This sprint had a running theme: Furkan kept telling us the screen looked sparse. We kept saying “trust the process, album art will fix it, animations will fix it.” Three times he said it. Three times we deflected.

He was right.

The now-playing screen with real data (“Speed” by Atari Teenage Riot) looks functional. Clean, correct, properly hierarchied. But not “an art piece that happens to be functional.” Not “earns attention without demanding it.” It reads as a readout, not a statement piece.

The honest assessment: the layout itself is solid for a 135px screen. You can’t cram more in without it looking cluttered. The visual richness has to come from execution, not density:

  • Dithered album art (Sprint 6) replaces the flat purple slab with actual texture
  • Animations (Sprint 7) give it life and personality
  • Idle screen (Sprint 9) properly handles the “Not Playing” state with clock + status instead of a sad dim label

The “Not Playing” screen is the weakest state. It’s a bright purple square, two dim words, and a bunch of nothing. That’s acknowledged and accepted: it’s a transitional state, not the final form. Sprint 9’s idle screen is what makes this usable.

Lesson learned: when the person staring at the physical device says something feels off, listen the first time. Pixel-perfect on paper doesn’t mean it works in the hand.


Architecture Decisions

Separation of Concerns: Two Files, Two Jobs

Started with everything in ui.cpp. By Step 2.4 it was getting fat. Furkan called it before we even started Phase 3.

Split into:

  • ui.cpp (47 lines): LVGL bootstrap only. Display driver, flush callback, tick provider, init
  • now_playing.cpp (300+ lines): Everything about the now-playing screen. Design tokens, layout, widgets, data updates, interpolation, no-playback state

Clean boundary. ui.cpp doesn’t know what a SpotifyTrack is. now_playing.cpp doesn’t know how pixels get to the screen.

spotifyPoll() Returns a Result Now

Was void. Problem: the loop needs to know what happened to decide between nowPlayingUpdate() and nowPlayingSetNoPlayback(). Changed to int:

  • 1 = track data available
  • 0 = no poll this frame (interval not reached, or error)
  • -1 = no active playback (204)

Errors map to 0 (no-op) so the UI doesn’t react to transient HTTP failures.

LVGL Built-in FontAwesome Symbols

The DESIGN.md said “imported icons, not text symbols.” Turns out LVGL’s Montserrat font has FontAwesome glyphs baked in: LV_SYMBOL_PLAY, LV_SYMBOL_PAUSE, LV_SYMBOL_SHUFFLE, LV_SYMBOL_LOOP. Zero additional flash. They render as clean vector icons, not Unicode text. The “imported” part was already done for us by LVGL’s font team.

Drift-Aware Interpolation

First attempt: snap to API value on every poll. Created visible micro-stutters (“speeds up and slows down” per Furkan). Fix: only snap when drift exceeds 2 seconds (seek, track change, major desync). Normal polling drift (<500ms from network latency) gets ignored. Local millis() is perfectly smooth.

Still has a ~300ms stutter from the blocking HTTP request. That’s an architecture problem (synchronous HTTP on the render loop), not an interpolation problem. Sprint 4.5 fixes it with FreeRTOS dual-core.


Things That Just Worked

  • LVGL rendering pipeline. First lv_timer_handler() call, pixels on screen. Flush callback, partial rendering, byte swap, all clean on the first try. The TFT_eSPI bridge pattern is well-documented and we followed it exactly.
  • Typography hierarchy. Montserrat 16/14/12 with #F0F0F0/#B0B0B0/#707070 creates three distinct levels visible from arm’s length. The weight hierarchy we lost (LVGL only has regular Montserrat) doesn’t matter when size + color carry the load.
  • Circular label scrolling. Set LV_LABEL_LONG_SCROLL_CIRCULAR on the title, give it a width constraint, and long titles auto-scroll with a pause at each end. Zero code for the scroll logic.
  • LVGL animation for art flash. lv_anim_init() + lv_anim_set_values(LV_OPA_20, LV_OPA_COVER) + 200ms duration. Four lines of code for a smooth opacity transition. The captureless lambda as exec callback just worked as a function pointer.
  • Context-dependent density. One lv_obj_set_style_text_color() call toggles album visibility between playing and paused. Simple, effective, the design intent comes through.

By the Numbers

MetricValue
Flash usage96.0% (1,258 KB / 1.3 MB)
RAM usage18.0% (58.8 KB / 327 KB)
Flash increase from Sprint 3+14.7% (LVGL core + 5 Montserrat fonts are hungry)
RAM increase from Sprint 3+3.2% (draw buffer + widget state)
Flash headroom~52 KB (tight, huge_app partition is the escape hatch)
Draw buffer size9,600 bytes (240x20 lines, partial rendering)
LVGL refresh rate~30fps (33ms per frame)
Montserrat font sizes5 (10, 12, 14, 16, 18)
Design tokens defined8 colors
Widget references13 static pointers
Cached state variables10 (dirty-checking)
Interpolation variables4
Files created this sprint3 (lv_conf.h, now_playing.h, now_playing.cpp)
Files modified4 (platformio.ini, ui.h, ui.cpp, main.cpp, spotify_api.h/cpp)
Times Furkan said “it looks sparse”3
Times we actually listened1 (the third time)

What’s Next

Sprint 4.5: Async HTTP (Dual-Core). The elephant in the loop. Every 3 seconds, a synchronous HTTPS request blocks everything for ~300ms. The progress bar stutters, LVGL can’t render, and (soon) the encoder won’t be read. FreeRTOS task on Core 0 handles the HTTP, Core 1 stays butter-smooth for UI and input. This is the architectural foundation that makes Sprint 5’s encoder input feel snappy.


Sprint 4 complete. The ESP32 has a face. It’s not the prettiest face yet, but it’s clean, it’s correct, and it knows all the words to “Speed” by Atari Teenage Riot. The purple square is a promise. The text hierarchy is honest. The progress bar is trying its best despite being blocked by its own HTTP requests every 3 seconds. We’ll fix that next. ᕙ(⇀‸↼‶)ᕗ