Community project

WiFi Radio Receiver

Photo of WiFi Radio Receiver
Generated with AI

Jegor

Published November 3, 2026

This WiFi radio receiver streams internet radio stations through an ESP32 microcontroller with a touchscreen interface and synchronized RGB lighting effects. The project combines audio streaming, web connectivity, and real-time visual feedback to create an interactive radio experience.

The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for connecting the WS2812B addressable LED strip through a logic level shifter to the ESP32, along with the firmware needed to browse stations, manage favorites, and sync the RGB panel glow to the music. Builders will have everything needed to assemble and program a fully functional internet radio with dynamic lighting.

Wiring diagram

Loading diagram…

Gather all the parts

QtyComponent
1

WS2812B

1-pixel WS2812B / NeoPixel

Addressable RGB LED strip

1

SN74AHCT125 3.3 V-to-5 V logic level-shifter module

A one-way logic converter that raises the ESP32's 3.3 V data signal to a reliable 5 V signal for the addressable RGB LED.

Assemble it in 4 steps

1. Keep the radio connections in place

Leave the screen, amplifier, speaker, and existing radio wiring exactly as they are. Keep the antenna end of the ESP32-S3 board clear of metal and thick wire bundles so the radio can receive Wi-Fi.

  • This RGB addition does not change the audio or Wi-Fi wiring.
  • Do not short any existing audio or display wires while adding the light.

2. Power the converter and RGB LED

Connect the level_shifter_1 VCC pin to the board's 5V pin and its GND pin to a board GND pin. Connect rgb_led_1 VCC to the same 5V point and rgb_led_1 GND to the same GND point.

  • All three GND connections must meet: the ESP32 board, the level converter, and the RGB LED.
  • Make sure 5V and GND are not swapped — swapped power can damage the RGB LED or converter.

3. Connect the music-light signal

Connect GPIO42 (IO42) on the ESP32-S3 to the level_shifter_1 A input. Connect level_shifter_1 OE to GND so its output stays enabled. Connect level_shifter_1 Y to rgb_led_1 DATA; on an LED strip, use the end marked DIN or the arrow pointing away from the controller.

  • The converter is important: it changes the ESP32's 3.3V signal into a reliable 5V signal for the WS2812B.
  • GPIO42 → A (signal); Y → DATA/DIN (signal).
  • Do not connect the RGB LED DATA wire directly to IO42 when the LED is powered from 5V — the light may flicker or fail to respond.

4. Plug in and upload

Plug the ESP32-S3 into USB. After the design check succeeds, press Deploy to write the updated radio program. When radio audio is playing, the RGB LED will softly change brightness and color from the audio stream; it turns off when the radio is paused or stopped.

  • For a strip or ring with more than one LED, this version controls the first LED only.
  • If the light remains dark, first check that its DATA wire goes to DIN rather than DOUT.
  • Do not power a large LED strip from the ESP32 board's USB 5V pin; use a separate 5V supply for a strip with multiple LEDs and connect its GND to the ESP32 GND.

Review all connections

1. Connections between "level_shifter_1" and "ESP32"

Functionlevel_shifter_1ESP32
powerVCC → 5 V power for the logic converter5V
groundGND → shared groundGND
digitalA → 3.3 V music-light data from GPIO42GPIO 42
groundOE → enable the converter permanentlyGND
digitalY → WS2812B DATAEXT

2. Connections between "rgb_led_1" and "ESP32"

Functionrgb_led_1ESP32
powerVCC → 5 V power for the RGB LED5V
groundGND → shared groundGND

Deploy the firmware

schematik_esp32.inoOpen in Schematik
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <WebServer.h>
#include <Preferences.h>
#include <Wire.h>
#include <time.h>
#include <Arduino_GFX_Library.h>
#include <Audio.h>
#include <Adafruit_NeoPixel.h>
#include <NimBLEDevice.h>

// Keep the Arduino loop task compact so the MP3 decoder has a contiguous RAM block.
SET_LOOP_TASK_STACK_SIZE(6144);

constexpr int LCD_BL = 45, LCD_CS = 10, LCD_DC = 46, LCD_SCLK = 12, LCD_MOSI = 11;
constexpr int TOUCH_SDA = 16, TOUCH_SCL = 15;
constexpr int AUDIO_EN = 1, AUDIO_MCLK = 4, AUDIO_BCLK = 5, AUDIO_DOUT = 8, AUDIO_LRCK = 7;
// External WS2812B data passes through a 74AHCT125 level shifter from GPIO42.
constexpr int PANEL_GLOW_PIN = 42;
constexpr uint16_t BLACK = 0x0000, WHITE = 0xFFFF, NAVY = 0x000F;
constexpr uint16_t CYAN = 0x07FF, GREEN = 0x07E0, YELLOW = 0xFFE0, GRAY = 0x8410;

struct Station { const char *name; const char *url; };
struct City { const char *name; const char *subtitle; const Station *stations; uint8_t count; };

constexpr uint8_t MAX_FAVORITES = 12;
struct FavoriteStorage { char name[48]; char url[192]; };
FavoriteStorage favoriteStorage[MAX_FAVORITES];
Station favorites[MAX_FAVORITES];
uint8_t favoriteCount = 0;

void setFavorite(uint8_t index, const char *name, const char *url) {
  strlcpy(favoriteStorage[index].name, name, sizeof(favoriteStorage[index].name));
  strlcpy(favoriteStorage[index].url, url, sizeof(favoriteStorage[index].url));
  favorites[index] = {favoriteStorage[index].name, favoriteStorage[index].url};
}
void loadDefaultFavorites() {
  favoriteCount = 4;
  setFavorite(0, "Groove Salad", "http://ice2.somafm.com/groovesalad-128-mp3");
  setFavorite(1, "Secret Agent", "http://ice2.somafm.com/secretagent-128-mp3");
  setFavorite(2, "Radio Paradise", "http://stream.radioparadise.com/mp3-128");
  setFavorite(3, "KEXP Alternative", "http://kexp-mp3-128.streamguys1.com/kexp128.mp3");
}
// ERR's Icecast server also offers these direct MP3 streams over HTTP.
// Avoiding TLS here leaves enough internal RAM for the MP3 decoder on the N8 board.
const Station tallinn[] = {
  {"Raadio 4", "http://icecast.err.ee/raadio4.mp3"},
  {"Raadio 2", "http://icecast.err.ee/raadio2.mp3"},
  {"Vikerraadio", "http://icecast.err.ee/vikerraadio.mp3"}
};
const Station sanFrancisco[] = {
  {"Groove Salad", "http://ice2.somafm.com/groovesalad-128-mp3"},
  {"Secret Agent", "http://ice2.somafm.com/secretagent-128-mp3"},
  {"DEF CON Radio", "http://ice2.somafm.com/defcon-128-mp3"}
};
const Station newYork[] = {
  {"WFMU Freeform", "http://stream0.wfmu.org/freeform-128k"},
  {"KEXP Alternative", "http://kexp-mp3-128.streamguys1.com/kexp128.mp3"},
  {"KUTX Eclectic", "http://streams.kut.org/4428_192.mp3?aw_0_1st.playerid=kutx-free"}
};
// "Other" deliberately contains only a short, maintained set of direct MP3 streams.
// HTTP avoids TLS memory failures that make otherwise-good HTTPS streams fail on the radio.
const Station other[] = {
  {"SomaFM PopTron", "http://ice2.somafm.com/poptron-128-mp3"},
  {"SomaFM Metal Detector", "http://ice2.somafm.com/metal-128-mp3"},
  {"Saint FM Community Radio", "https://orbit.citrus3.com:2020/stream/saintfmcommunityradio"},
};
// Old unverified links, duplicates, and Best FM Balaton are removed from the board menu.
constexpr uint8_t OTHER_STATION_COUNT = sizeof(other) / sizeof(other[0]);
City cities[] = {
  {"My favorite", "Saved stations", favorites, 0},
  {"Tallinn", "Estonia", tallinn, 3},
  {"San Francisco", "California", sanFrancisco, 3},
  {"New York", "United States", newYork, 3},
  {"Other", "Verified music streams", other, OTHER_STATION_COUNT}
};
constexpr uint8_t CITY_COUNT = sizeof(cities) / sizeof(cities[0]);

