Macro Keyboard

Controlling music seamlessly while maintaining aesthetics and functionality can be a challenge—especially when using compact keyboards. This inspired me to create a custom Spotify Macro Keyboard that combines functionality, dynamic lighting, and modern design principles.

Posted: December 14, 2024 - Last Updated: December 14, 2024 - Tags:

Designing a Custom Spotify Macro Keyboard

Controlling music seamlessly while maintaining aesthetics and functionality can be a challenge—especially when using compact keyboards. This inspired me to create a custom Spotify Macro Keyboard that combines functionality, dynamic lighting, and modern design principles.

With satisfying clicky mechanical switches, vibrant RGB lighting, and a smart OLED display, this keyboard became an essential quality-of-life upgrade for controlling my music.

Overview

The Spotify Macro Keyboard is a compact and modern custom macro keyboard equipped with features such as:

  • Dynamic RGB Lighting that matches album art colors.
  • Fully Wireless Connectivity using an ESP32 microcontroller.
  • OLED Display to show the current song, volume, and controls.

This project combined hardware design, coding, and API integration to bring together a functional and aesthetically pleasing device.

Finished Product

Research

There were many things I had to research before creating this Spotify Macro Keyboard. I started by considering the base functions I wanted my keyboard to have:

  1. Custom RGB lighting that matches the color of the music album art.
  2. A compact OLED display to show the current song or what’s playing next.
  3. Full control over the music player, such as shuffle, loop, and volume, without needing to open the app.

Dynamic RGB Lighting

Initially, I looked at RGB LEDs available to me, but they required three separate pins plus a ground pin. While they could display colors, controlling them precisely and fitting them neatly into a case was challenging.

After further research, I discovered the WS2812B addressable LED strips. These LEDs:

  • Offer precise, per-LED control with millisecond accuracy.
  • Require only 3.3V power, making them compatible with the ESP32.
  • Use minimal pins while still enabling dynamic lighting effects like fades or waves.

Early trials and testing with the lights went well.

Choosing a Display

I initially considered Liquid Crystal Displays (LCDs), which we used in labs. However, they required up to 14 pins, which was inefficient given the number of components I needed to connect.

I found a better alternative in the SSD1306 0.96-inch OLED display, which had:

  • Simple I2C communication, requiring only 2 data pins.
  • A resolution of 128x64, sufficient to display 8 lines of data.
  • Functions to adjust each pixel for custom graphics.

This choice introduced me to the I2C protocol, a highly efficient way to connect multiple devices using minimal pins.

Mechanical Switches

For a satisfying tactile experience, I selected blue mechanical switches. These switches:

  • Provide a noticeable click sound, adding to the user experience.
  • Are standardized for easy integration into the PCB and for 3D-printed keycaps.

I kept the design straightforward to ensure optimal alignment when integrating switches into the custom PCB.

Overcoming Memory & Storage Limits

The biggest challenge was determining the album art color for dynamic lighting. Processing images on an Arduino R3 wasn’t feasible because:

  1. Scaled-down album art (e.g., 160x160 pixels) still requires significant storage:
    • Each pixel uses 3 values (Red, Green, Blue), each with 8 bits.
    • Total: 160x160x3 = 76,800 bytes or ~75 KB.
  2. The Arduino R3 has only 32 KB of flash memory—far too small to store or process images.
  3. The Arduino lacked Wi-Fi or Bluetooth capabilities to fetch image data wirelessly.

Introducing the ESP32 Dev Module 1

To address these limitations, I chose the ESP32 Dev Module 1, which offered:

  • 2.4 GHz Wi-Fi and full Bluetooth support.
  • 320 KB storage, significantly larger than the Arduino R3.
  • A compact design, saving space and allowing for a smaller case.

ESP32 Dev Module 1.

However, even the ESP32 struggled to process album images directly due to storage constraints. Moving large amounts of data on such a small processor would slow down operations.

Creating a REST API

To overcome this, I built a REST API that offloaded heavy computation to a server:

  1. The API processes album images and calculates the dominant color.
  2. The ESP32 makes GET requests to fetch processed results.
  3. This approach reduced the average API request time from 3 seconds to 0.2 seconds—over 15x faster.

This solution not only solved the memory issue but also provided flexibility to add new features later.

Designing the Hardware

