smol-spotify-thing ♪(´▽`)
A “Now Playing” display and remote controller for Spotify, built on a tiny ESP32 with a 240x135 pixel screen. Because checking your phone to see what song is playing is mass too much effort.
Status: Work in progress. Hardware works. WiFi works. Spotify talks to us. LVGL renders a live now-playing screen with purple-black aesthetic, butter-smooth on both cores (HTTP on Core 0, UI on Core 1). The encoder is a fully functional Spotify remote: click for play/pause, double-click to skip, rotation for volume with a purple arc overlay. Album art is dithered in purple-black Bayer and animated with a diagonal loading wave. Label fades, border pulses, and smooth progress interpolation make it feel alive. Now it needs a menu system and a soul for when nothing’s playing. ᕙ(⇀‸↼‶)ᕗ
Before You Read Further
This is a personal project. It’s open source because sharing is nice and because someone out there might be Googling “TFT_eSPI invisible text” at 2am and land on our retro (hi, you need -DLOAD_GLCD=1).
But the intention was never wide adoption, portability, or “download and run.” This is built for one specific desk, one specific LilyGo T-Display, one specific EC11 encoder wired to specific pins, and one specific person who thinks 1/255 LED brightness is already too bright. There’s no abstraction layer, no hardware config file, no “just change these three lines and it works on your board.” The pin map is hardcoded. The screen is hardcoded. The vibe is hardcoded.
If you’re here to learn from the code, steal solutions to ESP32/TFT_eSPI/encoder problems, or just enjoy the debugging war stories in the retros, welcome! If you’re here looking for a plug-and-play Spotify display, you might want something like spotify-desk-thing which is built for exactly that.
We cool? Cool. Here’s what this thing does (◕‿◕)
What Is This?
A desk companion that shows what’s currently playing on Spotify and lets you control playback with a rotary encoder. No phone needed. No app switching. Just glance down and vibe.
The plan:
- Dithered album art on a 240x135 pixel screen (it looks cooler than it has any right to)
- Smooth-scrolling song title and artist name
- Progress bar with elapsed/total time
- Volume control, play/pause, skip via rotary encoder
- Full menu system with settings, audio features, and queue
- Animated transitions and particle effects (because why not)
- NTP clock when nothing’s playing
- Status LEDs that whisper at 1/255 brightness (bare LEDs are aggressive)
Hardware
| Component | Model | Role |
|---|---|---|
| Microcontroller | LilyGo T-Display ESP32 | Brains + screen |
| Display | ST7789 135x240 | The face |
| Input | EC11 Rotary Encoder | Rotate, click, long-press |
| Status | 2x bare LEDs (red + green) | Subtle indicators |
| Power | Any USB-C source | Phone charger, powerbank, whatever |
Total BOM cost: less than a month of Spotify Premium (◕‿◕)
Architecture
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Spotify │────▶│ ESP32 │────▶│ ST7789 Display │
│ Web API │◀────│ (WiFi) │ │ 240x135 │
└─────────────┘ └──────┬───────┘ └─────────────────┘
│
┌──────┴───────┐
│ EC11 │
│ Encoder │
└──────────────┘
No third-party Spotify libraries. We roll our own API client because we like to understand what our code is doing (and because it’s more fun that way).
Each hardware component lives in its own module with clean API boundaries. main.cpp orchestrates but owns nothing. The display module manages its own SPI bus, the encoder module manages its own interrupts, the LED module manages its own PWM channels. Nobody reaches into anyone else’s internals.
Sprint Progress
This project is built sprint-by-sprint with detailed retros. Each sprint has a focused goal and builds on the last.
| Sprint | Goal | Status |
|---|---|---|
| 0 | Hardware verification (screen, encoder, LEDs) | Done |
| 1 | WiFi, NTP & HTTPS | Done |
| 2 | Spotify OAuth2 token flow | Done |
| 3 | Spotify API client (9 endpoints, polling, commands) | Done |
| 4 | LVGL now-playing screen (live data, progress interpolation) | Done |
| 4.5 | Async HTTP on Core 0 (butter-smooth rendering) | Done |
| 5 | Encoder input & playback control (click, double-click, volume arc) | Done |
| 6 | Album art (JPEG decode + purple dithering + loading wave) | Done |
| 7 | Animations & transitions (label fades, border pulse, progress interp) | Done |
| 8 | Menu system (iPod-style) | Done |
| 9 | Idle screen & error resilience | Done |
| 10 | LED integration | Planned |
| 11 | Polish & optimization | Planned |
Can I Build One?
Technically? Yes. Realistically? This is a personal project built for one specific desk with one specific wiring setup and one specific person’s taste in LED brightness (1/255, we’re not animals). There’s no config wizard, no setup guide, no “works out of the box” promise. You would need the exact same hardware, the exact same wiring, and the exact same willingness to debug at 2am.
That said, the code is clean, the modules are documented, the retros explain every gotcha we hit. If you’re building something similar on a T-Display ESP32, steal whatever is useful. That’s what open source is for (◕‿◕)
# If you're brave enough
git clone https://github.com/fsecgin/smol-spotify-thing.git
cd smol-spotify-thing
# Build
pio run
# Flash (close any serial monitors first!)
pio run -t upload
# Monitor serial output
pio device monitor
Project Structure
src/
├── main.cpp # Entry point (setup + loop, orchestration only)
├── config.h # WiFi + Spotify credentials (gitignored)
├── display/
│ ├── display.h/.cpp # TFT_eSPI instance + backlight PWM
│ └── boot_screens.h/.cpp # Raw TFT boot screens (pre-LVGL)
├── input/
│ └── encoder.h/.cpp # EC11 quadrature ISR + button state machine
├── led/
│ └── led.h/.cpp # Status LEDs with PWM
├── network/
│ ├── network.h/.cpp # WiFi connection + NTP sync
│ └── https.h/.cpp # HTTPS transport (GET/POST/PUT, shared WiFiClientSecure)
├── spotify/
│ ├── spotify_api.h/.cpp # Spotify Web API client (9 endpoints, JSON parsing)
│ ├── spotify_auth.h/.cpp # OAuth2 token refresh
│ ├── spotify_task.h/.cpp # Core 0 async poll + command queue
│ └── spotify_types.h # Shared data types (SpotifyTrack, etc.)
├── fonts/
│ ├── montserrat_ext.h # Custom font declarations
│ └── montserrat_ext_*.c # Generated Montserrat 10/12/14/16 (extended Latin + FA symbols)
├── ui/
│ ├── now_playing.h/.cpp # Now-playing screen (creation, data binding, orchestration)
│ ├── np_internal.h # Shared widget refs, design tokens, layout constants
│ ├── np_transitions.cpp # Label fades, loading wave, album art swap, border pulse
│ ├── np_progress.cpp # Progress interpolation (per-frame tick)
│ ├── album_art.h/.cpp # JPEG download + Bayer dithering pipeline
│ ├── volume_overlay.h/.cpp # LVGL arc overlay on lv_layer_top()
│ ├── screen_mgr.h/.cpp # Screen transition manager
│ ├── anim_config.h/.cpp # Animation timing + global toggle
│ └── ui.h/.cpp # LVGL init + display driver
└── utils/ # Shared utilities
Lessons Learned So Far
Things we figured out the hard way so you don’t have to:
- TFT_eSPI +
USER_SETUP_LOADED: Fonts don’t load automatically. Add-DLOAD_GLCD=1,-DLOAD_FONT2=1, etc. or enjoy your textless screen (¬_¬) - TFT_eSPI + backlight pin: Don’t define
-DTFT_BL. The library will blast your backlight on during init while the framebuffer is full of garbage. Manage the pin yourself. - EC11 encoders: One physical “click” = 4 quadrature state transitions. If you’re dividing by 4 per loop iteration instead of tracking absolute position, you’ll get zero. Every time.
- Bare LED brightness: 1/255 is more than enough at desk distance. 60/255 will light up the room. 255/255 will interrogate you.
- ESP-IDF Mozilla CA bundle: Already compiled into the Arduino framework. One
extern+ onesetCACertBundle()call and your ESP32 trusts the same CAs your browser does. No downloading certs, no PEM strings. - Shared
WiFiClientSecureacross hosts:secureClient.stop()alone isn’t enough when there’s idle time between requests. You needhttp.setReuse(false)on every HTTPClient to forceConnection: closeand proper teardown. Without it, stale TLS state hangs the next handshake. The(-76) UNKNOWN ERROR CODEin serial is cosmetic and unfixable. Every ESP32 Spotify project has it. - Spotify’s “active” vs “playing”:
/v1/me/playerreturns 200 even when nothing is playing, as long as a device is recently active. 204 only when no device exists at all. Checkis_playingin the JSON, don’t just check the status code. - LVGL 9.x + PlatformIO: Put
lv_conf.hininclude/with-DLV_CONF_INCLUDE_SIMPLE -Iincludebuild flags. UseLV_USE_TFT_ESPI 0if you manage your own TFT_eSPI instance (you should, to control the backlight). System malloc (LV_STDLIB_CLIB) over LVGL’s internal pool. - LVGL eats flash: Going from “LVGL linked but unused” to “rendering a screen with 5 Montserrat font sizes” jumped flash usage from 81% to 96%. We switched to the
huge_apppartition scheme (3MB app, no OTA) and now sit comfortably at ~42%. - LVGL built-in fonts are ASCII only: Default Montserrat fonts cover U+0020-U+007F. Your Rosalia and Bjork track titles will render as sad little squares. Generate custom fonts via
lv_font_convwith Latin-1 Supplement (U+00A0-U+00FF) and Latin Extended-A (U+0100-U+017F). Don’t forget to include the FontAwesome codepoints forLV_SYMBOL_PLAYand friends, or your playback icons vanish too. - Blocking HTTP on the render loop: Synchronous HTTPS requests block LVGL rendering for ~300ms every poll cycle. Visible as progress bar stutters. The fix is FreeRTOS dual-core (HTTP on Core 0, UI on Core 1). Worth doing early.
- Bodyless PUT/POST to Spotify: ESP32’s
HTTPClient::PUT()with zero-length payload doesn’t sendContent-Length: 0automatically. Spotify returns 411 Length Required. Add the header yourself. - Spotify returns 200 instead of 204: Playback commands (play, pause, next) are documented to return 204 but sometimes return 200 with a small body. Check for both or your “success” handler will report failures for working commands.
- iPhone volume control via API:
VOLUME_CONTROL_DISALLOW. iOS handles volume at the system level. Spotify’s API can’t override it. Desktop works fine. Not your bug. - Breadboard debugging: If your encoder “stops working” intermittently during a prototype session, wiggle the wires before rewriting your state machine. Ask us how we know.
Full debugging war stories in the retros: Sprint 0 · Sprint 1 · Sprint 2 · Sprint 3 · Sprint 4 · Sprint 4.5 · Sprint 5 · Sprint 6 · Sprint 7
Built with love, an ESP32, and mass too many pio run -t upload cycles. ꒰ᐢ⸝⸝•‧̫•⸝⸝ᐢ꒱