Arduino_DataBus *lcdBus = new Arduino_ESP32SPI(LCD_DC, LCD_CS, LCD_SCLK, LCD_MOSI, GFX_NOT_DEFINED, SPI2_HOST);
Arduino_GFX *display = new Arduino_ILI9341(lcdBus, GFX_NOT_DEFINED, 0, true, 240, 320, 0, 0, 0, 0);
Audio audio;
Adafruit_NeoPixel panelGlow(1, PANEL_GLOW_PIN, NEO_GRB + NEO_KHZ800);
uint32_t lastGlowUpdate = 0;
uint16_t glowHue = 0;
uint8_t glowLevel = 0;
bool glowIsOn = false;
bool glowEnabled = true;
uint8_t glowBrightness = 128;
enum Screen : uint8_t { CITY_SCREEN, STATION_SCREEN, WEATHER_SCREEN, BLUETOOTH_SCREEN, WIFI_SCREEN, WIFI_PASSWORD_SCREEN };

// ESP32-S3 supports Bluetooth Low Energy (BLE), not Classic Bluetooth audio.
constexpr char BLE_DEVICE_NAME[] = "ESP32 Radio";
constexpr char BLE_SERVICE_UUID[] = "8ca6c001-7e2b-4d54-9f4e-77e81f1b9001";
constexpr char BLE_CONTROL_UUID[] = "8ca6c002-7e2b-4d54-9f4e-77e81f1b9001";
constexpr char BLE_STATUS_UUID[] = "8ca6c003-7e2b-4d54-9f4e-77e81f1b9001";
NimBLEServer *bleServer = nullptr;
NimBLECharacteristic *bleStatusCharacteristic = nullptr;
bool bleEnabled = false;
bool bleInitialized = false;
bool bleConnected = false;
bool bleAdvertising = false;
String bleCommand;

// Wi-Fi scanning runs only after tapping the Wi-Fi icon. Credentials are stored
// only after a successful connection; the built-in network remains a fallback.
constexpr uint8_t MAX_WIFI_NETWORKS = 20;
String wifiNetworks[MAX_WIFI_NETWORKS];
int16_t wifiRssi[MAX_WIFI_NETWORKS];
uint8_t wifiNetworkCount = 0, wifiPage = 0, selectedWifiIndex = 0;
String pendingWifiSsid, pendingWifiPassword;
bool wifiScanning = false, wifiConnecting = false;
uint32_t wifiConnectStartedAt = 0;
String bleStatus = "Bluetooth is off";
Screen screen = CITY_SCREEN;
// Track the scene that is already on the panel.  Repainting controls in the same
// scene must not blank the whole LCD first, because that is visible as a flash.
Screen renderedScreen = static_cast<Screen>(255);
constexpr uint8_t WEATHER_CITY_INDEX = 5;
String metarReport = "No weather report yet";
// METAR reports wind, but not the runway assigned by air traffic control.
// For EETN this is a wind-based recommendation between the 08/26 runway directions.
String metarRunwayInfo = "Runway: update METAR first";
String weatherStatus = "Tap UPDATE to download";
bool weatherUpdating = false;
uint32_t lastWeatherUpdate = 0;
constexpr uint8_t STATIONS_PER_PAGE = 4;
uint8_t cityIndex = 0, stationIndex = 0, stationPage = 0, volume = 12;
uint8_t sleepTimerMinutes = 0;
uint32_t sleepEndsAt = 0, lastClockUpdate = 0;
bool playing = false, reconnectPending = false, wifiReady = false;
// The radio uses only station Wi-Fi. Network selection and the password are entered
// directly on its touch screen; it does not create a phone-control access point.
// Resume the saved station once after a reboot, as soon as internet Wi-Fi is available.
bool autoStartPending = true;
String status = "Connecting to Wi-Fi...";
// The user supplied these router credentials to replace the previously saved network.
// The marker makes this a one-time update: later successful touch-screen choices stay saved.
constexpr char PROVISIONED_WIFI_SSID[] = "E5785_4D16";
constexpr char PROVISIONED_WIFI_PASSWORD[] = "t4m6n9HM5i9";
constexpr char PROVISIONED_WIFI_MARKER[] = "E5785_4D16-v1";
WebServer webServer(80);
Preferences preferences;

void saveFavorites() {
  preferences.putUChar("favCount", favoriteCount);
  for (uint8_t i = 0; i < favoriteCount; ++i) {
    preferences.putString((String("favName") + i).c_str(), favoriteStorage[i].name);
    preferences.putString((String("favUrl") + i).c_str(), favoriteStorage[i].url);
  }
}
void loadFavorites() {
  uint8_t storedCount = preferences.getUChar("favCount", 255);
  if (storedCount == 255 || storedCount > MAX_FAVORITES) {
    loadDefaultFavorites();
    saveFavorites();
  } else {
    favoriteCount = storedCount;
    for (uint8_t i = 0; i < favoriteCount; ++i) {
      String name = preferences.getString((String("favName") + i).c_str(), "");
      String url = preferences.getString((String("favUrl") + i).c_str(), "");
      if (name.length() == 0 || url.length() == 0) { loadDefaultFavorites(); saveFavorites(); break; }
      setFavorite(i, name.c_str(), url.c_str());
    }
  }
  cities[0].count = favoriteCount;
}
bool addFavorite(const char *name, const char *url) {
  if (!name || !url || !name[0] || !url[0]) { status = "Enter a name and stream link"; return false; }
  if (strncmp(url, "http://", 7) != 0 && strncmp(url, "https://", 8) != 0) { status = "Link must start with http:// or https://"; return false; }
  for (uint8_t i = 0; i < favoriteCount; ++i) {
    if (strcmp(favorites[i].url, url) == 0) { status = "Already in favorites"; return false; }
  }
  if (favoriteCount >= MAX_FAVORITES) { status = "Favorites list is full"; return false; }
  setFavorite(favoriteCount++, name, url);
  cities[0].count = favoriteCount;
  saveFavorites();
  status = String("Added to favorites: ") + name;
  return true;
}
bool addCurrentToFavorites() {
  if (cityIndex >= CITY_COUNT || stationIndex >= cities[cityIndex].count) return false;
  const Station &station = cities[cityIndex].stations[stationIndex];
  return addFavorite(station.name, station.url);
}
bool removeFavorite(uint8_t index) {
  if (index >= favoriteCount) return false;
  bool wasPlaying = playing && cityIndex == 0 && stationIndex == index;
  for (uint8_t i = index; i + 1 < favoriteCount; ++i) setFavorite(i, favoriteStorage[i + 1].name, favoriteStorage[i + 1].url);
  --favoriteCount;
  cities[0].count = favoriteCount;
  saveFavorites();
  if (wasPlaying) { audio.stopSong(); playing = false; status = "Removed favorite — radio stopped"; }
  else { if (cityIndex == 0 && stationIndex > index) --stationIndex; status = "Favorite removed"; }
  stationPage = stationIndex / STATIONS_PER_PAGE;
  return true;
}

void startStation();
void drawScreen();
void saveSettings();
void saveFavorites();
void loadFavorites();
bool addFavorite(const char *name, const char *url);
bool addCurrentToFavorites();
bool removeFavorite(uint8_t index);
void updatePanelGlow();
void selectRelativeStation(int8_t direction);
uint8_t pageCount();
void selectStation(uint8_t index);
void drawWeatherScreen();
void drawBluetoothScreen();
void drawWifiScreen();
void drawWifiPasswordScreen();
void beginWifiScan();
void startSelectedWifi();
void requestWeatherUpdate();
void setupBluetooth();
void setBluetoothEnabled(bool enabled);
void processBluetoothCommand();
void publishBluetoothStatus();

String metarDisplayLine(const String &raw) {
  String report = raw;
  report.replace("\r", " ");
  report.replace("\n", " ");
  report.trim();
  return report;
}
String runwayFromMetarWind(const String &report) {
  int knot = report.indexOf("KT");
  if (knot < 5) return "Runway: wind not available";
  int start = report.lastIndexOf(' ', knot - 1) + 1;
  String wind = report.substring(start, knot);
  if (wind.startsWith("VRB")) return "Runway: variable wind — no choice";
  if (wind.length() < 5 || !isDigit(wind[0]) || !isDigit(wind[1]) || !isDigit(wind[2])) return "Runway: wind not available";
  int direction = wind.substring(0, 3).toInt();
  if (direction < 1 || direction > 360) return "Runway: calm wind — either runway";
  // EETN runway headings: 08 is approximately 080 degrees, 26 approximately 260 degrees.
  int difference08 = abs(direction - 80); if (difference08 > 180) difference08 = 360 - difference08;
  int difference26 = abs(direction - 260); if (difference26 > 180) difference26 = 360 - difference26;
  String runway = difference08 <= difference26 ? "08" : "26";
  return String("Wind ") + wind.substring(0, 3) + " deg: favors RWY " + runway;
}
String htmlToText(String value) {
  value.replace("&nbsp;", " ");
  value.replace("&amp;", "&");
  value.replace("&#13;", " ");
  value.replace("<br>", " ");
  value.replace("<br/>", " ");
  value.replace("<br />", " ");
  bool insideTag = false;
  String text;
  text.reserve(value.length());
  for (size_t i = 0; i < value.length(); ++i) {
    char c = value[i];
    if (c == '<') insideTag = true;
    else if (c == '>') insideTag = false;
    else if (!insideTag) text += c;
  }
  text.replace("\r", " ");
  text.replace("\n", " ");
  while (text.indexOf("  ") >= 0) text.replace("  ", " ");
  text.trim();
  return text;
}
String extractEetnMetar(const String &response) {
  // The NWS mirror is a tiny plain-text current-observation file. It may contain
  // a heading line first, followed by the raw EETN report on the next line.
  int eetn = response.indexOf("EETN ");
  if (eetn < 0) return "";
  int end = response.indexOf('\n', eetn);
  if (end < 0) end = response.length();
  String metar = response.substring(eetn, end);
  metar.trim();
  return metar;
}
void fetchWeather() {
  // Fetch METAR in the main Arduino loop. The display, audio decoder, and Wi-Fi
  // client are not safe to update concurrently from a second FreeRTOS task.
  // tgftp.nws.noaa.gov redirects HTTP requests (302) to HTTPS, so use HTTPS directly.
  // Pause decoding during the short download so SSL has enough contiguous RAM.
  bool resumeRadio = playing;
  if (resumeRadio) {
    audio.stopSong();
    playing = false;
    delay(150);
  }

  String metar;
  int code = 0;
  // Use separate TLS clients. Reusing a closed client after HTTPClient::end() can
  // leave stale socket state on this small no-PSRAM board.
  {
    WiFiClientSecure client;
    client.setInsecure();
    HTTPClient http;
    http.useHTTP10(true);
    http.setConnectTimeout(9000);
    http.setTimeout(12000);
    const char *apiUrl = "https://aviationweather.gov/api/data/metar?ids=EETN&format=raw&taf=false";
    if (http.begin(client, apiUrl)) {
      code = http.GET();
      if (code == HTTP_CODE_OK) metar = extractEetnMetar(http.getString());
      http.end();
    }
  }

  // NWS is a direct HTTPS backup, used only if the primary source has no EETN line.
  if (!metar.startsWith("EETN ")) {
    WiFiClientSecure client;
    client.setInsecure();
    HTTPClient backup;
    backup.useHTTP10(true);
    backup.setConnectTimeout(9000);
    backup.setTimeout(12000);
    if (backup.begin(client, "https://tgftp.nws.noaa.gov/data/observations/metar/stations/EETN.TXT")) {
      int backupCode = backup.GET();
      if (backupCode == HTTP_CODE_OK) metar = extractEetnMetar(backup.getString());
      if (backupCode != 0) code = backupCode;
      backup.end();
    }
  }

  if (metar.startsWith("EETN ")) {
    metarReport = metarDisplayLine(metar);
    metarRunwayInfo = runwayFromMetarWind(metarReport);
    weatherStatus = "Updated from Aviation Weather";
    lastWeatherUpdate = millis();
  } else if (code == 0) {
    weatherStatus = "METAR server did not respond";
  } else {
    weatherStatus = String("METAR not available (HTTP ") + code + ")";
  }

  if (resumeRadio && wifiReady) {
    audio.connecttohost(cities[cityIndex].stations[stationIndex].url);
    playing = true;
  }
  weatherUpdating = false;
  if (screen == WEATHER_SCREEN) drawWeatherScreen();
}
void requestWeatherUpdate() {
  if (weatherUpdating) return;
  if (!wifiReady) { weatherStatus = "Connect Wi-Fi first"; drawWeatherScreen(); return; }
  weatherUpdating = true;
  weatherStatus = "Downloading METAR...";
  drawWeatherScreen();
  fetchWeather();
}

String webPage() {
  String page = F("<!doctype html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>"
                  "<title>Wi-Fi Radio Remote</title><style>body{margin:0;background:#07151b;color:#edf7f9;font-family:Arial,sans-serif}main{max-width:600px;margin:auto;padding:18px}h1{color:#63f5b3}h2{margin-top:28px;color:#6ee7ff}.status{padding:12px;background:#12313a;border-radius:10px}.btn{display:block;margin:9px 0;padding:14px;border-radius:10px;background:#123b52;color:white;text-decoration:none;font-size:18px;border:0}.play{background:#178a59}.warn{background:#8a6220}.row{display:flex;gap:10px}.row .btn{flex:1;text-align:center}input{box-sizing:border-box;width:100%;margin:5px 0;padding:13px;border-radius:8px;border:1px solid #4f7884;background:#fff;color:#111;font-size:16px}</style></head><body><main><h1>Wi-Fi Radio</h1><p class='status'>");
  page += wifiReady ? status : "Wi-Fi is not connected";
  page += F("</p><div class='row'><a class='btn' href='/previous'>Previous</a><a class='btn' href='/next'>Next</a></div><div class='row'><a class='btn play' href='/toggle'>");
  page += playing ? "Pause" : "Play";
  page += F("</a><a class='btn' href='/stop'>Stop</a></div><div class='row'><a class='btn' href='/volume?d=-1'>- Volume</a><a class='btn' href='/volume?d=1'>+ Volume</a></div><p>Volume: ");
  page += volume;
  page += F(" / 21</p><h2>Music light</h2><p>");
  page += glowEnabled ? "On — reacts to music" : "Off";
  page += F("</p><div class='row'><a class='btn play' href='/light?on=1'>Light on</a><a class='btn warn' href='/light?on=0'>Light off</a></div><div class='row'><a class='btn' href='/brightness?d=-16'>Less bright</a><a class='btn' href='/brightness?d=16'>More bright</a></div><p>Light brightness: ");
  page += glowBrightness;
  page += F(" / 255</p><h2>Sleep timer</h2><p>");
  page += sleepTimerMinutes ? (String(sleepTimerMinutes) + " minutes remaining") : "Off";
  page += F("</p><div class='row'><a class='btn warn' href='/sleep?m=15'>15 min</a><a class='btn warn' href='/sleep?m=30'>30 min</a><a class='btn warn' href='/sleep?m=60'>60 min</a><a class='btn' href='/sleep?m=0'>Turn off timer</a></div>");
  page += F("<h2>My favorite</h2><a class='btn play' href='/favorite/add'>Add current station to favorites</a><p>Or add any direct radio stream yourself:</p><form action='/favorite/custom' method='get'><input name='n' maxlength='47' placeholder='Station name' required><input name='u' maxlength='191' type='url' placeholder='https://direct-stream-link' required><button class='btn play' type='submit'>Add this station</button></form>");
  if (!favoriteCount) page += F("<p>No saved stations yet. Start any station, then use the button above.</p>");
  for (uint8_t s = 0; s < favoriteCount; ++s) {
    page += "<div class='row'><a class='btn play' href='/play?city=0&station=" + String(s) + "'>" + favorites[s].name + "</a><a class='btn warn' href='/favorite/remove?i=" + String(s) + "'>Remove</a></div>";
  }
  for (uint8_t c = 1; c < CITY_COUNT; ++c) {
    page += "<h2>" + String(cities[c].name) + " — " + cities[c].subtitle + "</h2>";
    for (uint8_t s = 0; s < cities[c].count; ++s) {
      page += "<a class='btn play' href='/play?city=" + String(c) + "&station=" + String(s) + "'>" + cities[c].stations[s].name + "</a>";
    }
  }
  page += F("<h2>Wi-Fi Radio remote</h2><p>This page controls the same direct radio streams as the touch screen. Keep it open on a phone connected to the same Wi-Fi network.</p></main></body></html>");
  return page;
}
void sendWebPage() { webServer.send(200, "text/html", webPage()); }
void setupWebServer() {
  webServer.on("/", HTTP_GET, []() { sendWebPage(); });
  webServer.on("/favorite/add", HTTP_GET, []() { addCurrentToFavorites(); drawScreen(); sendWebPage(); });
  webServer.on("/favorite/custom", HTTP_GET, []() { addFavorite(webServer.arg("n").c_str(), webServer.arg("u").c_str()); drawScreen(); sendWebPage(); });
  webServer.on("/favorite/remove", HTTP_GET, []() { removeFavorite(webServer.arg("i").toInt()); drawScreen(); sendWebPage(); });
  webServer.on("/play", HTTP_GET, []() {
    int c = webServer.arg("city").toInt(), s = webServer.arg("station").toInt();
    if (c >= 0 && c < CITY_COUNT && s >= 0 && s < cities[c].count) { cityIndex = c; selectStation(s); screen = STATION_SCREEN; startStation(); }
    sendWebPage();
  });
  webServer.on("/previous", HTTP_GET, []() { selectRelativeStation(-1); sendWebPage(); });
  webServer.on("/next", HTTP_GET, []() { selectRelativeStation(1); sendWebPage(); });
  webServer.on("/toggle", HTTP_GET, []() {
    if (playing) { audio.pauseResume(); playing = false; status = "Paused"; drawScreen(); } else { screen = STATION_SCREEN; startStation(); }
    sendWebPage();
  });
  webServer.on("/stop", HTTP_GET, []() { audio.stopSong(); playing = false; status = "Stopped"; drawScreen(); sendWebPage(); });
  webServer.on("/volume", HTTP_GET, []() { int d = webServer.arg("d").toInt(); if (d < 0 && volume > 0) --volume; if (d > 0 && volume < 21) ++volume; audio.setVolume(volume); saveSettings(); drawScreen(); sendWebPage(); });
  webServer.on("/light", HTTP_GET, []() { glowEnabled = webServer.arg("on") == "1"; if (!glowEnabled) { panelGlow.clear(); panelGlow.show(); glowIsOn = false; } preferences.putBool("glowOn", glowEnabled); sendWebPage(); });
  webServer.on("/brightness", HTTP_GET, []() { int next = int(glowBrightness) + webServer.arg("d").toInt(); glowBrightness = constrain(next, 8, 255); preferences.putUChar("glowBr", glowBrightness); sendWebPage(); });
  webServer.on("/sleep", HTTP_GET, []() { uint8_t minutes = webServer.arg("m").toInt(); if (minutes == 15 || minutes == 30 || minutes == 60) { sleepTimerMinutes = minutes; sleepEndsAt = millis() + uint32_t(minutes) * 60000UL; status = String("Sleep timer: ") + minutes + " min"; } else { sleepTimerMinutes = 0; sleepEndsAt = 0; status = playing ? String("Playing ") + cities[cityIndex].stations[stationIndex].name : "Sleep timer off"; } saveSettings(); drawScreen(); sendWebPage(); });
  webServer.begin();
}
constexpr uint32_t SCREEN_SAVER_MS = 20000;
constexpr uint32_t DISPLAY_SLEEP_MS = 60000;
uint32_t lastTap = 0, lastReconnect = 0, lastWifiCheck = 0, lastOpenNetworkScan = 0, lastDisplayActivity = 0, lastAnalogClockFrame = 0;
int8_t lastAnalogSecond = -1;
bool displaySleeping = false, screenSaverActive = false;
// One physical touch must produce one action.  Keep it latched until the finger lifts,
// otherwise a held touch repeatedly redraws the full screen and looks like flickering.
bool touchHeld = false;