Custom PCB Design

I designed a custom PCB using EasyEDA, starting with basic layouts and gradually refining the design. Here’s was my general process:

  1. Component Planning: Ensured all connections aligned with ESP32-compatible pins.
  2. Iterative Design: Tested and updated connections to avoid GPIO pin conflicts.
  3. Routing: Optimized the layout to maintain neat and efficient connections.

Early and final PCB design image.

Later iteration

Unfortunately, I made a crucial error here, making use of a pin that could not be used as a standard GPIO pin under certain conditions and so I had to go back and quickly revise this final design one last time.

Final PCB design

The final PCB looked pretty good in my opinion, the many different colours of the outlines represented the layer on which each component would be. This is similar to how photoshop works, here, pink was the outline, yellow was the silk layer, and red and blue were the first and second layer respectively.

Final Design

The final design could then be viewed in 3D and I had it printed and shipped over!

3D model

Final in person

Building the Physical Keyboard

  1. 3D-Printed Case & Keycaps: Designed and printed the case and translucent keycaps to house all components.
  2. Soldering Components: Carefully soldered the LEDs, display, and mechanical switches onto the PCB.
  3. Assembly: Wired the LED strips, mounted the ESP32, and secured everything within the case.

Software Implementation

My code for this project was decently long and so to describe and explain the general idea, I've split it into 3 distinct parts.

Simple Definitions File

I started by defining all constants, credentials, and bitmaps in a separate file. This file contained:

  • Wi-Fi credentials for the ESP32 to connect to the network.
  • API Server Certs for fetching song data and album art.
  • Pin Definitions for buttons, LEDs, and the OLED display.
  • Bitmaps for OLED display icons and progress bar.
1
const String PASSWORD = "REDACTED";
2
const char SSID[] = "REDACTED";
3
const char SSID_PASS[] = "REDACTED";
4
const char *benzServerCert =
5
"-----BEGIN CERTIFICATE-----\n"
6
"MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n"
7
// This part keeps on going for a while
8
"emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n"
9
"-----END CERTIFICATE-----";
10
#define RGB_PIN 18
11
// ... This also goes on for a while
12
#define SDA 21
13
#define SCREEN_WIDTH 128
14
#define SCREEN_HEIGHT 64
15
#define OLED_RESET -1
16
const unsigned char wifi_2[] PROGMEM = {
17
0xff, 0xff, 0xff, 0xff, 0xf0, 0x0f, 0xc1, 0x83, 0x9f, 0xf1, 0x38,
18
0x1c, 0xe1, 0x87, 0xcf, 0xf3, 0xd8, 0x1b, 0xf1, 0x8f, 0xf3, 0xcf,
19
0xfe, 0x7f, 0xfc, 0x3f, 0xfe, 0x7f, 0xff, 0xff, 0xff, 0xff};

Core ESP32 Program

The main program handled button inputs, display updates, and API requests. It included:

  • Button Handling: Detecting button presses and debouncing inputs.
  • LED Control: Updating LED colors based on album art.
  • Display Updates: Parsing JSON data and updating the OLED display.

Imports

But to start, I simply imported all necessary libraries, which included:

1
#include <Adafruit_GFX.h>
2
#include <Adafruit_SSD1306.h>
3
#include <FastLED.h>
4
#include <SMBCredentials.h>
5
#include <WiFi.h>
6
#include <WiFiClientSecure.h>
7
#include <Wire.h>

Global Variables

Next I defined many variables and pieces of data that I would need access to later.

1
WiFiClientSecure wifiClient;
2
TwoWire I2C = TwoWire(0);
3
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &I2C, OLED_RESET);
4
const int keys[7] = {SHUFFLE, VOLUME_DEC, VOLUME_INC, LOOP,
5
BACK, PAUSE_PLAY, SKIP};
6
bool keyPrevState[7] = {HIGH, HIGH, HIGH, HIGH, HIGH, HIGH, HIGH};
7
bool keyState[7] = {HIGH, HIGH, HIGH, HIGH, HIGH, HIGH, HIGH};
8
const unsigned long RSSIDelay = 10000;
9
const unsigned long currentDelay = 5000;
10
const unsigned long timeDelay = 1000;
11
const unsigned long fadeDelay = 4;
12
unsigned long nextRSSICheck, nextCurrentCheck, nextTimeCheck, nextFade;
13
String title, artist, album, color, durationRaw, progressRaw, pausedRaw,
14
volumeRaw, lastTitle;
15
int progress, duration, volume, lastVolume;
16
bool paused;
17
int rssi = 0;
18
CRGB LEDs[RGB_LED_NUM];
19
int red, blue;
20
int green = 255;
21
bool shouldFade = false;
22
int prevRed, prevGreen, prevBlue;
23
int step;

