// User_Setup.h - Konfiguracja dla ILI9341 2.8"
#define ILI9341_DRIVER
#define TFT_WIDTH 240
#define TFT_HEIGHT 320
// Piny dla ESP32-S3 (standardowe SPI)
#define TFT_MISO 12
#define TFT_MOSI 11
#define TFT_SCLK 13
#define TFT_CS 10
#define TFT_DC 9
#define TFT_RST 8
#define LOAD_GLCD
#define LOAD_FONT2
#define LOAD_FONT4
#define LOAD_FONT6
#define LOAD_FONT7
#define LOAD_FONT8
#define LOAD_GFXFF
#define SMOOTH_FONT
#define SPI_FREQUENCY 40000000
#define SPI_READ_FREQUENCY 20000000
#define SPI_TOUCH_FREQUENCY 2500000
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <TFT_eSPI.h>
#include "esp_bt_main.h"
#include "esp_bt_device.h"
#include "esp_gap_bt_api.h"
// Dla prawdziwego audio (odkomentuj na hardware)
// #include "AudioTools.h"
// #include "BluetoothA2DPSource.h"
// #include "AudioCodecs/CodecMP3Helix.h"
// Piny przycisków
#define BTN_PREV 1
#define BTN_NEXT 2
#define BTN_SELECT 3
// Kolory
#define COLOR_BG 0x0000 // Czarny
#define COLOR_HEADER 0x001F // Niebieski
#define COLOR_TEXT 0xFFFF // Biały
#define COLOR_ACTIVE 0x07E0 // Zielony
#define COLOR_SELECT 0xFFE0 // Żółty
#define COLOR_GRAY 0x7BEF // Szary
TFT_eSPI tft = TFT_eSPI();
WebServer server(80);
Preferences prefs;
// Tryb pracy
enum Mode {
MODE_CONFIG,
MODE_BT_SCAN,
MODE_RADIO
};
Mode currentMode = MODE_CONFIG;
// Struktury danych
struct Station {
String name;
String url;
};
struct BTDevice {
String name;
String address;
int rssi;
};
Station stations[50];
int stationCount = 0;
int currentStation = 0;
bool isPlaying = false;
BTDevice btDevices[10];
int btDeviceCount = 0;
int selectedBTDevice = 0;
String lastBTAddress = "";
String connectedBTName = "";
// Mock dla Wokwi - prawdziwy A2DP na hardware
#ifdef WOKWI
class MockA2DP {
public:
void start(const char* name) {
Serial.printf("Mock BT: Connecting to %s\n", name);
delay(1000);
}
bool isConnected() { return true; }
};
MockA2DP a2dp_source;
#else
// Odkomentuj dla prawdziwego hardware:
// BluetoothA2DPSource a2dp_source;
// URLStream urlStream;
// MP3DecoderHelix decoder;
// EncodedAudioStream encoded(&a2dp_source, &decoder);
// StreamCopy copier(encoded, urlStream);
#endif
// Prototypy funkcji
void displayMainScreen();
void displayBTScanScreen();
void scanBTDevices();
void connectToLastBT();
void saveLastBT(String address, String name);
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n=== ESP32-S3 Internet Radio ===");
// PSRAM check
#ifndef WOKWI
if(psramInit()){
Serial.printf("✅ PSRAM: %d MB\n", ESP.getPsramSize() / 1024 / 1024);
}
#endif
// Inicjalizacja TFT
tft.init();
tft.setRotation(1); // Landscape
tft.fillScreen(COLOR_BG);
// Logo startowe
showBootScreen();
delay(2000);
// Przyciski
pinMode(BTN_PREV, INPUT_PULLUP);
pinMode(BTN_NEXT, INPUT_PULLUP);
pinMode(BTN_SELECT, INPUT_PULLUP);
// Wczytaj konfigurację
loadConfig();
// Decyzja o trybie startowym
if (stationCount == 0) {
currentMode = MODE_CONFIG;
startConfigPortal();
} else {
// Sprawdź czy było zapisane urządzenie BT
if (lastBTAddress.length() > 0) {
currentMode = MODE_RADIO;
connectWiFi();
connectToLastBT();
playStation(0);
} else {
// Skanuj urządzenia BT
currentMode = MODE_BT_SCAN;
displayBTScanScreen();
scanBTDevices();
}
}
}
void loop() {
static unsigned long lastPress = 0;
unsigned long now = millis();
server.handleClient();
// Obsługa przycisków z debouncing
if (now - lastPress > 250) {
if (digitalRead(BTN_SELECT) == LOW) {
handleSelectButton();
lastPress = now;
}
if (digitalRead(BTN_NEXT) == LOW) {
handleNextButton();
lastPress = now;
}
if (digitalRead(BTN_PREV) == LOW) {
handlePrevButton();
lastPress = now;
}
}
// Streaming audio (tylko na hardware)
#ifndef WOKWI
if (isPlaying && currentMode == MODE_RADIO) {
// copier.copy();
}
#endif
// Animacja ekranu
if (currentMode == MODE_RADIO && isPlaying) {
static unsigned long lastAnim = 0;
if (now - lastAnim > 100) {
animatePlayback();
lastAnim = now;
}
}
}
// ============= EKRANY =============
void showBootScreen() {
tft.fillScreen(COLOR_BG);
// Header
tft.fillRect(0, 0, 320, 40, COLOR_HEADER);
tft.setTextColor(TFT_WHITE);
tft.setTextDatum(MC_DATUM);
tft.drawString("ESP32-S3 RADIO", 160, 20, 4);
// Info
tft.setTextColor(COLOR_SELECT);
tft.drawString("Internet Radio Player", 160, 80, 2);
tft.setTextColor(COLOR_ACTIVE);
tft.drawString("v1.0 - Wokwi Ready", 160, 110, 2);
#ifndef WOKWI
tft.setTextColor(COLOR_GRAY);
tft.drawString("PSRAM: " + String(ESP.getPsramSize()/1024/1024) + "MB", 160, 140, 2);
#endif
// Progress bar
for(int i = 0; i < 320; i += 10) {
tft.fillRect(0, 220, i, 5, COLOR_ACTIVE);
delay(30);
}
}
void displayMainScreen() {
tft.fillScreen(COLOR_BG);
// Header
tft.fillRect(0, 0, 320, 35, COLOR_HEADER);
tft.setTextColor(TFT_WHITE);
tft.setTextDatum(TL_DATUM);
tft.drawString("RADIO", 10, 10, 2);
// Numer stacji
tft.setTextDatum(TR_DATUM);
tft.drawString(String(currentStation + 1) + "/" + String(stationCount), 310, 10, 2);
// Nazwa stacji
tft.fillRect(5, 45, 310, 60, 0x2104);
tft.setTextColor(COLOR_SELECT);
tft.setTextDatum(MC_DATUM);
String stationName = stations[currentStation].name;
if(stationName.length() > 20) {
tft.drawString(stationName, 160, 75, 4);
} else {
tft.drawString(stationName, 160, 65, 4);
}
// Status połączenia BT
tft.fillRect(5, 115, 310, 30, 0x18C3);
tft.setTextColor(COLOR_ACTIVE);
tft.drawString("BT: " + connectedBTName, 160, 130, 2);
// Status odtwarzania
tft.setTextDatum(TL_DATUM);
tft.setTextColor(isPlaying ? COLOR_ACTIVE : TFT_RED);
tft.drawString(isPlaying ? "▶ PLAYING" : "⏸ PAUSED", 10, 160, 4);
// URL (mały)
tft.setTextColor(COLOR_GRAY);
tft.setTextDatum(TC_DATUM);
String url = stations[currentStation].url;
if(url.length() > 45) url = url.substring(0, 42) + "...";
tft.drawString(url, 160, 200, 1);
// Przyciski
drawButtons();
}
void drawButtons() {
int y = 220;
// Prev
tft.fillRoundRect(10, y, 90, 35, 5, 0x4208);
tft.setTextColor(TFT_WHITE);
tft.setTextDatum(MC_DATUM);
tft.drawString("< PREV", 55, y+17, 2);
// Play/Pause
tft.fillRoundRect(115, y, 90, 35, 5, isPlaying ? TFT_RED : COLOR_ACTIVE);
tft.drawString(isPlaying ? "PAUSE" : "PLAY", 160, y+17, 2);
// Next
tft.fillRoundRect(220, y, 90, 35, 5, 0x4208);
tft.drawString("NEXT >", 265, y+17, 2);
}
void displayBTScanScreen() {
tft.fillScreen(COLOR_BG);
// Header
tft.fillRect(0, 0, 320, 35, COLOR_HEADER);
tft.setTextColor(TFT_WHITE);
tft.setTextDatum(MC_DATUM);
tft.drawString("BLUETOOTH DEVICES", 160, 17, 2);
tft.setTextDatum(TL_DATUM);
tft.setTextColor(COLOR_GRAY);
tft.drawString("Scanning...", 10, 45, 2);
}
void updateBTList() {
tft.fillRect(0, 45, 320, 195, COLOR_BG);
if(btDeviceCount == 0) {
tft.setTextColor(TFT_RED);
tft.setTextDatum(MC_DATUM);
tft.drawString("No devices found", 160, 120, 2);
tft.setTextColor(COLOR_GRAY);
tft.drawString("Press SELECT to rescan", 160, 145, 2);
return;
}
tft.setTextDatum(TL_DATUM);
int y = 50;
for(int i = 0; i < btDeviceCount && i < 6; i++) {
bool isSelected = (i == selectedBTDevice);
// Tło
if(isSelected) {
tft.fillRoundRect(5, y, 310, 30, 3, COLOR_ACTIVE);
tft.setTextColor(COLOR_BG);
} else {
tft.fillRoundRect(5, y, 310, 30, 3, 0x2104);
tft.setTextColor(TFT_WHITE);
}
// Nazwa
String name = btDevices[i].name;
if(name.length() == 0) name = "Unknown Device";
if(name.length() > 22) name = name.substring(0, 22);
tft.drawString(name, 10, y + 8, 2);
// RSSI
if(!isSelected) tft.setTextColor(COLOR_GRAY);
tft.setTextDatum(TR_DATUM);
tft.drawString(String(btDevices[i].rssi) + "dB", 310, y + 8, 2);
tft.setTextDatum(TL_DATUM);
y += 35;
}
// Instrukcja
tft.fillRect(0, 220, 320, 20, COLOR_HEADER);
tft.setTextColor(TFT_WHITE);
tft.setTextDatum(MC_DATUM);
tft.drawString("PREV/NEXT: select | SELECT: connect", 160, 230, 1);
}
// ============= BLUETOOTH =============
void scanBTDevices() {
Serial.println("🔍 Scanning BT devices...");
btDeviceCount = 0;
#ifdef WOKWI
// Mock dla Wokwi - symuluj znalezione urządzenia
delay(1000);
btDevices[0] = {"JBL Flip 5", "AA:BB:CC:DD:EE:01", -45};
btDevices[1] = {"Sony Speaker", "AA:BB:CC:DD:EE:02", -67};
btDevices[2] = {"Mi Speaker", "AA:BB:CC:DD:EE:03", -72};
btDeviceCount = 3;
updateBTList();
Serial.printf("Found %d mock devices\n", btDeviceCount);
#else
// Prawdziwe skanowanie BT (dla hardware)
// esp_bt_gap_start_discovery(ESP_BT_INQ_MODE_GENERAL_INQUIRY, 10, 0);
// Callback ESP_BT_GAP_DISC_RES_EVT będzie dodawał urządzenia
// Po zakończeniu wywołaj updateBTList();
#endif
}
void connectToDevice(int index) {
if(index < 0 || index >= btDeviceCount) return;
tft.fillScreen(COLOR_BG);
tft.setTextColor(COLOR_SELECT);
tft.setTextDatum(MC_DATUM);
tft.drawString("Connecting to:", 160, 100, 2);
tft.setTextColor(TFT_WHITE);
tft.drawString(btDevices[index].name, 160, 130, 4);
Serial.printf("🔗 Connecting to: %s [%s]\n",
btDevices[index].name.c_str(),
btDevices[index].address.c_str());
#ifdef WOKWI
delay(1500);
connectedBTName = btDevices[index].name;
saveLastBT(btDevices[index].address, btDevices[index].name);
#else
// a2dp_source.start(btDevices[index].address.c_str());
// delay(2000);
// if(a2dp_source.isConnected()) {
// connectedBTName = btDevices[index].name;
// saveLastBT(btDevices[index].address, btDevices[index].name);
// }
#endif
currentMode = MODE_RADIO;
connectWiFi();
playStation(0);
}
void connectToLastBT() {
Serial.printf("🔗 Auto-connecting to last device: %s\n", lastBTAddress.c_str());
tft.fillScreen(COLOR_BG);
tft.setTextColor(COLOR_SELECT);
tft.setTextDatum(MC_DATUM);
tft.drawString("Connecting to:", 160, 100, 2);
tft.setTextColor(TFT_WHITE);
tft.drawString(connectedBTName, 160, 130, 4);
#ifdef WOKWI
delay(1500);
#else
// a2dp_source.start(lastBTAddress.c_str());
// delay(2000);
#endif
}
void saveLastBT(String address, String name) {
prefs.begin("radio", false);
prefs.putString("bt_addr", address);
prefs.putString("bt_name", name);
prefs.end();
lastBTAddress = address;
connectedBTName = name;
Serial.printf("💾 Saved BT: %s [%s]\n", name.c_str(), address.c_str());
}
// ============= PRZYCISKI =============
void handleSelectButton() {
switch(currentMode) {
case MODE_CONFIG:
// W trybie config SELECT nie robi nic (obsługa przez web)
break;
case MODE_BT_SCAN:
if(btDeviceCount > 0) {
connectToDevice(selectedBTDevice);
} else {
scanBTDevices();
}
break;
case MODE_RADIO:
togglePlay();
break;
}
}
void handleNextButton() {
switch(currentMode) {
case MODE_BT_SCAN:
selectedBTDevice = (selectedBTDevice + 1) % btDeviceCount;
updateBTList();
break;
case MODE_RADIO:
nextStation();
break;
}
}
void handlePrevButton() {
switch(currentMode) {
case MODE_BT_SCAN:
selectedBTDevice = (selectedBTDevice - 1 + btDeviceCount) % btDeviceCount;
updateBTList();
break;
case MODE_RADIO:
prevStation();
break;
}
}
// ============= RADIO =============
void playStation(int index) {
if(index < 0 || index >= stationCount) return;
currentStation = index;
isPlaying = true;
Serial.printf("▶️ Playing: %s\n", stations[index].name.c_str());
#ifndef WOKWI
// urlStream.end();
// auto cfg = urlStream.defaultConfig();
// cfg.buffer_size = 8192;
// urlStream.begin(stations[index].url.c_str(), "audio/mp3");
// encoded.begin();
#endif
displayMainScreen();
}
void nextStation() {
currentStation = (currentStation + 1) % stationCount;
playStation(currentStation);
}
void prevStation() {
currentStation = (currentStation - 1 + stationCount) % stationCount;
playStation(currentStation);
}
void togglePlay() {
isPlaying = !isPlaying;
displayMainScreen();
Serial.println(isPlaying ? "▶️ Play" : "⏸️ Pause");
}
void animatePlayback() {
static int barPos = 0;
static int barDir = 1;
int y = 185;
int barWidth = 8;
int spacing = 12;
// Wyczyść poprzedni pasek
tft.fillRect(5, y, 310, 10, COLOR_BG);
// Rysuj paski
for(int i = 0; i < 25; i++) {
int height = random(3, 10);
int x = 10 + i * spacing;
uint16_t color = (i == barPos) ? COLOR_SELECT : COLOR_ACTIVE;
tft.fillRect(x, y + (10 - height), barWidth, height, color);
}
barPos += barDir;
if(barPos >= 24 || barPos <= 0) barDir = -barDir;
}
// ============= WIFI & CONFIG =============
void connectWiFi() {
prefs.begin("radio", true);
String ssid = prefs.getString("ssid", "");
String pass = prefs.getString("pass", "");
prefs.end();
if(ssid.length() == 0) {
Serial.println("❌ No WiFi config");
return;
}
Serial.printf("📡 Connecting to: %s\n", ssid.c_str());
#ifndef WOKWI
WiFi.begin(ssid.c_str(), pass.c_str());
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
if(WiFi.status() == WL_CONNECTED) {
Serial.printf("\n✅ Connected! IP: %s\n", WiFi.localIP().toString().c_str());
} else {
Serial.println("\n❌ WiFi connection failed");
}
#else
Serial.println("✅ WiFi (simulated)");
#endif
}
void loadConfig() {
prefs.begin("radio", true);
// WiFi
String ssid = prefs.getString("ssid", "");
// Stacje
stationCount = prefs.getInt("count", 0);
for(int i = 0; i < stationCount; i++) {
stations[i].name = prefs.getString("name" + String(i), "");
stations[i].url = prefs.getString("url" + String(i), "");
}
// Ostatnie BT
lastBTAddress = prefs.getString("bt_addr", "");
connectedBTName = prefs.getString("bt_name", "");
prefs.end();
Serial.printf("📋 Loaded %d stations\n", stationCount);
if(lastBTAddress.length() > 0) {
Serial.printf("📋 Last BT: %s\n", connectedBTName.c_str());
}
}
void saveStations() {
prefs.begin("radio", false);
prefs.putInt("count", stationCount);
for(int i = 0; i < stationCount; i++) {
prefs.putString("name" + String(i), stations[i].name);
prefs.putString("url" + String(i), stations[i].url);
}
prefs.end();
Serial.printf("💾 Saved %d stations\n", stationCount);
}
// ============= WEB SERVER =============
void startConfigPortal() {
Serial.println("🌐 Starting config portal...");
tft.fillScreen(COLOR_BG);
tft.fillRect(0, 0, 320, 35, COLOR_HEADER);
tft.setTextColor(TFT_WHITE);
tft.setTextDatum(MC_DATUM);
tft.drawString("CONFIG MODE", 160, 17, 2);
tft.setTextColor(COLOR_SELECT);
tft.drawString("WiFi: ESP32-Radio", 160, 80, 2);
tft.setTextColor(TFT_WHITE);
tft.drawString("IP: 192.168.4.1", 160, 110, 2);
tft.setTextColor(COLOR_GRAY);
tft.drawString("Open browser and configure", 160, 150, 2);
WiFi.softAP("ESP32-Radio");
server.on("/", handleRoot);
server.on("/save", handleSave);
server.on("/list", handleList);
server.on("/delete", handleDelete);
server.on("/restart", []() {
server.send(200, "text/plain", "OK");
delay(1000);
ESP.restart();
});
server.begin();
Serial.println("✅ Web server started");
}
void handleRoot() {
String html = R"(
<!DOCTYPE html>
<html><head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>ESP32 Radio Config</title>
<style>
body{font-family:Arial;max-width:600px;margin:20px auto;padding:20px;background:#1a1a1a;color:#fff}
h2{color:#4CAF50;border-bottom:2px solid #4CAF50;padding-bottom:10px}
input,button{width:100%;padding:12px;margin:8px 0;font-size:16px;border-radius:5px;border:none;box-sizing:border-box}
input{background:#2a2a2a;color:#fff;border:1px solid #444}
button{background:#4CAF50;color:#fff;cursor:pointer;font-weight:bold}
button:hover{background:#45a049}
.station{background:#2a2a2a;padding:15px;margin:10px 0;border-radius:5px;border-left:4px solid #2196F3}
.station b{color:#2196F3;font-size:18px}
.station small{color:#888;display:block;margin:5px 0}
.delete{background:#f44336;width:auto;padding:8px 20px;margin-top:10px;display:inline-block}
.delete:hover{background:#da190b}
.section{background:#252525;padding:20px;margin:20px 0;border-radius:8px}
.save-btn{background:#2196F3;margin-top:30px;padding:15px;font-size:18px}
.save-btn:hover{background:#0b7dda}
</style>
</head><body>
<h2>🎵 ESP32-S3 Radio Config</h2>
<div class='section'>
<h3>📡 WiFi Settings</h3>
<input type='text' id='ssid' placeholder='WiFi SSID'>
<input type='password' id='pass' placeholder='WiFi Password'>
</div>
<div class='section'>
<h3>📻 Add Radio Station</h3>
<input type='text' id='name' placeholder='Station Name (e.g. RMF FM)'>
<input type='text' id='url' placeholder='Stream URL (http://...)'>
<button onclick='addStation()'>➕ Add Station</button>
</div>
<div class='section'>
<h3>📋 Saved Stations (<span id='count'>0</span>)</h3>
<div id='stations'></div>
</div>
<button class='save-btn' onclick='saveAndRestart()'>💾 Save & Restart</button>
<script>
loadStations();
function addStation(){
const name=document.getElementById('name').value;
const url=document.getElementById('url').value;
if(!name||!url){alert('Fill both fields!');return}
fetch('/save?name='+encodeURIComponent(name)+'&url='+encodeURIComponent(url))
.then(()=>{
document.getElementById('name').value='';
document.getElementById('url').value='';
loadStations();
});
}
function loadStations(){
fetch('/list')
.then(r=>r.json())
.then(data=>{
document.getElementById('count').innerText=data.length;
let html='';
data.forEach((s,i)=>{
html+=`<div class='station'>
<b>${s.name}</b>
<small>${s.url}</small>
<button class='delete' onclick='deleteStation(${i})'>🗑 Delete</button>
</div>`;
});
document.getElementById('stations').innerHTML=html||'<p style="color:#888">No stations yet</p>';
});
}
function deleteStation(i){
if(!confirm('Delete this station?'))return;
fetch('/delete?id='+i).then(()=>loadStations());
}
function saveAndRestart(){
const ssid=document.getElementById('ssid').value;
const pass=document.getElementById('pass').value;
if(ssid){
fetch('/save?ssid='+encodeURIComponent(ssid)+'&pass='+encodeURIComponent(pass))
.then(()=>{
alert('Saved! Device will restart in 3 seconds...');
setTimeout(()=>fetch('/restart'),3000);
});
} else {
fetch('/restart');
}
}
</script>
</body></html>
)";
server.send(200, "text/html", html);
}
void handleSave() {
if (server.hasArg("name") && server.hasArg("url")) {
stations[stationCount].name = server.arg("name");
stations[stationCount].url = server.arg("url");
stationCount++;
saveStations();
}
if (server.hasArg("ssid")) {
prefs.begin("radio", false);
prefs.putString("ssid", server.arg("ssid"));
prefs.putString("pass", server.arg("pass"));
prefs.end();
}
server.send(200, "text/plain", "OK");
}
void handleList() {
String json = "[";
for(int i = 0; i < stationCount; i++) {
if(i > 0) json += ",";
json += "{\"name\":\"" + stations[i].name + "\",";
json += "\"url\":\"" + stations[i].url + "\"}";
}
json += "]";
server.send(200, "application/json", json);
}
void handleDelete() {
int id = server.arg("id").toInt();
for(int i = id; i < stationCount - 1; i++) {
stations[i] = stations[i + 1];
}
stationCount--;
saveStations();
server.send(200, "text/plain", "OK");
}