void codecWrite(uint8_t reg, uint8_t value) { Wire.beginTransmission(0x18); Wire.write(reg); Wire.write(value); Wire.endTransmission(); }
void setupCodec() {
  codecWrite(0, 0x1F); delay(20); codecWrite(0, 0); codecWrite(1, 0x3F); codecWrite(2, 0);
  codecWrite(3, 0x10); codecWrite(4, 0x10); codecWrite(5, 0); codecWrite(6, 0); codecWrite(9, 0x0C);
  codecWrite(13, 1); codecWrite(14, 2); codecWrite(18, 0); codecWrite(19, 0x10); codecWrite(28, 0x6A);
  codecWrite(50, 0xBF); codecWrite(55, 8); codecWrite(0, 0x80);
}
void centered(const String &text, int y, uint16_t color, uint8_t size) {
  display->setTextSize(size); display->setTextColor(color); int16_t x1, y1; uint16_t w, h;
  display->getTextBounds(text, 0, y, &x1, &y1, &w, &h); display->setCursor((240 - w) / 2, y); display->print(text);
}
void drawWiFiSignal() {
  // Three small chevrons and a dot form a Wi-Fi symbol; filled levels follow the received signal.
  int8_t rssi = wifiReady ? WiFi.RSSI() : -100;
  uint8_t bars = !wifiReady ? 0 : (rssi >= -58 ? 3 : (rssi >= -70 ? 2 : (rssi >= -82 ? 1 : 0)));
  uint16_t active = BLACK;
  uint16_t inactive = GRAY;
  uint16_t outer = bars >= 3 ? active : inactive;
  uint16_t middle = bars >= 2 ? active : inactive;
  uint16_t inner = bars >= 1 ? active : inactive;
  display->drawLine(216, 10, 220, 6, outer); display->drawLine(220, 6, 228, 4, outer);
  display->drawLine(228, 4, 236, 6, outer); display->drawLine(236, 6, 239, 10, outer);
  display->drawLine(219, 16, 223, 12, middle); display->drawLine(223, 12, 228, 11, middle);
  display->drawLine(228, 11, 233, 12, middle); display->drawLine(233, 12, 237, 16, middle);
  display->drawLine(223, 21, 228, 17, inner); display->drawLine(228, 17, 233, 21, inner);
  display->fillCircle(228, 25, 2, bars ? active : inactive);
}
void drawClock() {
  // The clock has its own header zone: Bluetooth 0..28, title 34..118,
  // clock 124..204, and Wi-Fi 210..239.  These areas never overlap.
  display->fillRect(124, 4, 82, 28, GREEN);
  time_t now = time(nullptr); struct tm timeInfo;
  display->setTextSize(2); display->setTextColor(BLACK); display->setCursor(126, 10);
  if (now > 1700000000 && localtime_r(&now, &timeInfo)) {
    char clockText[6]; strftime(clockText, sizeof(clockText), "%H:%M", &timeInfo); display->print(clockText);
  } else display->print("--:--");
}
void drawBluetoothIcon() {
  // A simple, high-contrast Bluetooth rune in a 28-pixel touch area.
  display->drawLine(16, 7, 16, 30, BLACK);
  display->drawLine(16, 7, 24, 14, BLACK);
  display->drawLine(24, 14, 10, 25, BLACK);
  display->drawLine(16, 30, 24, 23, BLACK);
  display->drawLine(24, 23, 10, 12, BLACK);
}
void drawWifiIcon() {
  // This is also a 32-pixel-wide touch button in the header.
  drawWiFiSignal();
}
void header(const String &title, const String &sub) {
  // Clear only when moving to another scene.  Button presses redraw the controls
  // in place, so the LCD never briefly shows a full black frame.
  if (renderedScreen != screen) {
    display->fillScreen(BLACK);
    renderedScreen = screen;
  }
  display->fillRect(0, 0, 240, 37, GREEN);
  // Keep the title inside its own clear area in the header.
  display->setTextSize(1); display->setTextColor(BLACK); display->setCursor(12, 13); display->print(title.substring(0, 18));
  drawClock();
  drawWifiIcon();
  display->fillRect(0, 39, 240, 15, BLACK);
  display->setTextSize(1); display->setTextColor(wifiReady ? GREEN : YELLOW); display->setCursor(8, 43); display->print(sub);
}
void button(int y, const String &label, uint16_t color, bool active = false) {
  display->fillRoundRect(12, y, 216, 36, 7, active ? color : NAVY); display->drawRoundRect(12, y, 216, 36, 7, color);
  centered(label, y + 12, active ? BLACK : WHITE, 1);
}
String currentStationName() {
  if (cityIndex < CITY_COUNT && cities[cityIndex].stations && stationIndex < cities[cityIndex].count) return cities[cityIndex].stations[stationIndex].name;
  return "No station selected";
}
void drawCityScreen() {
  header("Wi-Fi Radio", playing ? "Radio is playing" : "Choose a station");
  for (uint8_t i = 0; i < CITY_COUNT; ++i) button(58 + i * 37, String(cities[i].name) + "  -  " + cities[i].subtitle, CYAN);
  display->fillRoundRect(12, 282, 102, 34, 7, NAVY);
  display->drawRoundRect(12, 282, 102, 34, 7, CYAN);
  display->fillRoundRect(126, 282, 102, 34, 7, NAVY);
  display->drawRoundRect(126, 282, 102, 34, 7, CYAN);
  centered("- VOL       + VOL", 294, WHITE, 2);
}
uint8_t pageCount() { return (cities[cityIndex].count + STATIONS_PER_PAGE - 1) / STATIONS_PER_PAGE; }
void selectStation(uint8_t index) {
  stationIndex = index;
  stationPage = stationIndex / STATIONS_PER_PAGE;
}
void drawWeatherScreen() {
  header("Weather", "Tallinn Airport - EETN");
  display->setTextColor(WHITE); display->setTextSize(1);
  display->setCursor(10, 62); display->print(weatherUpdating ? "Downloading latest aviation weather..." : weatherStatus);
  String line = metarReport;
  const uint8_t width = 30;
  for (uint16_t start = 0, row = 0; start < line.length() && row < 8; start += width, ++row) {
    display->setCursor(10, 84 + row * 16);
    display->print(line.substring(start, min<uint16_t>(start + width, line.length())));
  }
  display->setTextColor(YELLOW);
  display->setCursor(10, 214); display->print(metarRunwayInfo.substring(0, 30));
  display->setTextColor(GRAY);
  display->setCursor(10, 230); display->print("Wind-based, not ATC assignment");
  display->fillRoundRect(12, 258, 100, 36, 7, NAVY); display->drawRoundRect(12, 258, 100, 36, 7, CYAN);
  display->fillRoundRect(128, 258, 100, 36, 7, NAVY); display->drawRoundRect(128, 258, 100, 36, 7, CYAN);
  centered("UPDATE       BACK", 271, WHITE, 1);
}
void drawBluetoothScreen() {
  header("Bluetooth LE", bleEnabled ? (bleConnected ? "Phone connected" : "Ready to connect") : "Bluetooth is off");
  display->setTextColor(WHITE); display->setTextSize(1);
  display->setCursor(16, 62); display->print("Device name: ESP32 Radio");
  display->setCursor(16, 78); display->print("Use a BLE control app on your phone.");
  display->setTextColor(YELLOW);
  display->setCursor(16, 98); display->print("Phone music by Bluetooth is not supported");
  display->setCursor(16, 112); display->print("by this ESP32-S3 board.");
  button(136, bleEnabled ? "TURN BLUETOOTH OFF" : "TURN BLUETOOTH ON", bleEnabled ? YELLOW : GREEN, true);
  if (bleEnabled) button(180, bleAdvertising ? "VISIBLE TO PHONE" : "MAKE VISIBLE", CYAN, true);
  display->setTextColor(GRAY); display->setTextSize(1);
  display->setCursor(16, 230); display->print(bleStatus.substring(0, 34));
  button(270, "BACK", CYAN);
}
void drawWifiScreen() {
  header("Wi-Fi", wifiScanning ? "Searching once for nearby networks..." : "Choose a network");
  if (wifiScanning) {
    centered("Searching...", 130, YELLOW, 2);
    button(270, "BACK", CYAN);
    return;
  }
  if (!wifiNetworkCount) {
    centered("No networks found", 118, YELLOW, 1);
    button(160, "SEARCH AGAIN", GREEN, true);
    button(270, "BACK", CYAN);
    return;
  }
  const uint8_t perPage = 5;
  uint8_t first = wifiPage * perPage;
  for (uint8_t row = 0; row < perPage && first + row < wifiNetworkCount; ++row) {
    String label = wifiNetworks[first + row];
    label += "  (" + String(wifiRssi[first + row]) + " dBm)";
    button(58 + row * 37, label, CYAN);
  }
  uint8_t pages = (wifiNetworkCount + perPage - 1) / perPage;
  if (pages > 1) {
    display->fillRoundRect(202, 58, 32, 185, 7, GRAY);
    display->fillTriangle(206, 70, 230, 70, 218, 59, WHITE);
    display->fillTriangle(206, 231, 230, 231, 218, 242, WHITE);
    int thumbH = max(28, 185 / int(pages));
    int thumbY = 58 + (185 - thumbH) * wifiPage / (pages - 1);
    display->fillRoundRect(206, thumbY, 24, thumbH, 5, CYAN);
  }
  button(250, "SEARCH AGAIN                 BACK", CYAN);
}
void drawWifiPasswordScreen() {
  header("Wi-Fi password", pendingWifiSsid);
  display->setTextColor(WHITE); display->setTextSize(1); display->setCursor(12, 60);
  display->print("Password: ");
  for (uint8_t i = 0; i < pendingWifiPassword.length(); ++i) display->print('*');
  const char *rows[] = {"1234567890", "QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"};
  for (uint8_t r = 0; r < 4; ++r) {
    int y = 82 + r * 35;
    uint8_t len = strlen(rows[r]);
    for (uint8_t c = 0; c < len; ++c) {
      int x = 8 + c * (224 / len);
      display->drawRoundRect(x, y, 20, 28, 4, CYAN);
      display->setTextColor(WHITE); display->setTextSize(1); display->setCursor(x + 7, y + 10); display->print(rows[r][c]);
    }
  }
  button(226, "DELETE", YELLOW, true);
  display->fillRoundRect(12, 270, 102, 36, 7, NAVY); display->drawRoundRect(12, 270, 102, 36, 7, CYAN);
  display->fillRoundRect(126, 270, 102, 36, 7, GREEN); display->drawRoundRect(126, 270, 102, 36, 7, CYAN);
  centered("BACK       CONNECT", 282, WHITE, 1);
}
void beginWifiScan() {
  if (wifiScanning) return;
  wifiScanning = true; wifiNetworkCount = 0; wifiPage = 0; drawWifiScreen();
  int found = WiFi.scanNetworks(false, true);
  if (found > 0) {
    for (int i = 0; i < found && wifiNetworkCount < MAX_WIFI_NETWORKS; ++i) {
      String ssid = WiFi.SSID(i); if (!ssid.length()) continue;
      bool duplicate = false;
      for (uint8_t j = 0; j < wifiNetworkCount; ++j) if (wifiNetworks[j] == ssid) duplicate = true;
      if (!duplicate) { wifiNetworks[wifiNetworkCount] = ssid; wifiRssi[wifiNetworkCount++] = WiFi.RSSI(i); }
    }
  }
  WiFi.scanDelete(); wifiScanning = false; drawWifiScreen();
}
void startSelectedWifi() {
  if (!pendingWifiSsid.length()) return;
  wifiConnecting = true; wifiConnectStartedAt = millis();
  status = String("Connecting to ") + pendingWifiSsid;
  WiFi.disconnect(false, false); WiFi.begin(pendingWifiSsid.c_str(), pendingWifiPassword.c_str());
  screen = CITY_SCREEN; drawCityScreen();
}

void drawStationScreen() {
  String cityTitle = cities[cityIndex].name; cityTitle.toUpperCase();
  header(cityTitle, wifiReady ? status : "Wi-Fi is not connected"); const City &city = cities[cityIndex];
  uint8_t first = stationPage * STATIONS_PER_PAGE;
  for (uint8_t row = 0; row < STATIONS_PER_PAGE && first + row < city.count; ++row) {
    uint8_t index = first + row;
    int rowY = 60 + row * 35;
    if (cityIndex == 0 || cityIndex == 4) {
      // Favorites and Other can be longer than the screen. Reserve the right-hand
      // 40 pixels for a finger-friendly scrollbar, so it never covers a station card.
      bool active = playing && index == stationIndex;
      display->fillRoundRect(12, rowY, 180, 32, 7, active ? GREEN : NAVY);
      display->drawRoundRect(12, rowY, 180, 32, 7, GREEN);
      String label = city.stations[index].name;
      if (label.length() > 23) label = label.substring(0, 22) + ".";
      display->setTextSize(1);
      display->setTextColor(active ? BLACK : WHITE);
      display->setCursor(20, rowY + 11);
      display->print(label);
    } else {
      button(rowY, city.stations[index].name, GREEN, playing && index == stationIndex);
    }
  }
  button(205, playing ? "PAUSE" : "PLAY", playing ? YELLOW : GREEN, true);
  display->fillRoundRect(12, 250, 102, 34, 7, NAVY); display->drawRoundRect(12, 250, 102, 34, 7, CYAN);
  display->fillRoundRect(126, 250, 102, 34, 7, NAVY); display->drawRoundRect(126, 250, 102, 34, 7, CYAN); centered("- VOL       + VOL", 262, WHITE, 2);
  display->fillRect(0, 289, 240, 31, GRAY); display->setTextSize(1); display->setTextColor(WHITE); display->setCursor(8, 300); display->print("BACK");
  // The middle button manages the selected station without needing the phone.
  display->setCursor(64, 300);
  display->print(cityIndex == 0 ? "REMOVE" : "ADD FAV");
  display->setCursor(142, 300); display->print("SLEEP:");
  if (sleepTimerMinutes) { display->print(sleepTimerMinutes); display->print("m"); } else display->print("OFF");
  if (pageCount() > 1) { display->setCursor(205, 300); display->print(stationPage + 1); display->print('/'); display->print(pageCount()); }
  else { display->setCursor(205, 300); display->print("V"); display->print(volume); }
  // Favorites and Other have a wide touch strip so a finger can move through a long list.
  if ((cityIndex == 0 || cityIndex == 4) && pageCount() > 1) {
    constexpr int BAR_TOP = 60, BAR_HEIGHT = 140, BAR_X = 200, BAR_W = 40;
    int thumbHeight = max(22, BAR_HEIGHT / int(pageCount()));
    int thumbY = BAR_TOP + (BAR_HEIGHT - thumbHeight) * stationPage / (pageCount() - 1);
    display->fillRoundRect(BAR_X, BAR_TOP, BAR_W, BAR_HEIGHT, 8, GRAY);
    display->drawRoundRect(BAR_X, BAR_TOP, BAR_W, BAR_HEIGHT, 8, WHITE);
    display->fillRoundRect(BAR_X + 4, thumbY, BAR_W - 8, thumbHeight, 6, CYAN);
    display->fillTriangle(206, 55, 234, 55, 220, 43, WHITE);
    display->fillTriangle(206, 205, 234, 205, 220, 217, WHITE);
  }
}
void drawScreen() { if (screen == CITY_SCREEN) drawCityScreen(); else if (screen == WIFI_SCREEN) drawWifiScreen(); else if (screen == WIFI_PASSWORD_SCREEN) drawWifiPasswordScreen(); else drawStationScreen(); }