Setup Function

Then, for each pin that I had defined earlier, I set them up as inputs or outputs, I set some pins to pull up to avoid floating values. I also made sure to setup the LED strip and the OLED display.

I added a delay to allow the display to initialize and then I checked if the display was initialized correctly, if not, I set all the LEDs to a bright orange and then entered an infinite loop to show that something was wrong.

1
// The setup function contents
2
for (int i = 0; i < 7; i++) {
3
pinMode(keys[i], INPUT_PULLUP);
4
}
5
pinMode(LED, OUTPUT);
6
pinMode(SCL, INPUT_PULLUP);
7
pinMode(SDA, INPUT_PULLUP);
8
delay(2000);
9
FastLED.addLeds<CHIP_SET, LED, COLOR_CODE>(LEDs, RGB_LED_NUM);
10
FastLED.setBrightness(BRIGHTNESS);
11
FastLED.setMaxPowerInVoltsAndMilliamps(5, 500);
12
for (int i = 0; i < RGB_LED_NUM; i++) LEDs[i] = CRGB::White;
13
FastLED.show();
14
I2C.begin(SDA, SCL, 400000);
15
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
16
for (int i = 0; i < RGB_LED_NUM; i++) {
17
LEDs[i] = CRGB::OrangeRed;
18
}
19
FastLED.show();
20
for (;;) {
21
;
22
}
23
}
24
delay(2000);
25
display.clearDisplay();

I then finally moved onto connecting to the WiFi network, this process does take a bit of time and doesn't always work and so I made a loop to try and connect to the network 20 times before giving up and showing a red LED. During this time, it would also show a breathing effect on the LEDs to indicate it was attempting a connection. Once connected to the network, I would then fade the LEDs from white to green to indicate a successful connection.

To ensure a smooth transition, I modelled a sinusoidal wave in the form of y = asin(bx + c) + d, where a, b, c, and d are constants. This allowed me to smoothly transition between colors with a being the amplitude (110), b being the frequency, c being the phase shift (none), and d being the vertical shift (140). Calculating the frequency was less of a concern so I just ballparked that.

1
WiFi.mode(WIFI_STA);
2
WiFi.begin(SSID, SSID_PASS);
3
while (WiFi.status() != WL_CONNECTED) {
4
for (int times = 0; times < 20; times++) {
5
for (int i = 0; i < RGB_LED_NUM; i++) {
6
byte brightness = 140 + 110 * sin(millis() / 250.0);
7
LEDs[i] = CRGB::White;
8
LEDs[i].fadeToBlackBy(255 - brightness);
9
}
10
delay(25);
11
FastLED.show();
12
}
13
}
14
for (int step = 0; step < 256; step++) {
15
for (int i = 0; i < RGB_LED_NUM; i++) {
16
LEDs[i] = blend(CRGB::White, CRGB::Green, step);
17
}
18
delay(3);
19
FastLED.show();
20
}
21
wifiClient.setCACert(benzServerCert);

Sinusoidal wave

Updating the Display

This function was simply meant to update the display with the current song, artist, and progress. It would also update the progress bar and the volume icon. This took a lot of trial and error but eventually, I got it to work. I also got to learn a bit about RSSI and how to use it to determine the strength of the WiFi signal.

1
display.setCursor(0, 0);
2
display.setTextColor(WHITE);
3
display.setTextWrap(false);
4
display.setTextSize(2);
5
for (int i = 0; i < 2; i++) {
6
for (int j = 0; j < 16; j++) {
7
display.drawPixel(i, j, WHITE);
8
}
9
}
10
for (int i = 2; i < 18; i++) {
11
for (int j = 0; j < 16; j++) {
12
display.drawPixel(i, j, BLACK);
13
}
14
}
15
for (int i = 18; i < 64; i++) {
16
for (int j = 0; j < 16; j++) {
17
display.drawPixel(i, j, WHITE);
18
}
19
}
20
if (rssi == 0) {
21
display.drawBitmap(2, 0, wifi_0, 16, 16, WHITE);
22
} else if (rssi < -70) {
23
display.drawBitmap(2, 0, wifi_1, 16, 16, WHITE);
24
} else {
25
display.drawBitmap(2, 0, wifi_2, 16, 16, WHITE);
26
}
27
for (int i = 64; i < 108; i++) {
28
for (int j = 0; j < 16; j++) {
29
display.drawPixel(i, j, WHITE);
30
}
31
}
32
for (int i = 108; i < 124; i++) {
33
for (int j = 0; j < 16; j++) {
34
display.drawPixel(i, j, BLACK);
35
}
36
}
37
if (volume > 66) {
38
display.drawBitmap(108, 0, volume_3, 16, 16, WHITE);
39
} else if (volume > 33) {
40
display.drawBitmap(108, 0, volume_2, 16, 16, WHITE);
41
} else if (volume > 0) {
42
display.drawBitmap(108, 0, volume_1, 16, 16, WHITE);
43
} else {
44
display.drawBitmap(108, 0, volume_0, 16, 16, WHITE);
45
}
46
for (int i = 124; i < 128; i++) {
47
for (int j = 0; j < 16; j++) {
48
display.drawPixel(i, j, WHITE);
49
}
50
}
51
display.display();

Extract Value Function

This function is a bit more complex but its simply meant to extract a value from a JSON string given a key. I had to make this since other libraries accounted for a lot of things I wouldn't need to, and they also took up too much memory, and runtime.

1
void extractValue(const String& key, const String& json, String& result) {
2
String quoteKey = "\"" + key + "\":";
3
int start = json.indexOf(quoteKey);
4
if (start != -1) {
5
int end = json.indexOf(",", start + quoteKey.length());
6
if (end == -1) {
7
end = json.indexOf("}", start + quoteKey.length());
8
}
9
result = json.substring(start + quoteKey.length(), end);
10
result.trim();
11
result.remove(0, 1);
12
result.remove(result.length() - 1);
13
}
14
}

Sample JSON

Update State Function

This function is meant to update the state of the music player, it would send a GET request to the server with the action and subaction, if any. It would then wait for a response and then update the state of the music player accordingly.

I made this go through my own server so I don't have to create another connection to Spotify's servers while also only making use of GET requests to keep things simple (the routes usually require GET, PUT, or POST).

1
void updateState(char action, int subAction = 0) {
2
String actionString = "";
3
if (action == 'p') {
4
actionString = "playPause";
5
} else if (action == 'b') {
6
actionString = "back";
7
} else if (action == 's') {
8
actionString = "skip";
9
} else if (action == 'r') {
10
actionString = "loop";
11
} else if (action == 'v') {
12
if (subAction == 0) {
13
actionString = "vdec";
14
} else {
15
actionString = "vinc";
16
}
17
} else if (action == 'l') {
18
actionString = "loop";
19
} else if (action == 'f') {
20
actionString = "shuffle";
21
}
22
if (!wifiClient.connected()) {
23
if (!wifiClient.connect("benzhou.tech", 443)) {
24
for (int i = 0; i < RGB_LED_NUM; i++) LEDs[i] = CRGB::Red;
25
FastLED.show();
26
return;
27
}
28
yield();
29
}
30
wifiClient.print("GET /api/manageState/" + PASSWORD + "/" + actionString +
31
" HTTP/1.1\r\n" + "Host: benzhou.tech\r\n" +
32
"Connection: Keep-Alive\r\n\r\n");
33
wifiClient.flush();
34
}

Update Current Function

This function is meant to ensure the current connection is being maintained and if not, attempt to reconnect. I made sure to include an error state where the LEDs would turn yellow if the connection was lost.

Afterwards, I would send a GET request to the server to get the current song and its details. I would then parse the JSON data and update the display accordingly.

The final part of this function is simply checking if the new song is different from the last song, if it is, then I would update the display with the new song and artist.

It is quite long and tedious, but simple in principle.

