Created
June 18, 2024 08:33
-
-
Save espired/d2a8c1e3dc430dcc60196adcef97b3bb to your computer and use it in GitHub Desktop.
ESP32-S3 Spotify Remote Controller
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#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(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Now on https://github.com/espired/esp32-spotify-controller