Sprint 9 Retro: Idle Screen & Error Resilience
Goal: Graceful behavior when things go wrong or nothing is playing. A clock idle screen that makes the device look intentional even when Spotify isn’t doing anything. Error states with visual feedback. WiFi resilience. The device never gets to look confused.
Verdict: The idle screen went through more design iterations than any other feature in this project. Four HTML mockups, three firmware flashes of “nope, wrong approach,” one lesson about LVGL label sizing that should’ve been obvious, and a WiFi indicator that got added, removed, and conditionally re-added. The final two-column split looks clean. The auto-dim feels right. The WiFi reconnect just works. But honestly, the real win is negative: the device no longer sits on a stale now-playing screen staring at you like a confused puppy when Spotify stops. It does something. It looks like it meant to. ┗(^∇^)┛
What We Built
Idle Screen (The Design Saga)
The first attempt was four centered labels on a black screen. Furkan’s review: “completely empty.” Fair.
So we did what any self-respecting project does when the first design is bad: we built an HTML mockup at 3x scale with four competing variants. Floating card, left accent bar, centered with thick divider, two-column split. All rendered in Montserrat, all using the real color palette, all wrong in different ways except one.
Variant D: Two-Column Split won unanimously. Clock owns the left zone (128px), last-played info owns the right, a 1px divider separates them. The clock is montserrat_ext_16 in accent purple, the date is montserrat_ext_12 in secondary grey. The right side has a letter-spaced “LAST PLAYED” caption, track name, and artist. Clean, balanced, purposeful.
Then the real screen happened. The WiFi SSID (“[redacted]”) bled across the entire bottom of the screen. The track and artist labels overlapped into an unreadable mess. Two bugs, one root cause.
The Label Sizing Lesson
LVGL’s LV_LABEL_LONG_DOT (dot-truncation) needs both width AND height constrained via lv_obj_set_size(w, h). Setting only lv_obj_set_width() lets the label expand vertically when text wraps. No error, no warning, just text flowing downward and overlapping everything below it.
The fix was a createFixedLabel() helper that sets both dimensions and configures dot-truncation in one call. Explicit LINE_H_12 (14px) and LINE_H_10 (12px) height constants. Labels physically can’t escape their boxes.
WiFi got removed entirely. It looked out of place even with proper truncation. Then it got conditionally re-added in Phase 3: hidden when connected, “Reconnecting…” when WiFi drops. Best of both worlds. Clean when everything’s fine, informative when it’s not.
Auto-Dim
Non-blocking backlight fade. 120s of no encoder interaction, the screen fades to brightness/5 (minimum 15) over 500ms via LEDC PWM. Any encoder event restores full brightness instantly. No blocking delays, no frame drops, no missed LVGL ticks.
The fade runs as a linear interpolation in displayDimTick(), called every frame. One uint8_t LEDC write per frame during the 500ms ramp. After that, nothing until the next dim event. The display module tracks targetBrightness separately from the current LEDC value, so dimming respects whatever the user set in settings and restoring goes back to exactly that level.
displayResetActivity() is called on every encoder rotation and button press, even during screen transitions. The dim timer doesn’t care what screen you’re on or whether input is “blocked.” You moved the encoder, you’re alive, the screen wakes up.
Settings Expansion
Five settings became seven. “Idle” (10-120s, step 10s) and “Dim” (30-300s, step 30s) joined the party. Row height shrank from 25px to 19px to fit all seven items in 135px. It’s tight but readable. montserrat_ext_14 fits in 19px with room to breathe.
Both values persist to NVS with short keys (“idle_to”, “dim_to”). settingsApply() in main.cpp pushes the values to their respective timers. The hardcoded IDLE_TIMEOUT_MS constant became a variable, displaySetDimTimeout() updates the display module’s internal threshold. Change the setting, it takes effect immediately, survives reboot.
WiFi Resilience
networkTick() runs every 5 seconds, checks WiFi.isConnected(), and handles three states: connected (do nothing), just disconnected (set flag, start reconnect), and still disconnected (backoff and retry).
The backoff follows the standard doubling pattern: 5s, 10s, 20s, 30s cap. WiFi.reconnect() is non-blocking. It just kicks off the attempt. Next tick checks if it worked. On reconnect: NTP re-sync (clock may have drifted), spotifyAuth() for a fresh token (old one likely expired), clear flags, resume polling.
Spotify polling is simply skipped when WiFi is down: networkIsConnected() ? spotifyPoll() : 0. No error accumulation, no stale requests queuing up, no TLS handshakes into the void. WiFi comes back, polling resumes, life goes on.
Error Banner System
error_banner.h/.cpp on lv_layer_top(), same layer as the volume overlay. Two modes: transient banners (dark surface panel at screen bottom, amber text, 2.5s auto-dismiss) and a permanent auth failure overlay (full-screen black, red “Auth Failed” + “Re-run get_token.py”).
The transient banners are rate-limited. Same message won’t repeat within 10 seconds. Without this, a flaky API would strobe error messages every poll cycle. With it, you see “Spotify unavailable” once, then silence for 10 seconds, then again if it’s still broken.
Getting the error codes to the UI required plumbing a new path through the async architecture. spotify_api.cpp stores the last HTTP status in a static. spotify_task.cpp grabs it under the existing mutex and stores it as a one-shot apiErrorPending. spotifyConsumeApiError() reads and clears it from the main loop. Same pattern as volume error, proven and thread-safe.
No Active Device vs Paused
Two paths to idle, two different experiences:
| State | Source | Timeout | Idle Screen Shows |
|---|---|---|---|
| Paused | pollResult == 1, !isPlaying | Full idle timeout (default 30s) | “LAST PLAYED” + track info |
| No device | pollResult == -1 | 3 seconds | ”No active device” / “Open Spotify” |
The noDeviceIdle flag tracks which path triggered the transition. The idle screen’s idleScreenSetNoDevice(true) hides the “LAST PLAYED” caption and replaces track/artist with helpful text. When Spotify comes back, normal wake flow handles everything. The flag gets cleared implicitly when the idle screen is destroyed on wake.
nowPlayingSetNoPlayback() now says “No Active Device” instead of “Not Playing.” It’s only visible for 3 seconds before the idle transition, but at least those 3 seconds are honest.
Things That Just Worked
- HTML mockups for embedded UI design. Montserrat from Google Fonts, real hex colors, 3x scale. What you see in the browser is what you get on the screen (more or less). Iterating in HTML is infinitely faster than flash-test-flash-test. Should’ve done this in Sprint 4.
- Non-blocking WiFi reconnect.
WiFi.reconnect()returns immediately. The ESP32 handles the actual reconnection in the background. No blocking, no polling in a tight loop, no frozen UI. Just checkisConnected()on the next tick. - One-shot error pattern. The
apiErrorPending/spotifyConsumeApiError()pattern, copied from the volume error system, worked on the first try. Core 0 sets under mutex, Core 1 reads and clears under mutex. No missed errors, no double-fires, no race conditions. - Dim system in the display module. Backlight is GPIO 4 with LEDC PWM. The dim system owns
targetBrightnessand the fade state.main.cppjust callsdisplayResetActivity()on encoder events anddisplayDimTick()in the loop. Clean separation, zero coupling to the app state machine.
By the Numbers
| Metric | Value |
|---|---|
| Flash usage | 42.3% (1,330 KB / 3,145 KB) |
| RAM usage | 22.3% (73.1 KB / 327 KB) |
| App states | 5 (NowPlaying, Menu, Settings, Queue, Idle) |
| Settings items | 7 (added Idle, Dim) |
| Settings row height | 19px (was 25px) |
| Registered screens | 5 (2 permanent, 3 lazy) |
| Dim fade duration | 500ms |
| No-device idle timeout | 3s |
| WiFi reconnect backoff | 5s -> 10s -> 20s -> 30s cap |
| Error banner dismiss | 2.5s |
| Error rate limit | 10s (same message) |
| Design mockup variants | 4 (1 chosen) |
| Idle screen rewrites | 3 |
| Files created | 4 (idle_screen, error_banner) |
| Files modified | 14 |
| New modules | 2 (idle_screen, error_banner) |
What’s Next
Sprint 10: Audio Features Display. 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 9 complete. The device has opinions now. Nothing playing? Here’s a clock. No device? Open Spotify. WiFi died? We’re reconnecting, don’t worry about it. Auth revoked? Go reflash your token, we’ll wait. Three design iterations taught us that “just center everything” is the enemy of good UI, and that LVGL labels will happily paint themselves into infinity if you don’t tell them to stop. The idle screen started as four sad labels on a black void and ended as a two-column split with a personality. The device never looks confused anymore. That’s the whole point. ╰(°▽°)╯