1
if (!wifiClient.connected()) {
2
if (!wifiClient.connect("benzhou.tech", 443)) {
3
for (int i = 0; i < RGB_LED_NUM; i++) LEDs[i] = CRGB::Red;
4
FastLED.show();
5
return;
6
}
7
yield();
8
}
9
wifiClient.print("GET /api/getCurrent/" + PASSWORD + " HTTP/1.1\r\n" +
10
"Host: benzhou.tech\r\n" +
11
"Connection: Keep-Alive\r\n\r\n");
12
unsigned long timeout = millis();
13
while (!wifiClient.available()) {
14
if (millis() - timeout > 5000) {
15
for (int i = 0; i < RGB_LED_NUM; i++) LEDs[i] = CRGB::Yellow;
16
FastLED.show();
17
return;
18
}
19
}
20
yield();
21
char endOfHeaders[] = "\r\n\r\n";
22
if (!wifiClient.find(endOfHeaders)) {
23
for (int i = 0; i < RGB_LED_NUM; i++) LEDs[i] = CRGB::Orange;
24
FastLED.show();
25
return;
26
}
27
wifiClient.find("{\"ti");
28
String response = "{\"ti";
29
while (wifiClient.available()) {
30
char c = wifiClient.read();
31
response += c;
32
}
33
wifiClient.flush();
34
lastTitle = title;
35
extractValue("title", response, title);
36
extractValue("artist", response, artist);
37
extractValue("album", response, album);
38
extractValue("duration", response, durationRaw);
39
extractValue("progress", response, progressRaw);
40
extractValue("paused", response, pausedRaw);
41
extractValue("volume", response, volumeRaw);
42
paused = pausedRaw == "true";
43
lastVolume = volume;
44
volume = volumeRaw.toInt();
45
duration = durationRaw.toInt();
46
progress = progressRaw.toInt();
47
if (title != lastTitle) {
48
color = response.substring(response.indexOf("[") + 1,
49
response.indexOf("]"));
50
int comma1 = color.indexOf(",");
51
int comma2 = color.indexOf(",", comma1 + 1);
52
shouldFade = true;
53
prevRed = red;
54
prevGreen = green;
55
prevBlue = blue;
56
red = color.substring(0, comma1 + 1).toInt();
57
green = color.substring(comma1 + 1, comma2 + 1).toInt();
58
blue = color.substring(comma2 + 1, color.length()).toInt();
59
display.setTextColor(WHITE);
60
display.setTextWrap(false);
61
display.clearDisplay();
62
updateScreen();
63
display.setCursor(0, 16);
64
display.setTextSize(2);
65
display.println(title);
66
display.setTextSize(1);
67
display.println(artist);
68
}
69
display.display();

Fade LED Function

This function is meant to fade the LEDs from one color to another, it would do this by calculating the difference between the current color and the target color and then incrementing or decrementing the current color by a small amount.

1
bool fadeLED() {
2
if (step > 255) {
3
step = 0;
4
return false;
5
}
6
for (int i = 0; i < RGB_LED_NUM; i++) {
7
LEDs[i] = blend(CRGB(prevRed, prevGreen, prevBlue),
8
CRGB(red, green, blue), step);
9
}
10
FastLED.show();
11
step += 1;
12
return true;
13
}

Update Time Function

This function is meant to update the progress bar on the display, it would do this by calculating the percentage of the song that has been played and then updating the progress bar accordingly. It's also a simple function in principle, though it took a bit of time to get the correct logic and math.

1
void updateTime(int total, int current) {
2
display.fillRect(0, 48, 128, 16, BLACK);
3
if (current > total) {
4
current = total;
5
}
6
int barWidth = 100;
7
int barHeight = 6;
8
int barX = 14;
9
int barY = 56;
10
display.drawRect(barX, barY, barWidth, barHeight, WHITE);
11
if (total <= 0) {
12
total = 1;
13
}
14
int percent = (100 * current) / total;
15
int progressWidth = (barWidth * percent) / 100;
16
display.fillRect(barX, barY, progressWidth, barHeight, WHITE);
17
int currentMinutes = current / 60;
18
int currentSeconds = current % 60;
19
int totalMinutes = total / 60;
20
int totalSeconds = total % 60;
21
display.setTextSize(1);
22
display.setCursor(32, 48);
23
display.print(currentMinutes);
24
display.print(":");
25
if (currentSeconds < 10) display.print("0");
26
display.print(currentSeconds);
27
display.print("/");
28
display.print(totalMinutes);
29
display.print(":");
30
if (totalSeconds < 10) display.print("0");
31
display.println(totalSeconds);
32
display.display();
33
}

State functions

This part was also quite tedious, but simply put, if we define a function callable for each button, we can then call the updateState function with the appropriate action and subaction.

1
void shuffle() { updateState('f'); }
2
void volumeDecrease() {
3
updateState('v', 0);
4
updateScreen();
5
}
6
void volumeIncrease() {
7
updateState('v', 1);
8
updateScreen();
9
}
10
void repeat() { updateState('r'); }
11
void back() {
12
updateState('b');
13
nextCurrentCheck = millis() + 100;
14
}
15
void pausePlay() {
16
updateState('p');
17
nextCurrentCheck = millis() + 100;
18
}
19
void skip() {
20
updateState('s');
21
nextCurrentCheck = millis() + 100;
22
updateCurrent();
23
}
24
void (*funcs[7])() = {
25
shuffle, volumeDecrease, volumeIncrease, repeat, back, pausePlay, skip};

This on its own doesn't actually do anything but thats where the magical loop function comes in.

Loop Function

The loop function continuously runs for the entirety of the program, it checks for button presses and then calls the appropriate function. It also checks if the current song has changed and if so, updates the display accordingly as well as a bunch of other small checks.

1
for (int i = 0; i < 7; i++) {
2
keyState[i] = digitalRead(keys[i]);
3
if (keyState[i] == LOW && keyPrevState[i] == HIGH) {
4
funcs[i]();
5
}
6
keyPrevState[i] = keyState[i];
7
}
8
if (millis() > nextRSSICheck) {
9
nextRSSICheck = millis() + RSSIDelay;
10
rssi = WiFi.RSSI();
11
updateScreen();
12
}
13
if (millis() > nextCurrentCheck) {
14
nextCurrentCheck = millis() + currentDelay;
15
updateCurrent();
16
}
17
if (millis() > nextTimeCheck) {
18
nextTimeCheck = millis() + timeDelay;
19
updateTime(duration, progress);
20
if (!paused) {
21
progress++;
22
}
23
}
24
if (shouldFade && millis() > nextFade) {
25
nextFade = millis() + fadeDelay;
26
shouldFade = fadeLED();
27
}

REST API for Offloading Heavy Tasks

The ESP32 alone couldn't handle image processing for album art. To solve this, I developed a custom REST API hosted on my server. The API:

  1. Extracts the dominant color from album artwork.
  2. Returns clean JSON data for the ESP32 to process.
  3. Handles playback states like shuffle, repeat, and volume control.

This approach offloaded heavy computation to a more capable server, allowing the ESP32 to focus on display and LED control.

My API itself can also be split into 3 separate routes along with a utility route.

Get Color

This route is meant to get the dominant color of the album art, it would do this by fetching the album art from Spotify's servers and then processing it to get the dominant color.

1
import type { NextApiRequest, NextApiResponse } from "next";
2
import { getColorFromURL } from "color-thief-node";
3
4
type Data = {
5
answer: number[];
6
};
7
8
function findNearestColor(rgbArray: number[]): number[] {
9
const colors: number[][] = [
10
[255, 0, 0],
11
[255, 125, 0],
12
[255, 255, 0],
13
[125, 255, 0],
14
[0, 255, 0],
15
[0, 255, 125],
16
[0, 255, 255],
17
[0, 125, 255],
18
[0, 0, 255],
19
[125, 0, 255],
20
[255, 0, 255],
21
[255, 0, 125],
22
];
23
24
let minDistance: number = Infinity;
25
let closestColor: number[] = [];
26
colors.forEach((color: number[]) => {
27
const distance: number = Math.sqrt(
28
Math.pow(rgbArray[0] - color[0], 2) +
29
Math.pow(rgbArray[1] - color[1], 2) +
30
Math.pow(rgbArray[2] - color[2], 2)
31
);
32
if (distance < minDistance) {
33
minDistance = distance;
34
closestColor = color;
35
}
36
});
37
return closestColor;
38
}
39
40
export default async function handler(
41
req: NextApiRequest,
42
res: NextApiResponse<Data>
43
) {
44
const hash = req.query.hash as string;
45
const url = `https://i.scdn.co/image/${hash}`;
46
let colors = await getColorFromURL(url);
47
const color = findNearestColor(colors);
48
res.status(200).json({ answer: color });
49
}