void updatePanelGlow() {
  if (!playing || !glowEnabled) {
    if (glowIsOn) {
      panelGlow.clear();
      panelGlow.show();
      glowIsOn = false;
    }
    return;
  }
  if (millis() - lastGlowUpdate < 50) return;
  lastGlowUpdate = millis();

  // ESP32-audioI2S supplies the average left/right music level as two 0..255 values.
  uint16_t vu = audio.getVUlevel();
  uint8_t left = vu >> 8;
  uint8_t right = vu & 0xFF;
  uint8_t musicLevel = max(left, right);
  // A little smoothing keeps the glow fluid while preserving visible beats.
  glowLevel = (glowLevel * 2 + musicLevel) / 3;
  uint8_t brightness = constrain(4 + (uint16_t(glowLevel) * glowBrightness) / 255, 4, glowBrightness);
  glowHue += 350 + glowLevel * 7;
  panelGlow.setPixelColor(0, panelGlow.gamma32(panelGlow.ColorHSV(glowHue, 200, brightness)));
  panelGlow.show();
  glowIsOn = true;
}
void drawAnalogHand(int centerX, int centerY, float angle, int length, uint16_t color, uint8_t width) {
  float radians = (angle - 90.0f) * PI / 180.0f;
  int endX = centerX + int(cosf(radians) * length);
  int endY = centerY + int(sinf(radians) * length);
  for (int offset = -int(width) / 2; offset <= int(width) / 2; ++offset) {
    display->drawLine(centerX + offset, centerY, endX + offset, endY, color);
  }
}
void drawAnalogClock() {
  const int centerX = 120, centerY = 158, radius = 103;
  display->fillScreen(BLACK);
  display->drawCircle(centerX, centerY, radius, CYAN);
  display->drawCircle(centerX, centerY, radius - 2, GRAY);
  for (uint8_t mark = 0; mark < 60; ++mark) {
    float radians = (mark * 6.0f - 90.0f) * PI / 180.0f;
    int outerX = centerX + int(cosf(radians) * (radius - 7));
    int outerY = centerY + int(sinf(radians) * (radius - 7));
    int innerRadius = radius - (mark % 5 == 0 ? 17 : 11);
    int innerX = centerX + int(cosf(radians) * innerRadius);
    int innerY = centerY + int(sinf(radians) * innerRadius);
    display->drawLine(innerX, innerY, outerX, outerY, mark % 5 == 0 ? WHITE : GRAY);
  }
  const char *numbers[] = {"12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"};
  for (uint8_t number = 0; number < 12; ++number) {
    float radians = (number * 30.0f - 90.0f) * PI / 180.0f;
    int x = centerX + int(cosf(radians) * 75);
    int y = centerY + int(sinf(radians) * 75) - 4;
    display->setTextSize(1); display->setTextColor(WHITE);
    int16_t x1, y1; uint16_t w, h;
    display->getTextBounds(numbers[number], 0, 0, &x1, &y1, &w, &h);
    display->setCursor(x - w / 2, y); display->print(numbers[number]);
  }
  time_t now = time(nullptr); struct tm timeInfo;
  if (now > 1700000000 && localtime_r(&now, &timeInfo)) {
    float minuteAngle = timeInfo.tm_min * 6.0f;
    float hourAngle = (timeInfo.tm_hour % 12) * 30.0f + timeInfo.tm_min * 0.5f;
    drawAnalogHand(centerX, centerY, hourAngle, 48, WHITE, 5);
    drawAnalogHand(centerX, centerY, minuteAngle, 70, CYAN, 3);
    drawAnalogHand(centerX, centerY, timeInfo.tm_sec * 6.0f, 82, YELLOW, 1);
    lastAnalogSecond = timeInfo.tm_sec;
  } else {
    centered("Waiting for time", 285, YELLOW, 1);
  }
  display->fillCircle(centerX, centerY, 5, YELLOW);
  centered("Wi-Fi Radio", 20, GREEN, 2);
  centered("Touch screen to return", 298, GRAY, 1);
}
void drawScreenSaver() {
  lastAnalogClockFrame = 0;
  lastAnalogSecond = -1;
  drawAnalogClock();
}
void animateAnalogClock() {
  if (!screenSaverActive || displaySleeping || millis() - lastAnalogClockFrame < 200) return;
  lastAnalogClockFrame = millis();
  time_t now = time(nullptr); struct tm timeInfo;
  if (now <= 1700000000 || !localtime_r(&now, &timeInfo) || timeInfo.tm_sec == lastAnalogSecond) return;

  const int centerX = 120, centerY = 158;
  // Erase only the old thin seconds hand. Its 82-pixel length stays inside the
  // hour numerals and tick marks, so the fixed dial is never redrawn or flashed.
  if (lastAnalogSecond >= 0) drawAnalogHand(centerX, centerY, lastAnalogSecond * 6.0f, 82, BLACK, 1);
  float minuteAngle = timeInfo.tm_min * 6.0f;
  float hourAngle = (timeInfo.tm_hour % 12) * 30.0f + timeInfo.tm_min * 0.5f;
  // Restore the two hands in case the old seconds hand crossed them, then draw its new position.
  drawAnalogHand(centerX, centerY, hourAngle, 48, WHITE, 5);
  drawAnalogHand(centerX, centerY, minuteAngle, 70, CYAN, 3);
  drawAnalogHand(centerX, centerY, timeInfo.tm_sec * 6.0f, 82, YELLOW, 1);
  display->fillCircle(centerX, centerY, 5, YELLOW);
  lastAnalogSecond = timeInfo.tm_sec;
}
void wakeDisplay() {
  if (displaySleeping) {
    digitalWrite(LCD_BL, HIGH);
    displaySleeping = false;
  }
  if (screenSaverActive) {
    screenSaverActive = false;
    // The screen saver replaced the scene, so the restored page needs one full draw.
    renderedScreen = static_cast<Screen>(255);
    drawScreen();
  }
  lastDisplayActivity = millis();
}
void sleepDisplayIfIdle() {
  uint32_t idle = millis() - lastDisplayActivity;
  if (!screenSaverActive && idle >= SCREEN_SAVER_MS) {
    screenSaverActive = true;
    drawScreenSaver();
  }
  if (!displaySleeping && idle >= DISPLAY_SLEEP_MS) {
    digitalWrite(LCD_BL, LOW);
    displaySleeping = true;
  }
}
bool readTouch(uint16_t &x, uint16_t &y) {
  Wire.beginTransmission(0x38); Wire.write(2);
  if (Wire.endTransmission(false) != 0 || Wire.requestFrom(0x38, 5) != 5) return false;
  if (!(Wire.read() & 15)) return false;
  uint8_t a = Wire.read(), b = Wire.read(), c = Wire.read(), d = Wire.read(); x = ((a & 15) << 8) | b; y = ((c & 15) << 8) | d;
  return x < 240 && y < 320;
}
void saveSettings() {
  preferences.putUChar("city", cityIndex);
  preferences.putUChar("station", stationIndex);
  preferences.putUChar("volume", volume);
  preferences.putUChar("sleepMin", sleepTimerMinutes);
}
void setSleepTimer(uint8_t minutes) {
  sleepTimerMinutes = minutes;
  sleepEndsAt = minutes ? millis() + uint32_t(minutes) * 60000UL : 0;
  status = minutes ? String("Sleep timer: ") + minutes + " min" : "Sleep timer off";
  saveSettings();
}
void updateClockAndSleepTimer() {
  if (!screenSaverActive && !displaySleeping && millis() - lastClockUpdate >= 1000) { lastClockUpdate = millis(); drawClock(); }
  if (sleepTimerMinutes && sleepEndsAt && int32_t(millis() - sleepEndsAt) >= 0) {
    audio.stopSong(); playing = false; reconnectPending = false; sleepTimerMinutes = 0; sleepEndsAt = 0;
    status = "Sleep timer ended - radio off"; saveSettings(); drawScreen();
  }
}

