Skip to content

Instantly share code, notes, and snippets.

@espired
Created June 18, 2024 08:33
Show Gist options
  • Save espired/d2a8c1e3dc430dcc60196adcef97b3bb to your computer and use it in GitHub Desktop.
Save espired/d2a8c1e3dc430dcc60196adcef97b3bb to your computer and use it in GitHub Desktop.
ESP32-S3 Spotify Remote Controller
#include <LilyGo_AMOLED.h>
#include <LV_Helper.h>
#include <WiFiClientSecure.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <esp_wifi.h>
// WiFi credentials
const char* ssid = "WIFI_SSID";
const char* password = "WIFI_PASS";
// Spotify API credentials
const char* spotifyClientID = "SPOTIFY_CLIENT_ID";
const char* spotifyClientSecret = "SPOTIFY_CLIENT_SECRET";
String accessToken = "ACCESS_TOKEN";
String refreshToken = "REFRESH_TOKEN";
// Spotify API URL
const char* spotifyApiUrl = "https://api.spotify.com/v1/me/player";
// Variables to store current track information
int currentTrackDuration = 0;
int currentTrackProgress = 0;
bool isPlaying = false;
int httpErrorCount = 0;
const int maxErrorCount = 3;
// LVGL objects for the display
lv_obj_t* labelTrack = nullptr;
lv_obj_t* labelArtist = nullptr;
lv_obj_t* barProgress = nullptr;
lv_obj_t* labelCurrentTime = nullptr;
lv_obj_t* labelTotalTime = nullptr;
lv_obj_t* btnContainer = nullptr;
lv_obj_t* btnPlayPause = nullptr;
lv_obj_t* btnNext = nullptr;
lv_obj_t* btnPrevious = nullptr;
lv_obj_t* btnVolumeDown = nullptr;
lv_obj_t* btnVolumeUp = nullptr;
lv_obj_t* labelNowPlaying = nullptr;
lv_obj_t* screen1 = nullptr;
lv_obj_t* screen2 = nullptr;
lv_obj_t* labelScreen2 = nullptr;
lv_obj_t* deviceList = nullptr;
lv_obj_t* splashScreen = nullptr;
// Task handles for updating track information and displaying the current track
TaskHandle_t updateTrackInfoTaskHandle = NULL;
TaskHandle_t displayCurrentTrackTaskHandle = NULL;
// Create an instance of the LilyGo_Class for the AMOLED display
LilyGo_Class amoled;
// Function declarations
void gui();
void WiFiEvent(WiFiEvent_t event);
String renewAccessToken();
void updateTrackInfo(void* parameter);
void displayCurrentTrack(void* parameter);
void controlSpotifyPlayer(const char* action);
void controlSpotifyVolume(const char* action);
void updateDeviceList();
void setActiveDevice(const char* deviceId);
// Helper function to create buttons
lv_obj_t* createButton(lv_obj_t* parent, const char* symbol, lv_event_cb_t event_cb, int width = 90, int height = 60, lv_color_t bg_color = lv_color_hex(0xFFFFFF)) {
lv_obj_t* btn = lv_btn_create(parent);
lv_obj_set_size(btn, width, height);
lv_obj_set_style_bg_color(btn, bg_color, 0);
lv_obj_t* label = lv_label_create(btn);
lv_label_set_text(label, symbol);
lv_obj_set_style_text_font(label, &lv_font_montserrat_28, 0);
if (bg_color.full == lv_color_hex(0xFFFFFF).full) {
lv_obj_set_style_text_color(label, lv_color_hex(0x000000), 0);
}
lv_obj_center(label);
lv_obj_add_event_cb(btn, event_cb, LV_EVENT_CLICKED, NULL);
return btn;
}
void gui() {
// Create screen1
screen1 = lv_obj_create(NULL);
lv_scr_load(screen1);
// Set the LVGL theme to dark
lv_theme_t* dark_theme = lv_theme_default_init(NULL, lv_palette_main(LV_PALETTE_GREY), lv_palette_main(LV_PALETTE_BLUE), true, LV_FONT_DEFAULT);
lv_disp_set_theme(NULL, dark_theme);
// Create LVGL objects for the display
labelNowPlaying = lv_label_create(screen1);
lv_label_set_text(labelNowPlaying, "Now playing:");
lv_obj_align(labelNowPlaying, LV_ALIGN_TOP_LEFT, 10, 10);
lv_obj_set_style_text_font(labelNowPlaying, &lv_font_montserrat_28, 0);
lv_obj_t* panel = lv_obj_create(screen1);
lv_obj_set_size(panel, lv_obj_get_width(screen1) - 20, 150);
lv_obj_align(panel, LV_ALIGN_TOP_LEFT, 10, 50);
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_SPACE_AROUND, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
labelTrack = lv_label_create(panel);
lv_obj_set_style_text_font(labelTrack, &lv_font_montserrat_32, 0);
lv_label_set_long_mode(labelTrack, LV_LABEL_LONG_WRAP); // Enable line wrapping
labelArtist = lv_label_create(panel);
lv_obj_set_style_text_font(labelArtist, &lv_font_montserrat_22, 0);
barProgress = lv_bar_create(screen1);
lv_obj_set_size(barProgress, lv_obj_get_width(screen1) - 20, 20);
lv_obj_align(barProgress, LV_ALIGN_BOTTOM_MID, 0, -34);
btnContainer = lv_obj_create(screen1);
lv_obj_remove_style_all(btnContainer);
lv_obj_set_size(btnContainer, lv_obj_get_width(screen1) - 20, 70);
lv_obj_align(btnContainer, LV_ALIGN_BOTTOM_MID, 0, -140);
lv_obj_set_flex_flow(btnContainer, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(btnContainer, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
btnVolumeDown = createButton(btnContainer, LV_SYMBOL_VOLUME_MID, [](lv_event_t* e) { controlSpotifyVolume("volume_down"); });
btnPrevious = createButton(btnContainer, LV_SYMBOL_PREV, [](lv_event_t* e) { controlSpotifyPlayer("previous"); });
btnPlayPause = createButton(btnContainer, isPlaying ? LV_SYMBOL_PAUSE : LV_SYMBOL_PLAY, [](lv_event_t* e) { controlSpotifyPlayer(isPlaying ? "pause" : "play"); }, 110, 70, lv_color_hex(0x1DB954));
btnNext = createButton(btnContainer, LV_SYMBOL_NEXT, [](lv_event_t* e) { controlSpotifyPlayer("next"); });
btnVolumeUp = createButton(btnContainer, LV_SYMBOL_VOLUME_MAX, [](lv_event_t* e) { controlSpotifyVolume("volume_up"); });
lv_obj_t* timeContainer = lv_obj_create(screen1);
lv_obj_remove_style_all(timeContainer);
lv_obj_set_size(timeContainer, lv_obj_get_width(screen1) - 20, 40);
lv_obj_align_to(timeContainer, barProgress, LV_ALIGN_OUT_TOP_MID, 0, -10);
lv_obj_set_flex_flow(timeContainer, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(timeContainer, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
labelCurrentTime = lv_label_create(timeContainer);
lv_obj_set_style_text_font(labelCurrentTime, &lv_font_montserrat_28, 0);
labelTotalTime = lv_label_create(timeContainer);
lv_obj_set_style_text_font(labelTotalTime, &lv_font_montserrat_28, 0);
// Create a task to update track information
if (!updateTrackInfoTaskHandle) {
xTaskCreate(updateTrackInfo, "UpdateTrackInfo", 5 * 1024, NULL, 12, &updateTrackInfoTaskHandle);
}
// Create screen2
screen2 = lv_obj_create(NULL);
labelScreen2 = lv_label_create(screen2);
lv_label_set_text(labelScreen2, "Available Devices:");
lv_obj_align(labelScreen2, LV_ALIGN_TOP_LEFT, 10, 10);
deviceList = lv_list_create(screen2);
lv_obj_set_size(deviceList, lv_obj_get_width(screen2) - 20, lv_obj_get_height(screen2) - 50);
lv_obj_align(deviceList, LV_ALIGN_TOP_LEFT, 10, 50);
// Add swipe gesture to switch between screens
lv_obj_add_event_cb(screen1, [](lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_GESTURE) {
lv_dir_t dir = lv_indev_get_gesture_dir(lv_indev_get_act());
if (dir == LV_DIR_LEFT) {
lv_scr_load(screen2);
updateDeviceList();
}
}
}, LV_EVENT_GESTURE, NULL);
lv_obj_add_event_cb(screen2, [](lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_GESTURE) {
lv_dir_t dir = lv_indev_get_gesture_dir(lv_indev_get_act());
if (dir == LV_DIR_RIGHT) {
lv_scr_load(screen1);
}
}
}, LV_EVENT_GESTURE, NULL);
}
void showSplashScreen() {
splashScreen = lv_obj_create(NULL);
lv_obj_set_style_bg_color(splashScreen, lv_color_hex(0x1DB954), 0);
lv_obj_t* label = lv_label_create(splashScreen);
lv_label_set_text(label, "Spotify Remote Controller");
lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_font(label, &lv_font_montserrat_32, 0);
lv_obj_center(label);
lv_scr_load(splashScreen);
lv_task_handler(); // Ensure the splash screen is rendered
delay(2000);
}
void setup() {
Serial.begin(115200);
Serial.println("============================================");
Serial.println("Welcome to spotify player");
Serial.println("============================================");
// Initialize WiFi in station mode
WiFi.mode(WIFI_STA);
// Connect to WiFi
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(2000);
Serial.println("Connecting to WiFi...");
}
Serial.println("Connected to WiFi");
// Initialize the AMOLED display
bool rslt = false;
rslt = amoled.beginAMOLED_241();
if (!rslt) {
while (1) {
Serial.println("The board model cannot be detected, please raise the Core Debug Level to an error");
delay(1000);
}
}
// Initialize LVGL helper
beginLvglHelper(amoled);
showSplashScreen();
gui();
}
void loop() {
// Handle LVGL tasks
lv_task_handler();
delay(1);
}
void WiFiEvent(WiFiEvent_t event) {
for (;;) {
switch (event) {
case ARDUINO_EVENT_WIFI_SCAN_DONE:
Serial.println("Completed scan for access points");
break;
case ARDUINO_EVENT_WIFI_STA_START:
Serial.println("WiFi client started");
break;
case ARDUINO_EVENT_WIFI_STA_STOP:
Serial.println("WiFi client stopped");
break;
case ARDUINO_EVENT_WIFI_STA_CONNECTED:
Serial.println("Connected to access point");
break;
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
Serial.println("Disconnected from WiFi access point");
break;
case ARDUINO_EVENT_WIFI_STA_AUTHMODE_CHANGE:
Serial.println("Authentication mode of access point has changed");
break;
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
Serial.print("Obtained IP address: ");
Serial.println(WiFi.localIP());
if (!updateTrackInfoTaskHandle) {
xTaskCreate(updateTrackInfo, "UpdateTrackInfo", 5 * 1024, NULL, 12, &updateTrackInfoTaskHandle);
}
break;
case ARDUINO_EVENT_WIFI_STA_LOST_IP:
Serial.println("Lost IP address and IP address is reset to 0");
break;
default: break;
}
delay(2000);
}
}
String renewAccessToken() {
HTTPClient http;
http.begin("https://accounts.spotify.com/api/token");
http.addHeader("Content-Type", "application/x-www-form-urlencoded");
String httpRequestData = "grant_type=refresh_token&refresh_token=" + refreshToken + "&client_id=" + spotifyClientID + "&client_secret=" + spotifyClientSecret;
int httpResponseCode = http.POST(httpRequestData);
if (httpResponseCode != HTTP_CODE_OK) {
Serial.printf("Failed to renew access token, error: %d\n", httpResponseCode);
return "";
}
String response = http.getString();
http.end();
DynamicJsonDocument doc(1024);
deserializeJson(doc, response);
return doc["access_token"].as<String>();
}
void updateTrackInfo(void* parameter) {
String trackName = "Unknown";
String artistName = "Unknown";
for (;;) {
HTTPClient http;
http.begin(String(spotifyApiUrl) + "/currently-playing");
http.addHeader("Authorization", "Bearer " + accessToken);
int httpResponseCode = http.GET();
if (httpResponseCode == 401) {
accessToken = renewAccessToken();
if (accessToken == "") {
Serial.println("Failed to renew access token, restarting...");
ESP.restart();
}
http.begin(String(spotifyApiUrl) + "/currently-playing");
http.addHeader("Authorization", "Bearer " + accessToken);
httpResponseCode = http.GET();
}
if (httpResponseCode > 0) {
httpErrorCount = 0;
String response = http.getString();
DynamicJsonDocument doc(1024);
deserializeJson(doc, response);
artistName = doc["item"]["artists"][0]["name"].as<String>();
trackName = doc["item"]["name"].as<String>();
currentTrackDuration = doc["item"]["duration_ms"].as<int>();
currentTrackProgress = doc["progress_ms"].as<int>();
bool newIsPlaying = doc["is_playing"].as<bool>();
if (newIsPlaying != isPlaying) {
isPlaying = newIsPlaying;
lv_label_set_text(lv_obj_get_child(btnPlayPause, NULL), isPlaying ? LV_SYMBOL_PAUSE : LV_SYMBOL_PLAY);
}
} else {
Serial.println("HTTP GET request failed with error: " + String(httpResponseCode));
currentTrackDuration = 0;
currentTrackProgress = 0;
isPlaying = false;
httpErrorCount++;
if (httpErrorCount >= maxErrorCount) {
Serial.println("Too many HTTP errors, restarting...");
ESP.restart();
}
}
http.end();
lv_label_set_text(labelTrack, trackName.c_str());
lv_label_set_text(labelArtist, artistName.c_str());
lv_bar_set_value(barProgress, (currentTrackProgress * 100) / currentTrackDuration, LV_ANIM_OFF);
int currentTimeSec = currentTrackProgress / 1000;
int totalTimeSec = currentTrackDuration / 1000;
char currentTimeStr[10];
char totalTimeStr[10];
snprintf(currentTimeStr, sizeof(currentTimeStr), "%02d:%02d", currentTimeSec / 60, currentTimeSec % 60);
snprintf(totalTimeStr, sizeof(totalTimeStr), "%02d:%02d", totalTimeSec / 60, totalTimeSec % 60);
lv_label_set_text(labelCurrentTime, currentTimeStr);
lv_label_set_text(labelTotalTime, totalTimeStr);
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
void controlSpotifyPlayer(const char* action) {
HTTPClient http;
String url = String(spotifyApiUrl) + "/";
if (strcmp(action, "play") == 0) {
url += "play";
} else if (strcmp(action, "pause") == 0) {
url += "pause";
} else if (strcmp(action, "next") == 0) {
url += "next";
} else if (strcmp(action, "previous") == 0) {
url += "previous";
}
http.begin(url);
http.addHeader("Authorization", "Bearer " + accessToken);
http.addHeader("Content-Type", "application/json");
int httpResponseCode;
if (strcmp(action, "play") == 0 || strcmp(action, "pause") == 0) {
httpResponseCode = http.PUT("{}");
} else {
httpResponseCode = http.POST("{}");
}
if (httpResponseCode != HTTP_CODE_NO_CONTENT) {
Serial.printf("Failed to %s music, error: %d\n", action, httpResponseCode);
if (httpResponseCode == 401) {
accessToken = renewAccessToken();
if (accessToken == "") {
Serial.println("Failed to renew access token, restarting...");
ESP.restart();
}
}
} else {
Serial.printf("Successfully sent %s command to Spotify\n", action);
if (strcmp(action, "play") == 0 || strcmp(action, "pause") == 0) {
isPlaying = !isPlaying;
lv_label_set_text(lv_obj_get_child(btnPlayPause, NULL), isPlaying ? LV_SYMBOL_PAUSE : LV_SYMBOL_PLAY);
}
}
http.end();
}
void controlSpotifyVolume(const char* action) {
HTTPClient http;
String url = String(spotifyApiUrl) + "/volume";
int volumePercent;
if (strcmp(action, "volume_up") == 0) {
volumePercent = 100; // Set volume to 100%
} else if (strcmp(action, "volume_down") == 0) {
volumePercent = 0; // Set volume to 0%
} else {
Serial.printf("Invalid volume action: %s\n", action);
return;
}
url += "?volume_percent=" + String(volumePercent);
http.begin(url);
http.addHeader("Authorization", "Bearer " + accessToken);
http.addHeader("Content-Type", "application/json");
int httpResponseCode = http.PUT("{}");
if (httpResponseCode != HTTP_CODE_NO_CONTENT) {
Serial.printf("Failed to change volume, error: %d\n", httpResponseCode);
if (httpResponseCode == 401) {
accessToken = renewAccessToken();
if (accessToken == "") {
Serial.println("Failed to renew access token, restarting...");
ESP.restart();
}
}
} else {
Serial.printf("Successfully sent %s command to Spotify\n", action);
}
http.end();
}
void updateDeviceList() {
HTTPClient http;
http.begin(String(spotifyApiUrl) + "/devices");
http.addHeader("Authorization", "Bearer " + accessToken);
int httpResponseCode = http.GET();
if (httpResponseCode == 401) {
accessToken = renewAccessToken();
if (accessToken == "") {
Serial.println("Failed to renew access token, restarting...");
ESP.restart();
}
http.begin(String(spotifyApiUrl) + "/devices");
http.addHeader("Authorization", "Bearer " + accessToken);
httpResponseCode = http.GET();
}
if (httpResponseCode > 0) {
String response = http.getString();
DynamicJsonDocument doc(2048);
deserializeJson(doc, response);
lv_obj_clean(deviceList);
JsonArray devices = doc["devices"].as<JsonArray>();
for (JsonObject device : devices) {
const char* deviceName = device["name"];
const char* deviceId = device["id"];
lv_obj_t* listBtn = lv_list_add_btn(deviceList, NULL, deviceName);
lv_obj_set_user_data(listBtn, strdup(deviceId)); // Use strdup to ensure the deviceId is correctly stored
lv_obj_set_style_text_font(listBtn, &lv_font_montserrat_22, 0); // Set the font size to make the text bigger
lv_obj_add_event_cb(listBtn, [](lv_event_t* e) {
lv_obj_t* btn = lv_event_get_target(e);
const char* deviceId = (const char*)lv_obj_get_user_data(btn);
setActiveDevice(deviceId);
}, LV_EVENT_CLICKED, NULL);
}
} else {
Serial.println("HTTP GET request failed with error: " + String(httpResponseCode));
}
http.end();
}
void setActiveDevice(const char* deviceId) {
HTTPClient http;
http.begin(spotifyApiUrl);
http.addHeader("Authorization", "Bearer " + accessToken);
http.addHeader("Content-Type", "application/json");
String payload = "{\"device_ids\":[\"" + String(deviceId) + "\"]}";
int httpResponseCode = http.PUT(payload);
if (httpResponseCode != HTTP_CODE_NO_CONTENT) {
Serial.printf("Failed to set active device, error: %d with id: %s \n", httpResponseCode, deviceId);
if (httpResponseCode == 401) {
accessToken = renewAccessToken();
if (accessToken == "") {
Serial.println("Failed to renew access token, restarting...");
ESP.restart();
}
}
} else {
Serial.println("Successfully set active device");
}
http.end();
}
@norpol
Copy link

norpol commented Nov 16, 2024

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment