◀ esp32-spotify-display

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

ComponentModelRole
MicrocontrollerLilyGo T-Display ESP32Brains + screen
DisplayST7789 135x240The face
InputEC11 Rotary EncoderRotate, click, long-press
Status2x bare LEDs (red + green)Subtle indicators
PowerAny USB-C sourcePhone 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.

SprintGoalStatus
0Hardware verification (screen, encoder, LEDs)Done
1WiFi, NTP & HTTPSDone
2Spotify OAuth2 token flowDone
3Spotify API client (9 endpoints, polling, commands)Done
4LVGL now-playing screen (live data, progress interpolation)Done
4.5Async HTTP on Core 0 (butter-smooth rendering)Done
5Encoder input & playback control (click, double-click, volume arc)Done
6Album art (JPEG decode + purple dithering + loading wave)Done
7Animations & transitions (label fades, border pulse, progress interp)Done
8Menu system (iPod-style)Done
9Idle screen & error resilienceDone
10LED integrationPlanned
11Polish & optimizationPlanned

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 + one setCACertBundle() call and your ESP32 trusts the same CAs your browser does. No downloading certs, no PEM strings.
  • Shared WiFiClientSecure across hosts: secureClient.stop() alone isn’t enough when there’s idle time between requests. You need http.setReuse(false) on every HTTPClient to force Connection: close and proper teardown. Without it, stale TLS state hangs the next handshake. The (-76) UNKNOWN ERROR CODE in serial is cosmetic and unfixable. Every ESP32 Spotify project has it.
  • Spotify’s “active” vs “playing”: /v1/me/player returns 200 even when nothing is playing, as long as a device is recently active. 204 only when no device exists at all. Check is_playing in the JSON, don’t just check the status code.
  • LVGL 9.x + PlatformIO: Put lv_conf.h in include/ with -DLV_CONF_INCLUDE_SIMPLE -Iinclude build flags. Use LV_USE_TFT_ESPI 0 if 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_app partition 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_conv with Latin-1 Supplement (U+00A0-U+00FF) and Latin Extended-A (U+0100-U+017F). Don’t forget to include the FontAwesome codepoints for LV_SYMBOL_PLAY and 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 send Content-Length: 0 automatically. 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. ꒰ᐢ⸝⸝•‧̫•⸝⸝ᐢ꒱