void selectRelativeStation(int8_t direction) {
  const City &city = cities[cityIndex];
  if (!city.count) { status = "No saved favorites"; drawScreen(); return; }
  int next = int(stationIndex) + direction;
  if (next < 0) next = city.count - 1;
  if (next >= city.count) next = 0;
  selectStation(static_cast<uint8_t>(next));
  screen = STATION_SCREEN;
  startStation();
}

class BleServerCallbacks : public NimBLEServerCallbacks {
  void onConnect(NimBLEServer *, NimBLEConnInfo &) override { bleConnected = true; bleAdvertising = false; bleStatus = "Phone connected"; }
  void onDisconnect(NimBLEServer *, NimBLEConnInfo &, int) override {
    bleConnected = false; bleStatus = bleEnabled ? "Waiting for a phone" : "Bluetooth is off";
    if (bleEnabled) { NimBLEDevice::getAdvertising()->start(); bleAdvertising = true; }
  }
};
class BleControlCallbacks : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *characteristic, NimBLEConnInfo &) override {
    std::string value = characteristic->getValue(); bleCommand = String(value.c_str()); bleCommand.trim(); bleCommand.toUpperCase();
  }
};
void publishBluetoothStatus() {
  if (bleStatusCharacteristic) {
    String state = String("radio=") + (playing ? "playing" : "stopped") + ";volume=" + volume + ";wifi=" + (wifiReady ? "connected" : "offline");
    bleStatusCharacteristic->setValue(state.c_str()); if (bleConnected) bleStatusCharacteristic->notify();
  }
}
void setupBluetooth() {
  if (bleInitialized) return;
  NimBLEDevice::init(BLE_DEVICE_NAME); bleServer = NimBLEDevice::createServer(); bleServer->setCallbacks(new BleServerCallbacks());
  NimBLEService *service = bleServer->createService(BLE_SERVICE_UUID);
  NimBLECharacteristic *control = service->createCharacteristic(BLE_CONTROL_UUID, NIMBLE_PROPERTY::WRITE);
  control->setCallbacks(new BleControlCallbacks());
  bleStatusCharacteristic = service->createCharacteristic(BLE_STATUS_UUID, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);
  service->start(); NimBLEDevice::getAdvertising()->addServiceUUID(BLE_SERVICE_UUID); NimBLEDevice::getAdvertising()->setName(BLE_DEVICE_NAME);
  bleInitialized = true;
}
void setBluetoothEnabled(bool enabled) {
  if (enabled) setupBluetooth();
  bleEnabled = enabled; preferences.putBool("bleOn", bleEnabled);
  if (bleEnabled) { NimBLEDevice::getAdvertising()->start(); bleAdvertising = true; bleStatus = "Visible as ESP32 Radio"; }
  else { NimBLEDevice::getAdvertising()->stop(); bleAdvertising = false; bleConnected = false; bleStatus = "Bluetooth is off"; }
  publishBluetoothStatus();
}
void processBluetoothCommand() {
  if (!bleCommand.length()) return; String command = bleCommand; bleCommand = "";
  if (command == "PLAY") { if (!playing) startStation(); }
  else if (command == "PAUSE") { if (playing) { audio.pauseResume(); playing = false; status = "Paused from Bluetooth"; } }
  else if (command == "STOP") { audio.stopSong(); playing = false; status = "Stopped from Bluetooth"; }
  else if (command == "NEXT") selectRelativeStation(1);
  else if (command == "PREVIOUS") selectRelativeStation(-1);
  else if (command == "VOLUME+") { if (volume < 21) ++volume; audio.setVolume(volume); saveSettings(); }
  else if (command == "VOLUME-") { if (volume > 0) --volume; audio.setVolume(volume); saveSettings(); }
  else { bleStatus = "Unknown command"; publishBluetoothStatus(); return; }
  bleStatus = String("Command: ") + command; publishBluetoothStatus();
  if (screen != BLUETOOTH_SCREEN) drawScreen(); else drawBluetoothScreen();
}

void startStation() {
  if (!cities[cityIndex].count) { status = "No saved favorites"; drawScreen(); return; }
  if (!wifiReady) { status = "Wi-Fi is not connected"; drawScreen(); return; }
  const Station &station = cities[cityIndex].stations[stationIndex]; reconnectPending = false; audio.stopSong(); audio.setVolume(volume); audio.connecttohost(station.url);
  playing = true; status = String("Playing ") + station.name; saveSettings(); drawStationScreen();
}
void audio_eof_stream(const char *info) { (void)info; if (playing) reconnectPending = true; }
void handleTap() {
  uint16_t x, y;
  if (!readTouch(x, y)) {
    touchHeld = false;
    return;
  }
  if (touchHeld || millis() - lastTap < 120) return;
  touchHeld = true;
  lastTap = millis();
  if (displaySleeping || screenSaverActive) { wakeDisplay(); return; }
  lastDisplayActivity = lastTap;
  if (screen == CITY_SCREEN) {
    if (x >= 204 && y < 37) { screen = WIFI_SCREEN; beginWifiScan(); }
    else if (y >= 282 && y < 320) {
      if (x < 120 && volume > 0) --volume;
      if (x >= 120 && volume < 21) ++volume;
      audio.setVolume(volume);
      saveSettings();
      drawCityScreen();
    } else if (y >= 58 && y < 58 + CITY_COUNT * 37) {
      cityIndex = (y - 58) / 37;
      selectStation(0); screen = STATION_SCREEN; status = "Choose a station"; drawStationScreen();
    }
    return;
  }
  if (screen == WIFI_SCREEN) {
    if (y >= 250) { if (x < 120) beginWifiScan(); else { screen = CITY_SCREEN; drawCityScreen(); } }
    else if (wifiNetworkCount && x >= 202 && y >= 58 && y < 243) {
      uint8_t pages = (wifiNetworkCount + 4) / 5;
      if (y < 82 && wifiPage) --wifiPage;
      else if (y > 218 && wifiPage + 1 < pages) ++wifiPage;
      else if (pages > 1) { wifiPage = uint32_t(y - 58) * pages / 185; if (wifiPage >= pages) wifiPage = pages - 1; }
      drawWifiScreen();
    } else if (y >= 58 && y < 243) {
      uint8_t selected = wifiPage * 5 + (y - 58) / 37;
      if (selected < wifiNetworkCount) { selectedWifiIndex = selected; pendingWifiSsid = wifiNetworks[selected]; pendingWifiPassword = ""; screen = WIFI_PASSWORD_SCREEN; drawWifiPasswordScreen(); }
    }
    return;
  }
  if (screen == WIFI_PASSWORD_SCREEN) {
    if (x >= 204 && y < 37) { screen = WIFI_SCREEN; drawWifiScreen(); return; }
    if (y >= 270) { if (x < 120) { pendingWifiPassword = ""; screen = WIFI_SCREEN; drawWifiScreen(); } else startSelectedWifi(); return; }
    if (y >= 226 && y < 262) { if (pendingWifiPassword.length()) pendingWifiPassword.remove(pendingWifiPassword.length() - 1); drawWifiPasswordScreen(); return; }
    const char *rows[] = {"1234567890", "QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"};
    if (y >= 82 && y < 222) {
      uint8_t row = (y - 82) / 35; uint8_t len = strlen(rows[row]); uint8_t col = min<uint8_t>(len - 1, (x > 8 ? x - 8 : 0) / (224 / len));
      if (pendingWifiPassword.length() < 63) pendingWifiPassword += rows[row][col]; drawWifiPasswordScreen();
    }
    return;
  }
  if (x >= 204 && y < 37) { screen = WIFI_SCREEN; beginWifiScan(); return; }
  const City &city = cities[cityIndex];
  if ((cityIndex == 0 || cityIndex == 4) && x >= 200 && y >= 40 && y < 220 && pageCount() > 1) {
    // Wide 40-pixel strip: the arrow zones move one screenful; tapping the centre jumps through the list.
    if (y < 60) {
      if (stationPage > 0) --stationPage;
    } else if (y >= 200) {
      if (stationPage + 1 < pageCount()) ++stationPage;
    } else {
      stationPage = (uint32_t(y - 60) * pageCount()) / 140;
      if (stationPage >= pageCount()) stationPage = pageCount() - 1;
    }
    drawStationScreen();
  }
  else if (y >= 60 && y < 200) { uint8_t selected = stationPage * STATIONS_PER_PAGE + (y - 60) / 35; if (selected < city.count) { selectStation(selected); startStation(); } }
  else if (y >= 205 && y < 241) { if (playing) { audio.pauseResume(); playing = false; status = "Paused"; drawStationScreen(); } else startStation(); }
  else if (y >= 250 && y < 284) { if (x < 120 && volume > 0) --volume; if (x >= 120 && volume < 21) ++volume; audio.setVolume(volume); saveSettings(); drawStationScreen(); }
  // BACK only returns to the city list; it must not stop the station that is already playing.
  else if (y >= 289 && x < 60) { screen = CITY_SCREEN; status = playing ? String("Playing ") + cities[cityIndex].stations[stationIndex].name : "Pick a city"; drawCityScreen(); }
  else if (y >= 289 && x < 135) {
    if (cityIndex == 0) {
      removeFavorite(stationIndex);
      if (favoriteCount == 0) { screen = CITY_SCREEN; status = "Favorites are empty"; drawCityScreen(); }
      else { if (stationIndex >= favoriteCount) selectStation(favoriteCount - 1); drawStationScreen(); }
    } else {
      addCurrentToFavorites();
      drawStationScreen();
    }
  }
  else if (y >= 289) { if (sleepTimerMinutes == 0) setSleepTimer(15); else if (sleepTimerMinutes == 15) setSleepTimer(30); else if (sleepTimerMinutes == 30) setSleepTimer(60); else if (sleepTimerMinutes == 60) setSleepTimer(90); else setSleepTimer(0); drawStationScreen(); }
}
void applyRequestedWifiProvisioning() {
  if (preferences.getString("wifiProvision", "") != PROVISIONED_WIFI_MARKER) {
    preferences.putString("wifiSsid", PROVISIONED_WIFI_SSID);
    preferences.putString("wifiPass", PROVISIONED_WIFI_PASSWORD);
    preferences.putString("wifiProvision", PROVISIONED_WIFI_MARKER);
  }
}