This accomplishes with a simple formula of root(r^2 + g^2 + b^2) to get the distance between two colors, once this formula is applied to all colors, the closest clean color is returned.

Get Current

This route is meant to get the current song and its details, it would do this by fetching the current song from Spotify's servers and then parsing the JSON data to get the song, artist, album, duration, progress, paused, and volume.

1
import { NextApiRequest, NextApiResponse } from "next";
2
import getSpotifyAccessToken from "@/utils/functions/getSpotify";
3
type ESPInfo = {
4
title: string;
5
artist: string;
6
album: string;
7
color: [number, number, number];
8
duration: string;
9
progress: string;
10
paused: string;
11
volume: string;
12
shuffle: boolean;
13
loop: string;
14
}
15
type Error = {
16
error: string;
17
}
18
export default async function handler(
19
req: NextApiRequest,
20
res: NextApiResponse<ESPInfo | Error>
21
) {
22
const { password } = req.query;
23
if (password !== process.env.PASSWORD) {
24
res.status(401).json({ error: "Unauthorized" });
25
return;
26
}
27
try {
28
const accessToken = await getSpotifyAccessToken();
29
const response = await fetch(
30
`https://api.spotify.com/v1/me/player`,
31
{
32
headers: {
33
Authorization: `Bearer ${accessToken}`,
34
},
35
}
36
);
37
if (response.ok) {
38
const current = await response.json();
39
const dominantColor = await fetch(`https://bzhou.ca/api/getColor/${current.item.album.images[0].url.split("/")[4]}`).then(res => res.json());
40
console.log(current);
41
res.status(200).json({
42
title: current.item.name,
43
artist: current.item.artists[0].name,
44
album: current.item.album.name,
45
color: dominantColor.answer,
46
duration: String(Math.round(current.item.duration_ms / 1000)),
47
progress: String(Math.round(current.progress_ms / 1000)),
48
paused: String(!current.is_playing),
49
volume: String(current.device.volume_percent),
50
shuffle: current.shuffle_state,
51
loop: current.repeat_state
52
});
53
} else {
54
const errorMessage = await response.text();
55
res.status(response.status).json({ error: errorMessage });
56
}
57
} catch (error) {
58
res.status(500).json({ error: "Internal Server Error" });
59
console.log(error);
60
}
61
}

This function is a bit more complex but its simply meant to extract all the values I need and return them appropriately.

Manage State

This route is meant to manage the state of the music player, it would do this by sending a POST/PUT request to Spotify's servers with the appropriate action and subaction.

1
import { NextApiRequest, NextApiResponse } from "next";
2
import getSpotifyAccessToken from "@/utils/functions/getSpotify";
3
type ESPInfo = {
4
answer: string;
5
};
6
type Error = {
7
error: string;
8
};
9
const changes = ["playPause", "skip", "back", "vinc", "vdec", "loop", "shuffle"];
10
11
async function getPlayerData(res: NextApiResponse, accessToken: string) {
12
const response = await fetch(
13
`https://api.spotify.com/v1/me/player`,
14
{
15
headers: {
16
Authorization: `Bearer ${accessToken}`,
17
},
18
}
19
);
20
if (response.ok) {
21
const responseData = await response.json();
22
return responseData;
23
}
24
else {
25
res.status(response.status).json({
26
error: "Error fetching player status",
27
});
28
return;
29
}
30
}
31
32
export default async function handler(
33
req: NextApiRequest,
34
res: NextApiResponse<ESPInfo | Error>
35
) {
36
const { password, change } = req.query;
37
if (password !== process.env.PASSWORD) {
38
res.status(401).json({ error: "Unauthorized" });
39
return;
40
}
41
if (!changes.includes(change as string)) {
42
res.status(400).json({ error: "Invalid change" });
43
return;
44
}
45
try {
46
const accessToken = await getSpotifyAccessToken();
47
let url = "";
48
let metho = "";
49
if (change === "playPause") {
50
const responseData = await getPlayerData(res, accessToken);
51
if (!responseData.is_playing) {
52
url = "https://api.spotify.com/v1/me/player/play";
53
metho = "PUT";
54
} else {
55
url = "https://api.spotify.com/v1/me/player/pause";
56
metho = "PUT";
57
}
58
} else if (change === "skip") {
59
url = `https://api.spotify.com/v1/me/player/next`;
60
metho = "POST";
61
} else if (change === "back") {
62
url = `https://api.spotify.com/v1/me/player/previous`;
63
metho = "POST";
64
}
65
else if (change === "vinc" || change === "vdec") {
66
const responseData = await getPlayerData(res, accessToken);
67
let volume = responseData.device.volume_percent;
68
if (change === "vinc") {
69
volume += 10;
70
volume = Math.min(volume, 100);
71
} else {
72
volume -= 10;
73
volume = Math.max(volume, 0);
74
}
75
url = `https://api.spotify.com/v1/me/player/volume?volume_percent=${volume}`;
76
metho = "PUT";
77
} else if (change === "loop") {
78
const responseData = await getPlayerData(res, accessToken);
79
let state = responseData.repeat_state;
80
if (state === "track") {
81
state = "context";
82
} else if (state === "context") {
83
state = "off";
84
} else {
85
state = "track";
86
}
87
url = `https://api.spotify.com/v1/me/player/repeat?state=${state}`;
88
metho = "PUT"
89
} else if (change === "shuffle") {
90
const responseData = await getPlayerData(res, accessToken);
91
let shuffle = responseData.shuffle_state;
92
url = `https://api.spotify.com/v1/me/player/shuffle?state=${!shuffle}`;
93
metho = "PUT";
94
}
95
96
await fetch(url, {
97
method: metho,
98
headers: {
99
Authorization: `Bearer ${accessToken}`,
100
},
101
});
102
103
res.status(200).json({ answer: "Success" });
104
} catch (error) {
105
res.status(500).json({ error: "Internal Server Error" });
106
console.log(error);
107
}
108
}

This part was a bit more complex than it might've seemed initially primarily since I had to account for the different states of the music player and the different actions that could be taken. Simply put, you cannot just tell spotify to loop or unloop or move onto the next state. You have to explicitly tell it to loop the song, loop the playlist, or not loop at all. Similar logic follows for the shuffle state. Therefore I just opted to have it sort of rotate through similar to how the app does it.

Spotify Access Token

This utility route is meant to get the access token for Spotify, it would do this by sending a POST request to Spotify's servers with the appropriate credentials and then parsing the JSON data to get the access token. I need this since access tokens actually expire after a certain amount of time and so this just helps me get the correct credentials forever.

1
let accessToken = "";
2
let tokenExpiration = 0;
3
4
export default async function getSpotifyAccessToken() {
5
const { SPOTIFY_CLIENTID, SPOTIFY_SECRET, SPOTIFY_REFRESHTOKEN } =
6
process.env;
7
8
if (Date.now() < tokenExpiration) {
9
return accessToken;
10
}
11
12
const authString = Buffer.from(
13
`${SPOTIFY_CLIENTID}:${SPOTIFY_SECRET}`
14
).toString("base64");
15
16
const tokenResponse = await fetch(
17
"https://accounts.spotify.com/api/token",
18
{
19
method: "POST",
20
headers: {
21
Authorization: `Basic ${authString}`,
22
"Content-Type": "application/x-www-form-urlencoded",
23
},
24
body: new URLSearchParams({
25
grant_type: "refresh_token",
26
refresh_token: SPOTIFY_REFRESHTOKEN || "",
27
}),
28
}
29
);
30
31
const tokenData = await tokenResponse.json();
32
33
accessToken = tokenData.access_token;
34
tokenExpiration = Date.now() + tokenData.expires_in * 1000;
35
36
return accessToken;
37
};

Conclusion

In conclusion, I had a really fun time creating this project especially since I was able to combine a lot of my knowledge in programming as well as hardware to create a semi-useful project... I decided to specifically write about this project since it was one of the major reasons I decided to pursue a degree in computer engineering and I thought it would be a good way to show off some of my skills.

Final Product

Contacts

Thanks for reading through my website! If you want to contact me please reach out on LinkedIn.

Copyright © 2023-2024 Ben Zhou All rights reserved.

Socials

Check out my socials below!