Spotify Macroboard

Building a custom Spotify macro keyboard.

Updated April 28, 2026
#software#hardware

Designing a Custom Spotify Macro Keyboard

I built a small macro keyboard for Spotify.

This was one of those projects that started from a pretty small annoyance. I use compact keyboards a lot, and I didn't really like tabbing into Spotify just to pause a song, change volume, or skip something. I also wanted an excuse to make something that combined a PCB, a 3D printed case, an OLED display, RGB lighting, and some API work.

In the end, it became a tiny Spotify controller with mechanical switches, lights that change based on the album art, and a small display showing the current song.

Was this necessary? Probably not. Was it fun? Definitely.

Overview

The general idea was simple. I wanted the keyboard to be able to:

  • change the RGB lighting based on the current album art
  • connect wirelessly using an ESP32
  • show the current song, volume, and progress on an OLED display
  • control shuffle, repeat, volume, play/pause, skip, and back

It ended up being a pretty good mix of hardware and software. I had to design the PCB, print the case, solder everything, write the ESP32 code, and make a small server so the board could talk to Spotify without doing too much work itself.

Finished Product

Research

Before I could actually start building it, I had to figure out what parts made sense. I knew I wanted a few main things:

  1. RGB lighting that roughly matched the album art
  2. a small display for the current song
  3. physical buttons for the things I actually use in Spotify

Dynamic RGB Lighting

At first I looked at regular RGB LEDs, but they needed three separate control pins and a ground pin. That technically works, but it is annoying when you are trying to keep the board small and clean.

I eventually found WS2812B addressable LED strips, which were a much better fit. They only need one data pin, and each LED can be controlled separately.

This meant I could do simple effects like fading between colours without wasting a bunch of GPIO pins.

Early trials and testing with the lights went well.

Choosing a Display

I also thought about using an LCD since I had used them in labs before. The problem was that the ones I had seen could use up to 14 pins, which felt like a ridiculous amount for a tiny music controller.

The better option was a 0.96 inch SSD1306 OLED display. It uses I2C, so it only needs two data pins, and the 128x64 resolution was enough for song info, volume, and a progress bar.

This was also how I properly learned about I2C. I had heard about it before, but this was the first time I actually had a reason to care about why it was useful.

Mechanical Switches

For the switches, I used blue mechanical switches.

They are loud and clicky, which is either great or awful depending on who you ask. For this project, I liked them. Since mechanical switches are also very standardized, it made the PCB and keycap design much easier.

I kept the layout pretty simple because I did not want to make alignment harder than it needed to be.

Overcoming Memory and Storage Limits

The hardest part was the album art colour.

My original idea was to have the board look at the current album art, find a dominant colour, and set the LEDs to that. This sounds simple until you remember that tiny microcontrollers are not exactly built for image processing.

I first thought about using an Arduino R3, but that was clearly not going to work:

  1. Even a 160x160 image is already around 75 KB if you store RGB values.
  2. The Arduino R3 only has 32 KB of flash memory.
  3. It also does not have Wi-Fi, which is kind of important for Spotify.

Using the ESP32 Dev Module 1

So I switched to an ESP32 Dev Module 1.

It had Wi-Fi, Bluetooth, more storage, and was still small enough to fit inside the case. It was a pretty clear choice.

ESP32 Dev Module 1.

Even then, I still did not want the ESP32 processing images directly. It probably could have handled some version of it, but it would have been slow and annoying, and I already had a server I could use.

Creating a REST API

So I made a small REST API to do the heavier work.

The ESP32 asks my server what is currently playing, and the server talks to Spotify, gets the album art, calculates a colour, and sends back a small JSON response.

This made the board much simpler. Instead of asking the ESP32 to do everything, it just had to make a request, parse a few values, and update the display and LEDs.

Designing the Hardware

Custom PCB Design

I designed the PCB in EasyEDA. This was my first time making a board that was more than just a few basic connections, so there was a lot of trial and error.

The main things I had to think about were:

  1. which ESP32 pins could actually be used
  2. how to route everything without making the board ugly
  3. how the switches, display, LEDs, and ESP32 would physically fit together

Early and final PCB design image.

Later iteration

Unfortunately, I did make a pretty important mistake here. I used a pin that could not be used as a normal GPIO pin under certain conditions, so I had to go back and revise the design one more time.

Final PCB design

The final PCB looked pretty good in my opinion. The different colours represented the different layers of the board, which is sort of similar to how layers work in Photoshop. Pink was the outline, yellow was the silkscreen, and red and blue were the two copper layers.

Final Design

After that, I checked the 3D preview, ordered the PCB, and waited for it to show up.

3D model

Final in person

Building the Physical Keyboard

The physical build was mostly what you would expect:

  1. I designed and 3D printed the case and keycaps.
  2. I soldered the switches, display, LEDs, and headers.
  3. I mounted the ESP32 and tried to fit everything cleanly into the case.

This part was fun, but also a little stressful because one bad solder joint or one measurement being slightly off can make the whole thing feel cursed.

Software Implementation

The code for this project was pretty long, so I'll split it into a few parts instead of dumping everything at once.

Simple Definitions File

I started with a definitions file for all the things I needed throughout the project:

  • Wi-Fi credentials
  • the server certificate
  • pin definitions
  • small bitmaps for the OLED icons
const String PASSWORD = "REDACTED";
const char SSID[] = "REDACTED";
const char SSID_PASS[] = "REDACTED";
const char *benzServerCert =
    "-----BEGIN CERTIFICATE-----\n"
    "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n"
    // This part keeps on going for a while
    "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n"
    "-----END CERTIFICATE-----";
#define RGB_PIN 18
// ... This also goes on for a while
#define SDA 21
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
const unsigned char wifi_2[] PROGMEM = {
    0xff, 0xff, 0xff, 0xff, 0xf0, 0x0f, 0xc1, 0x83, 0x9f, 0xf1, 0x38,
    0x1c, 0xe1, 0x87, 0xcf, 0xf3, 0xd8, 0x1b, 0xf1, 0x8f, 0xf3, 0xcf,
    0xfe, 0x7f, 0xfc, 0x3f, 0xfe, 0x7f, 0xff, 0xff, 0xff, 0xff};

Core ESP32 Program

The main ESP32 program handled most of the actual device behaviour:

  • reading button presses
  • updating the LEDs
  • making requests to my server
  • parsing the response
  • drawing everything on the OLED

Imports

To start, I imported the libraries I needed:

#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <FastLED.h>
#include <SMBCredentials.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <Wire.h>

Global Variables

Then I defined a bunch of global variables. This is not the prettiest thing in the world, but for a small embedded project it made the code much easier to manage.

WiFiClientSecure wifiClient;
TwoWire I2C = TwoWire(0);
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &I2C, OLED_RESET);
const int keys[7] = {SHUFFLE, VOLUME_DEC, VOLUME_INC, LOOP,
                     BACK,    PAUSE_PLAY, SKIP};
bool keyPrevState[7] = {HIGH, HIGH, HIGH, HIGH, HIGH, HIGH, HIGH};
bool keyState[7] = {HIGH, HIGH, HIGH, HIGH, HIGH, HIGH, HIGH};
const unsigned long RSSIDelay = 10000;
const unsigned long currentDelay = 5000;
const unsigned long timeDelay = 1000;
const unsigned long fadeDelay = 4;
unsigned long nextRSSICheck, nextCurrentCheck, nextTimeCheck, nextFade;
String title, artist, album, color, durationRaw, progressRaw, pausedRaw,
    volumeRaw, lastTitle;
int progress, duration, volume, lastVolume;
bool paused;
int rssi = 0;
CRGB LEDs[RGB_LED_NUM];
int red, blue;
int green = 255;
bool shouldFade = false;
int prevRed, prevGreen, prevBlue;
int step;

Setup Function

In the setup function, I initialized the pins, LEDs, and OLED display. Some of the button pins used pullups so the values would not float around randomly.

I also added a small delay before starting the display. If the display failed to initialize, the LEDs turn orange and the program stops there. Not fancy, but good enough to tell me something was wrong.

// The setup function contents
for (int i = 0; i < 7; i++) {
    pinMode(keys[i], INPUT_PULLUP);
}
pinMode(LED, OUTPUT);
pinMode(SCL, INPUT_PULLUP);
pinMode(SDA, INPUT_PULLUP);
delay(2000);
FastLED.addLeds<CHIP_SET, LED, COLOR_CODE>(LEDs, RGB_LED_NUM);
FastLED.setBrightness(BRIGHTNESS);
FastLED.setMaxPowerInVoltsAndMilliamps(5, 500);
for (int i = 0; i < RGB_LED_NUM; i++) LEDs[i] = CRGB::White;
FastLED.show();
I2C.begin(SDA, SCL, 400000);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    for (int i = 0; i < RGB_LED_NUM; i++) {
        LEDs[i] = CRGB::OrangeRed;
    }
    FastLED.show();
    for (;;) {
        ;
    }
}
delay(2000);
display.clearDisplay();

After that, the ESP32 connects to Wi-Fi. This sometimes takes a bit, so while it is connecting the LEDs do a breathing animation. Once it connects, the LEDs fade from white to green.

For the breathing effect, I used a sine wave. The exact math was not too important, so I mostly adjusted the numbers until it looked smooth enough.

WiFi.mode(WIFI_STA);
WiFi.begin(SSID, SSID_PASS);
while (WiFi.status() != WL_CONNECTED) {
    for (int times = 0; times < 20; times++) {
        for (int i = 0; i < RGB_LED_NUM; i++) {
            byte brightness = 140 + 110 * sin(millis() / 250.0);
            LEDs[i] = CRGB::White;
            LEDs[i].fadeToBlackBy(255 - brightness);
        }
        delay(25);
        FastLED.show();
    }
}
for (int step = 0; step < 256; step++) {
    for (int i = 0; i < RGB_LED_NUM; i++) {
        LEDs[i] = blend(CRGB::White, CRGB::Green, step);
    }
    delay(3);
    FastLED.show();
}
wifiClient.setCACert(benzServerCert);

Sinusoidal wave

Updating the Display

This function updates the OLED display with the current song, artist, progress bar, Wi-Fi icon, and volume icon.

This took more trial and error than I expected. Working with such a small screen means every pixel matters, and I had to manually draw or clear a lot of small areas so the display would not look broken. I also learned a bit about RSSI, which I used to show a rough Wi-Fi strength indicator.

display.setCursor(0, 0);
display.setTextColor(WHITE);
display.setTextWrap(false);
display.setTextSize(2);
for (int i = 0; i < 2; i++) {
    for (int j = 0; j < 16; j++) {
        display.drawPixel(i, j, WHITE);
    }
}
for (int i = 2; i < 18; i++) {
    for (int j = 0; j < 16; j++) {
        display.drawPixel(i, j, BLACK);
    }
}
for (int i = 18; i < 64; i++) {
    for (int j = 0; j < 16; j++) {
        display.drawPixel(i, j, WHITE);
    }
}
if (rssi == 0) {
    display.drawBitmap(2, 0, wifi_0, 16, 16, WHITE);
} else if (rssi < -70) {
    display.drawBitmap(2, 0, wifi_1, 16, 16, WHITE);
} else {
    display.drawBitmap(2, 0, wifi_2, 16, 16, WHITE);
}
for (int i = 64; i < 108; i++) {
    for (int j = 0; j < 16; j++) {
        display.drawPixel(i, j, WHITE);
    }
}
for (int i = 108; i < 124; i++) {
    for (int j = 0; j < 16; j++) {
        display.drawPixel(i, j, BLACK);
    }
}
if (volume > 66) {
    display.drawBitmap(108, 0, volume_3, 16, 16, WHITE);
} else if (volume > 33) {
    display.drawBitmap(108, 0, volume_2, 16, 16, WHITE);
} else if (volume > 0) {
    display.drawBitmap(108, 0, volume_1, 16, 16, WHITE);
} else {
    display.drawBitmap(108, 0, volume_0, 16, 16, WHITE);
}
for (int i = 124; i < 128; i++) {
    for (int j = 0; j < 16; j++) {
        display.drawPixel(i, j, WHITE);
    }
}
display.display();

Extract Value Function

This function extracts a value from a JSON string given a key.

Normally I would just use a proper JSON library, but on the ESP32 I wanted to keep things light. The response from my server was very predictable, so this small helper was enough.

void extractValue(const String& key, const String& json, String& result) {
    String quoteKey = "\"" + key + "\":";
    int start = json.indexOf(quoteKey);
    if (start != -1) {
        int end = json.indexOf(",", start + quoteKey.length());
        if (end == -1) {
            end = json.indexOf("}", start + quoteKey.length());
        }
        result = json.substring(start + quoteKey.length(), end);
        result.trim();
        result.remove(0, 1);
        result.remove(result.length() - 1);
    }
}

Sample JSON

Update State Function

This function sends an action to my server whenever I press a button.

I routed this through my own server instead of making the ESP32 talk directly to Spotify. That way the ESP32 could just send a simple request, and the server could handle the different Spotify methods like GET, PUT, and POST.

void updateState(char action, int subAction = 0) {
    String actionString = "";
    if (action == 'p') {
        actionString = "playPause";
    } else if (action == 'b') {
        actionString = "back";
    } else if (action == 's') {
        actionString = "skip";
    } else if (action == 'r') {
        actionString = "loop";
    } else if (action == 'v') {
        if (subAction == 0) {
            actionString = "vdec";
        } else {
            actionString = "vinc";
        }
    } else if (action == 'l') {
        actionString = "loop";
    } else if (action == 'f') {
        actionString = "shuffle";
    }
    if (!wifiClient.connected()) {
        if (!wifiClient.connect("benzhou.tech", 443)) {
            for (int i = 0; i < RGB_LED_NUM; i++) LEDs[i] = CRGB::Red;
            FastLED.show();
            return;
        }
        yield();
    }
    wifiClient.print("GET /api/manageState/" + PASSWORD + "/" + actionString +
                        " HTTP/1.1\r\n" + "Host: benzhou.tech\r\n" +
                        "Connection: Keep-Alive\r\n\r\n");
    wifiClient.flush();
}

Update Current Function

This function checks what is currently playing.

It first makes sure the connection is still alive. If something goes wrong, the LEDs change colour so I can tell what failed without needing to plug it into my computer.

After that, it asks the server for the current song, parses the JSON response, and updates the screen.

The last part checks if the song changed. If it did, the board updates the text and starts fading the LEDs to the new album colour. The function is long and a bit tedious, but the idea itself is pretty straightforward.

if (!wifiClient.connected()) {
    if (!wifiClient.connect("benzhou.tech", 443)) {
        for (int i = 0; i < RGB_LED_NUM; i++) LEDs[i] = CRGB::Red;
        FastLED.show();
        return;
    }
    yield();
}
wifiClient.print("GET /api/getCurrent/" + PASSWORD + " HTTP/1.1\r\n" +
                    "Host: benzhou.tech\r\n" +
                    "Connection: Keep-Alive\r\n\r\n");
unsigned long timeout = millis();
while (!wifiClient.available()) {
    if (millis() - timeout > 5000) {
        for (int i = 0; i < RGB_LED_NUM; i++) LEDs[i] = CRGB::Yellow;
        FastLED.show();
        return;
    }
}
yield();
char endOfHeaders[] = "\r\n\r\n";
if (!wifiClient.find(endOfHeaders)) {
    for (int i = 0; i < RGB_LED_NUM; i++) LEDs[i] = CRGB::Orange;
    FastLED.show();
    return;
}
wifiClient.find("{\"ti");
String response = "{\"ti";
while (wifiClient.available()) {
    char c = wifiClient.read();
    response += c;
}
wifiClient.flush();
lastTitle = title;
extractValue("title", response, title);
extractValue("artist", response, artist);
extractValue("album", response, album);
extractValue("duration", response, durationRaw);
extractValue("progress", response, progressRaw);
extractValue("paused", response, pausedRaw);
extractValue("volume", response, volumeRaw);
paused = pausedRaw == "true";
lastVolume = volume;
volume = volumeRaw.toInt();
duration = durationRaw.toInt();
progress = progressRaw.toInt();
if (title != lastTitle) {
    color = response.substring(response.indexOf("[") + 1,
                                response.indexOf("]"));
    int comma1 = color.indexOf(",");
    int comma2 = color.indexOf(",", comma1 + 1);
    shouldFade = true;
    prevRed = red;
    prevGreen = green;
    prevBlue = blue;
    red = color.substring(0, comma1 + 1).toInt();
    green = color.substring(comma1 + 1, comma2 + 1).toInt();
    blue = color.substring(comma2 + 1, color.length()).toInt();
    display.setTextColor(WHITE);
    display.setTextWrap(false);
    display.clearDisplay();
    updateScreen();
    display.setCursor(0, 16);
    display.setTextSize(2);
    display.println(title);
    display.setTextSize(1);
    display.println(artist);
}
display.display();

Fade LED Function

This function fades the LEDs from the previous colour to the new one.

I originally considered just switching colours instantly, but it looked a lot worse. A quick fade made the whole thing feel much more polished.

bool fadeLED() {
    if (step > 255) {
        step = 0;
        return false;
    }
    for (int i = 0; i < RGB_LED_NUM; i++) {
        LEDs[i] = blend(CRGB(prevRed, prevGreen, prevBlue),
                        CRGB(red, green, blue), step);
    }
    FastLED.show();
    step += 1;
    return true;
}

Update Time Function

This function updates the progress bar.

It calculates how much of the song has played, draws the filled part of the bar, and prints the current time beside the total duration. Again, pretty simple in theory, but it took a bit to get the display layout looking right.

void updateTime(int total, int current) {
    display.fillRect(0, 48, 128, 16, BLACK);
    if (current > total) {
        current = total;
    }
    int barWidth = 100;
    int barHeight = 6;
    int barX = 14;
    int barY = 56;
    display.drawRect(barX, barY, barWidth, barHeight, WHITE);
    if (total <= 0) {
        total = 1;
    }
    int percent = (100 * current) / total;
    int progressWidth = (barWidth * percent) / 100;
    display.fillRect(barX, barY, progressWidth, barHeight, WHITE);
    int currentMinutes = current / 60;
    int currentSeconds = current % 60;
    int totalMinutes = total / 60;
    int totalSeconds = total % 60;
    display.setTextSize(1);
    display.setCursor(32, 48);
    display.print(currentMinutes);
    display.print(":");
    if (currentSeconds < 10) display.print("0");
    display.print(currentSeconds);
    display.print("/");
    display.print(totalMinutes);
    display.print(":");
    if (totalSeconds < 10) display.print("0");
    display.println(totalSeconds);
    display.display();
}

State Functions

This part maps each physical button to a function.

Each button just calls updateState with a different action. Some of them also force the board to refresh sooner, since skipping or pausing should update the display almost immediately.

void shuffle() { updateState('f'); }
void volumeDecrease() {
    updateState('v', 0);
    updateScreen();
}
void volumeIncrease() {
    updateState('v', 1);
    updateScreen();
}
void repeat() { updateState('r'); }
void back() {
    updateState('b');
    nextCurrentCheck = millis() + 100;
}
void pausePlay() {
    updateState('p');
    nextCurrentCheck = millis() + 100;
}
void skip() {
    updateState('s');
    nextCurrentCheck = millis() + 100;
    updateCurrent();
}
void (*funcs[7])() = {
    shuffle, volumeDecrease, volumeIncrease, repeat, back, pausePlay, skip};

This on its own does not do anything yet. That happens in the loop function.

Loop Function

The loop function runs forever and checks everything on a schedule.

It reads button presses, checks the Wi-Fi signal, asks for the current song every few seconds, updates the progress bar every second, and fades the LEDs when needed.

for (int i = 0; i < 7; i++) {
    keyState[i] = digitalRead(keys[i]);
    if (keyState[i] == LOW && keyPrevState[i] == HIGH) {
        funcs[i]();
    }
    keyPrevState[i] = keyState[i];
}
if (millis() > nextRSSICheck) {
    nextRSSICheck = millis() + RSSIDelay;
    rssi = WiFi.RSSI();
    updateScreen();
}
if (millis() > nextCurrentCheck) {
    nextCurrentCheck = millis() + currentDelay;
    updateCurrent();
}
if (millis() > nextTimeCheck) {
    nextTimeCheck = millis() + timeDelay;
    updateTime(duration, progress);
    if (!paused) {
        progress++;
    }
}
if (shouldFade && millis() > nextFade) {
    nextFade = millis() + fadeDelay;
    shouldFade = fadeLED();
}

REST API for Offloading Heavy Tasks

The ESP32 could handle the buttons and display, but I did not want it dealing with Spotify authentication or album art processing.

So I hosted a small API on my server. It did three main things:

  1. get the dominant colour from album art
  2. return a clean JSON response for the ESP32
  3. send playback commands to Spotify

This made the ESP32 code much less painful. The board did not need to know much about Spotify, it just needed to know how to ask my server for information.

The API had three main routes and one small utility function.

Get Color

This route gets the dominant colour of the album art.

It fetches the album art from Spotify's image CDN, gets the dominant colour, and then snaps that colour to the closest one in a small list of clean RGB values. I did this because the true dominant colour can sometimes be kind of muddy, and muddy colours do not look great on LEDs.

import type { NextApiRequest, NextApiResponse } from "next";
import { getColorFromURL } from "color-thief-node";

type Data = {
    answer: number[];
};

function findNearestColor(rgbArray: number[]): number[] {
    const colors: number[][] = [
        [255, 0, 0],
        [255, 125, 0],
        [255, 255, 0],
        [125, 255, 0],
        [0, 255, 0],
        [0, 255, 125],
        [0, 255, 255],
        [0, 125, 255],
        [0, 0, 255],
        [125, 0, 255],
        [255, 0, 255],
        [255, 0, 125],
    ];

    let minDistance: number = Infinity;
    let closestColor: number[] = [];
    colors.forEach((color: number[]) => {
        const distance: number = Math.sqrt(
            Math.pow(rgbArray[0] - color[0], 2) +
                Math.pow(rgbArray[1] - color[1], 2) +
                Math.pow(rgbArray[2] - color[2], 2)
        );
        if (distance < minDistance) {
            minDistance = distance;
            closestColor = color;
        }
    });
    return closestColor;
}

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse<Data>
) {
    const hash = req.query.hash as string;
    const url = `https://i.scdn.co/image/${hash}`;
    let colors = await getColorFromURL(url);
    const color = findNearestColor(colors);
    res.status(200).json({ answer: color });
}

The colour matching uses the distance between two RGB values. It checks every colour in my list and returns whichever one is closest.

Get Current

This route gets the current song and formats the data for the ESP32.

It asks Spotify what is playing, gets the title, artist, album, duration, progress, paused state, volume, shuffle state, and repeat state. It also calls the colour route so the ESP32 can update the LEDs at the same time.

import { NextApiRequest, NextApiResponse } from "next";
import getSpotifyAccessToken from "@/utils/functions/getSpotify";
type ESPInfo = {
    title: string;
    artist: string;
    album: string;
    color: [number, number, number];
    duration: string;
    progress: string;
    paused: string;
    volume: string;
    shuffle: boolean;
    loop: string;
}
type Error = {
    error: string;
}
export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse<ESPInfo | Error>
) {
    const { password } = req.query;
    if (password !== process.env.PASSWORD) {
        res.status(401).json({ error: "Unauthorized" });
        return;
    }
    try {
        const accessToken = await getSpotifyAccessToken();
        const response = await fetch(
            `https://api.spotify.com/v1/me/player`,
            {
                headers: {
                    Authorization: `Bearer ${accessToken}`,
                },
            }
        );
        if (response.ok) {
            const current = await response.json();
            const dominantColor = await fetch(`https://bzhou.ca/api/getColor/${current.item.album.images[0].url.split("/")[4]}`).then(res => res.json());
            console.log(current);
            res.status(200).json({
                title: current.item.name,
                artist: current.item.artists[0].name,
                album: current.item.album.name,
                color: dominantColor.answer,
                duration: String(Math.round(current.item.duration_ms / 1000)),
                progress: String(Math.round(current.progress_ms / 1000)),
                paused: String(!current.is_playing),
                volume: String(current.device.volume_percent),
                shuffle: current.shuffle_state,
                loop: current.repeat_state
            });
        } else {
            const errorMessage = await response.text();
            res.status(response.status).json({ error: errorMessage });
        }
    } catch (error) {
        res.status(500).json({ error: "Internal Server Error" });
        console.log(error);
    }
}

This route is mostly just cleaning up Spotify's response. Spotify gives you a lot of data, but the board only needs a small part of it.

Manage State

This route handles button actions.

The ESP32 sends one of a few simple actions like skip, back, vinc, or shuffle, and the server turns that into the correct Spotify request.

import { NextApiRequest, NextApiResponse } from "next";
import getSpotifyAccessToken from "@/utils/functions/getSpotify";
type ESPInfo = {
    answer: string;
};
type Error = {
    error: string;
};
const changes = ["playPause", "skip", "back", "vinc", "vdec", "loop", "shuffle"];

async function getPlayerData(res: NextApiResponse, accessToken: string) {
    const response = await fetch(
        `https://api.spotify.com/v1/me/player`,
        {
            headers: {
                Authorization: `Bearer ${accessToken}`,
            },
        }
    );
    if (response.ok) {
        const responseData = await response.json();
        return responseData;
    }
    else {
        res.status(response.status).json({
            error: "Error fetching player status",
        });
        return;
    }
}

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse<ESPInfo | Error>
) {
    const { password, change } = req.query;
    if (password !== process.env.PASSWORD) {
        res.status(401).json({ error: "Unauthorized" });
        return;
    }
    if (!changes.includes(change as string)) {
        res.status(400).json({ error: "Invalid change" });
        return;
        }
    try {
        const accessToken = await getSpotifyAccessToken();
        let url = "";
        let metho = "";
        if (change === "playPause") {
            const responseData = await getPlayerData(res, accessToken);
            if (!responseData.is_playing) {
                url = "https://api.spotify.com/v1/me/player/play";
                metho = "PUT";
            } else {
                url = "https://api.spotify.com/v1/me/player/pause";
                metho = "PUT";
            }
        } else if (change === "skip") {
            url = `https://api.spotify.com/v1/me/player/next`;
            metho = "POST";
        } else if (change === "back") {
            url = `https://api.spotify.com/v1/me/player/previous`;
            metho = "POST";
        }
        else if (change === "vinc" || change === "vdec") {
            const responseData = await getPlayerData(res, accessToken);
            let volume = responseData.device.volume_percent;
            if (change === "vinc") {
                volume += 10;
                volume = Math.min(volume, 100);
            } else {
                volume -= 10;
                volume = Math.max(volume, 0);
            }
            url = `https://api.spotify.com/v1/me/player/volume?volume_percent=${volume}`;
            metho = "PUT";
        } else if (change === "loop") {
            const responseData = await getPlayerData(res, accessToken);
            let state = responseData.repeat_state;
            if (state === "track") {
                state = "context";
            } else if (state === "context") {
                state = "off";
            } else {
                state = "track";
            }
            url = `https://api.spotify.com/v1/me/player/repeat?state=${state}`;
            metho = "PUT"
        } else if (change === "shuffle") {
            const responseData = await getPlayerData(res, accessToken);
            let shuffle = responseData.shuffle_state;
            url = `https://api.spotify.com/v1/me/player/shuffle?state=${!shuffle}`;
            metho = "PUT";
        }

        await fetch(url, {
            method: metho,
            headers: {
                Authorization: `Bearer ${accessToken}`,
            },
        });

        res.status(200).json({ answer: "Success" });
    } catch (error) {
        res.status(500).json({ error: "Internal Server Error" });
        console.log(error);
    }
}

This was a bit more annoying than I expected because Spotify does not always have a simple "toggle this" endpoint.

For example, repeat has three states: repeat track, repeat context, and off. So I made it rotate through those states the same way the app does. Shuffle was a bit simpler, but I still had to check the current state first.

Spotify Access Token

This utility function gets a Spotify access token.

Spotify access tokens expire, so the server uses a refresh token to get a new one whenever needed. It also caches the token until it expires, so I am not asking Spotify for a new token on every single request.

let accessToken = "";
let tokenExpiration = 0;

export default async function getSpotifyAccessToken() {
    const { SPOTIFY_CLIENTID, SPOTIFY_SECRET, SPOTIFY_REFRESHTOKEN } =
        process.env;

    if (Date.now() < tokenExpiration) {
        return accessToken;
    }

    const authString = Buffer.from(
        `${SPOTIFY_CLIENTID}:${SPOTIFY_SECRET}`
    ).toString("base64");

    const tokenResponse = await fetch(
        "https://accounts.spotify.com/api/token",
        {
            method: "POST",
            headers: {
                Authorization: `Basic ${authString}`,
                "Content-Type": "application/x-www-form-urlencoded",
            },
            body: new URLSearchParams({
                grant_type: "refresh_token",
                refresh_token: SPOTIFY_REFRESHTOKEN || "",
            }),
        }
    );

    const tokenData = await tokenResponse.json();

    accessToken = tokenData.access_token;
    tokenExpiration = Date.now() + tokenData.expires_in * 1000;

    return accessToken;
};

Conclusion

This was a really fun project.

It was one of the first projects where I felt like I was actually combining a bunch of different things I cared about: hardware, software, APIs, PCB design, soldering, and 3D printing. It was also semi-useful, which is always a nice bonus.

I wanted to write about it because this project was one of the reasons I decided to pursue computer engineering. It made the connection between software and hardware feel a lot more real to me, and even though there are definitely things I would do differently now, I am still pretty happy with how it turned out.

Final Product