void connectToLocalWiFi() {
  String savedSsid = preferences.getString("wifiSsid", "");
  String savedPass = preferences.getString("wifiPass", "");
  if (!savedSsid.length()) {
    status = "Choose Wi-Fi on this screen";
    return;
  }
  Serial.printf("Connecting to saved Wi-Fi: %s\n", savedSsid.c_str());
  status = String("Connecting to ") + savedSsid;
  WiFi.disconnect(false, false);
  WiFi.begin(savedSsid.c_str(), savedPass.c_str());
}

void updateWiFi() {
  if (millis() - lastWifiCheck < 1000) return;
  lastWifiCheck = millis();
  bool connected = WiFi.status() == WL_CONNECTED;

  if (wifiConnecting && connected) {
    preferences.putString("wifiSsid", pendingWifiSsid);
    preferences.putString("wifiPass", pendingWifiPassword);
    wifiConnecting = false; pendingWifiPassword = "";
  } else if (wifiConnecting && millis() - wifiConnectStartedAt >= 15000) {
    wifiConnecting = false; pendingWifiPassword = "";
    status = "Password did not work — enter it again";
    screen = WIFI_PASSWORD_SCREEN; drawWifiPasswordScreen();
  }

  if (connected != wifiReady) {
    wifiReady = connected;
    if (connected) {
      status = String("Connected: ") + WiFi.SSID();
      Serial.printf("Connected to %s, IP address: %s\n", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());
      // Start time synchronisation only after the network is connected. It runs in
      // the background and lets the header clock change from --:-- to local time.
      configTzTime("EET-2EEST,M3.5.0/3,M10.5.0/4", "time.google.com", "pool.ntp.org");
      if (autoStartPending) {
        autoStartPending = false;
        screen = STATION_SCREEN;
        startStation();
      }
    } else {
      status = "Wi-Fi connection lost — reconnecting";
      audio.stopSong();
      playing = false;
      lastOpenNetworkScan = millis();
    }
    drawScreen();
  }

  if (!connected && millis() - lastOpenNetworkScan >= 10000) {
    lastOpenNetworkScan = millis();
    connectToLocalWiFi();
    drawScreen();
  }
}
void setup() {
  Serial.begin(115200);
  panelGlow.begin();
  panelGlow.clear();
  panelGlow.show();
  pinMode(LCD_BL, OUTPUT); digitalWrite(LCD_BL, HIGH); pinMode(AUDIO_EN, OUTPUT); digitalWrite(AUDIO_EN, LOW);
  display->begin(40000000); display->invertDisplay(false); Wire.begin(TOUCH_SDA, TOUCH_SCL, 400000); Wire.setTimeOut(20); setupCodec();
  preferences.begin("yoradio", false);
  applyRequestedWifiProvisioning();
  loadFavorites();
  cityIndex = preferences.getUChar("city", 0); if (cityIndex >= CITY_COUNT) cityIndex = 0;
  stationIndex = preferences.getUChar("station", 0); if (stationIndex >= cities[cityIndex].count) stationIndex = 0;
  stationPage = stationIndex / STATIONS_PER_PAGE;
  volume = preferences.getUChar("volume", 12); if (volume > 21) volume = 12;
  sleepTimerMinutes = preferences.getUChar("sleepMin", 0); if (sleepTimerMinutes != 15 && sleepTimerMinutes != 30 && sleepTimerMinutes != 60 && sleepTimerMinutes != 90) sleepTimerMinutes = 0;
  glowEnabled = preferences.getBool("glowOn", true);
  glowBrightness = preferences.getUChar("glowBr", 128); if (glowBrightness < 8) glowBrightness = 128;
  bleEnabled = false;
  if (sleepTimerMinutes) sleepEndsAt = millis() + uint32_t(sleepTimerMinutes) * 60000UL;
  // This N8 board has no PSRAM. A 24 KB compressed-audio buffer leaves enough contiguous
  // internal RAM for MP3 decoder work buffers, preventing decoder allocation failures and resets.
  audio.setBufsize(24576, 0);
  audio.setPinout(AUDIO_BCLK, AUDIO_LRCK, AUDIO_DOUT, AUDIO_MCLK);
  // Some public stations are offline or negotiate an unsupported stream format.
  // Keep their failed connection from blocking the touch screen for a long time.
  audio.setConnectionTimeout(2500, 3500);
  audio.setVolume(volume);
  WiFi.mode(WIFI_STA);
  // Start the station connection immediately from saved credentials. On a new
  // radio, open the on-device Wi-Fi list instead; no phone is required.
  if (preferences.getString("wifiSsid", "").length()) {
    connectToLocalWiFi();
  } else {
    screen = WIFI_SCREEN;
    beginWifiScan();
  }
  lastOpenNetworkScan = millis();
  // Time synchronisation starts after Wi-Fi reports a completed connection in updateWiFi().
  lastDisplayActivity = millis();
  if (screen == CITY_SCREEN) drawCityScreen();
}
void loop() {
  audio.loop(); handleTap(); updateWiFi();
  // Give the decoder time to release its previous stream before reconnecting.
  // Rapid reconnect loops can fragment the small internal heap on this no-PSRAM board.
  if (reconnectPending && playing && wifiReady && millis() - lastReconnect > 5000) { reconnectPending = false; lastReconnect = millis(); status = "Reconnecting..."; drawStationScreen(); audio.stopSong(); delay(100); audio.connecttohost(cities[cityIndex].stations[stationIndex].url); }
  updateClockAndSleepTimer();
  sleepDisplayIfIdle();
  animateAnalogClock();
  updatePanelGlow();
  delay(1);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik