Community project

Internet Radio And Flight Radar

ESP32
Photo of Internet Radio And Flight Radar
Generated with AI

bn sharma

Published October 31, 2026

This project turns an ESP32-powered display panel into a portable internet radio and flight radar receiver. The device streams MP3 radio stations over Wi-Fi and displays real-time aircraft tracking data on a 10.1-inch touchscreen, all powered by a compact 10,000 mAh USB-C battery pack. The lean firmware build fits within the ESP32's memory constraints while retaining full radio and radar functionality.

The guide includes a complete wiring diagram showing how to connect the power bank to the display panel, a full parts list, step-by-step assembly instructions, and the optimized firmware code. Builders will learn how to set up Wi-Fi connectivity through a simple captive portal, configure radio station streams, and keep the device secure during transport. The project demonstrates efficient embedded systems design by hand-coding JSON parsing and HTTP helpers to minimize code size.

Wiring diagram

Loading diagram…

Gather all the parts

QtyComponent
1

10,000 mAh USB-C Power Bank (5 V, 3 A minimum)

10,000 mAh; USB-C output rated 5 V / 3 A or higher

A rechargeable USB battery pack that powers the CrowPanel through its normal USB-C power socket away from a wall outlet.

Assemble it in 3 steps

1. Choose a suitable battery pack

Use a rechargeable USB-C power bank rated for at least 5 V and 3 A, with about 10,000 mAh capacity. This larger battery is needed because the 10.1-inch screen uses much more power than a small phone accessory.

  • A power bank marked 20 W or higher normally provides enough output power.
  • A 10,000 mAh pack is estimated to run the panel for about 4 hours; screen brightness and Wi-Fi use can make this shorter.
  • Do not use a loose lithium battery, bare battery wires, or a small 1 A USB supply — insufficient power can make the screen flicker, reset, or refuse to start.

2. Connect the portable power

Plug a good-quality USB-C to USB-C cable from the power bank’s output into the CrowPanel USB-C power socket. The same cable carries USB-C 5V → 5V (power) and USB-C GND → GND (ground), so no internal panel wiring is needed.

  • Press the power-bank button if it does not start automatically.
  • Use a short, undamaged cable rated for 3 A or more.
  • Do not plug the power bank into a USB socket meant only for data or into the panel’s separate programming connection if it has one; use the normal USB-C power input.

3. Keep the panel safe while carrying it

Place the panel and power bank in a case or on a firm stand. Leave space around the panel for cooling and route the USB-C cable so it cannot be pulled sideways from the socket.

  • Turn the screen brightness down when full brightness is not needed to extend battery time.
  • Do not put the battery pack under the screen or cover either device with fabric; trapped heat can damage the battery or panel.

Review all connections

1. Connections between "powerbank_1" and "ESP32"

Functionpowerbank_1ESP32
powerUSB-C 5V → 5 V power delivered through a USB-C cable to the CrowPanel USB-C power input5V
groundUSB-C GND → Ground return carried in the same USB-C cableGND

Deploy the firmware

// CHADAMI NEON HINDI RADIO (lean build) - Elecrow CrowPanel Advanced 10.1" (1024x600) ESP32-P4
//
// WHY "LEAN": the build service gives the app a fixed 1,310,720-byte slot. The previous build was 1,349,552
// bytes and the panel could not boot. This version drops three heavy libraries and keeps every feature:
//   - ArduinoJson   -> small hand-written JSON scanner
//   - HTTPClient    -> small raw HTTP/1.0 GET helper
//   - WebServer/DNS -> tiny raw Wi-Fi setup page (open http://192.168.4.1 after joining CHADAMI-SETUP)
//
// After the build, look for the line "Total image size: N bytes". N must be BELOW 1310720.
// If it is still too big, set USE_TLS to 0 below (removes HTTPS: no market/weather, https-only stations skipped).
//
// libraries needed: ESP32_Display_Panel (you already have it) and pschatzmann/arduino-libhelix.
// ArduinoJson is no longer needed.

#include <Arduino.h>
#include <limits.h>
#include <math.h>
#include <esp_heap_caps.h>
#include <ESP_Panel_Library.h>
#include <WiFi.h>
#include <Preferences.h>
#include <time.h>
#include "driver/i2s_std.h"
#include "MP3DecoderHelix.h"
using namespace libhelix;

#define USE_TLS 1                 // 1 = HTTPS (station list, market, weather, https streams). 0 = smallest build.
#if USE_TLS
#include <WiFiClientSecure.h>
#endif

// ---- Speaker amplifier ----
// Elecrow sources disagree on which level turns the amplifier on, so the first start-up plays TWO test beeps:
//   beep 1 (LOW pitch)  = amplifier pin HIGH
//   beep 2 (HIGH pitch) = amplifier pin LOW
// Whichever beep you hear tells you the right level. Set AMP_ON_LEVEL to it, then set AMP_TEST to 0.
#define AMP_ON_LEVEL LOW
#define AMP_TEST 1

// Optional: put your Wi-Fi here to skip the phone setup page (leave "" to use the phone page).
#define DEFAULT_WIFI_SSID ""
#define DEFAULT_WIFI_PASS ""

// Pins from Elecrow's CrowPanel Advanced 10.1" P4 wiki: LRCLK=21, BCLK=22, SDATA=23, amplifier enable=30.
static constexpr int I2S_BCLK = 22;
static constexpr int I2S_LRC  = 21;
static constexpr int I2S_DOUT = 23;
static constexpr int AMP_ENABLE_PIN = 30;

static constexpr int LCD_POWER_PIN = 29;
static constexpr int LCD_BACKLIGHT_PIN = 31;
static constexpr uint16_t SCREEN_W = 1024;
static constexpr uint16_t SCREEN_H = 600;

// ---------------- Stations (filled from the internet) ----------------
struct Station { char name[40]; char url[160]; uint16_t bitrate; char codec[8]; };
static constexpr int MAX_STATIONS = 120;
static Station *stationsArr = nullptr;           // lives in PSRAM
static volatile int stationCount = 0;
static volatile int loadState = 0;               // 0 waiting, 1 loading, 2 ok, 3 failed
static constexpr int TILES_PER_PAGE = 8;

ESP_Panel *panel = nullptr;
static uint8_t *frame = nullptr;
static int selected = 0;
static int stationPage = 0;
static volatile bool playing = false;            // user asked for playback
static bool muted = false;
static int volumeLevel = 70;
static char titleBuf[100] = "";                  // current song title from the stream (ICY metadata)
static char errBuf[48] = "";
static String logLine = "SYSTEM BOOT OK";
static volatile bool dirty = true;
static unsigned long lastAnim = 0;
static uint8_t bars[20];
static volatile int audioLevel = 0;              // 0..100 measured from the real audio data

static bool touchWasDown = false;
static uint16_t touchStartX = 0, touchStartY = 0, touchLastX = 0, touchLastY = 0;

static WiFiServer portal(80);
static Preferences wifiPrefs;
static bool setupPortalOn = false;
static bool wifiJoining = false;
static volatile bool wifiOnline = false;
static String connectedSsid = "";
static unsigned long wifiJoinStarted = 0;
static bool scanRunning = false;
static int scanCount = -2;
static unsigned long nextScanAt = 0;
static String scanNames[20];
static int scanChannels[20];
static int scanRssi[20];

// Live dashboard data. Values are only labelled live after a successful network fetch.
static String clockText = "--:--:--";
static String dateText = "SYNCING CLOCK";
#if USE_TLS
static String sensexText = "SENSEX: LOADING";
static String niftyText = "NIFTY 50: LOADING";
static String weatherText = "REWA MP: LOADING";
#else
static String sensexText = "MARKET: OFF IN THIS BUILD";
static String niftyText = "(NEEDS HTTPS)";
static String weatherText = "WEATHER: OFF";
#endif
static uint32_t marketColour = 0;
static uint32_t weatherColour = 0;
static unsigned long lastClockDraw = 0;

static int pageCount() { const int n = stationCount; return n <= 0 ? 1 : (n + TILES_PER_PAGE - 1) / TILES_PER_PAGE; }
static void setLog(const String &s) { logLine = s; dirty = true; Serial.println("CHADAMI RADIO: " + s); }

// =====================================================================
//                          AUDIO ENGINE
//   netTask  (core 0): connects to the stream, strips ICY metadata, fills a PSRAM ring buffer
//   audioTask(core 1): pulls MP3 bytes from the ring, decodes with Helix, writes PCM to I2S
// =====================================================================
enum StreamState { ST_IDLE = 0, ST_CONNECTING, ST_BUFFERING, ST_PLAYING, ST_ERROR };
static volatile int streamState = ST_IDLE;
static volatile int cmdPlay = -1;
static volatile bool cmdStop = false;
static volatile bool cmdClick = false;
static volatile int clickPos = -1;               // >= 0 while a click is mixed into the running stream
static volatile bool netDone = false;            // connection ended
static volatile bool flushReq = false;           // ask audio task to empty ring + reset decoder

static i2s_chan_handle_t i2sTx = nullptr;
static uint32_t i2sRate = 44100;

static constexpr size_t RING_SIZE = 320 * 1024;
static constexpr size_t PREBUFFER = 40 * 1024;   // start playing once this much is buffered
static uint8_t *ringBuf = nullptr;
static volatile size_t rHead = 0, rTail = 0;
static inline size_t ringUsed() { return (rHead + RING_SIZE - rTail) % RING_SIZE; }
static inline size_t ringFree() { return RING_SIZE - 1 - ringUsed(); }
static void ringPush(const uint8_t *d, size_t n) {
  size_t h = rHead; const size_t first = min(n, RING_SIZE - h);
  memcpy(ringBuf + h, d, first); if (n > first) memcpy(ringBuf, d + first, n - first);
  __sync_synchronize(); rHead = (h + n) % RING_SIZE;
}
static size_t ringPop(uint8_t *d, size_t maxN) {
  const size_t n = min(maxN, ringUsed()); if (!n) return 0;
  size_t t = rTail; const size_t first = min(n, RING_SIZE - t);
  memcpy(d, ringBuf + t, first); if (n > first) memcpy(d + first, ringBuf, n - first);
  __sync_synchronize(); rTail = (t + n) % RING_SIZE; return n;
}

static void requestClick() { cmdClick = true; }
static void applyVolume() { dirty = true; }

static void setError(const char *msg) {
  strncpy(errBuf, msg, sizeof(errBuf) - 1); errBuf[sizeof(errBuf) - 1] = 0;
  streamState = ST_ERROR; playing = false; audioLevel = 0;
  setLog(String("STREAM FAILED: ") + msg);
}

// ---- I2S output ----
static bool i2sStart() {
  i2s_chan_config_t cc = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_AUTO, I2S_ROLE_MASTER);
  cc.dma_desc_num = 8; cc.dma_frame_num = 512; cc.auto_clear = true;
  if (i2s_new_channel(&cc, &i2sTx, nullptr) != ESP_OK) return false;
  i2s_std_config_t sc = {
    .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(44100),
    .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
    .gpio_cfg = { .mclk = I2S_GPIO_UNUSED, .bclk = (gpio_num_t)I2S_BCLK, .ws = (gpio_num_t)I2S_LRC, .dout = (gpio_num_t)I2S_DOUT, .din = I2S_GPIO_UNUSED,
                  .invert_flags = { .mclk_inv = false, .bclk_inv = false, .ws_inv = false } },
  };
  if (i2s_channel_init_std_mode(i2sTx, &sc) != ESP_OK) return false;
  return i2s_channel_enable(i2sTx) == ESP_OK;
}
static void i2sSetRate(uint32_t r) {
  if (r == i2sRate || r < 8000 || r > 48000) return;
  i2s_std_clk_config_t clk = I2S_STD_CLK_DEFAULT_CONFIG(r);
  i2s_channel_disable(i2sTx); i2s_channel_reconfig_std_clock(i2sTx, &clk); i2s_channel_enable(i2sTx);
  i2sRate = r;
}

// Click played directly through I2S when no stream is running.
static void playIdleClick() {
  if (!i2sTx || muted) return;
  static int16_t cb[2400 * 2];
  int n = i2sRate / 20; if (n > 2400) n = 2400;
  const float vg = volumeLevel / 100.0f; const float cg = max(vg * vg, 0.25f);
  for (int i = 0; i < n; ++i) {
    const float t = (float)i / i2sRate;
    const int16_t v = (int16_t)(sinf(6.2831853f * 1800.0f * t) * expf(-t * 90.0f) * 9000.0f * cg);
    cb[i * 2] = v; cb[i * 2 + 1] = v;
  }
  size_t w = 0; i2s_channel_write(i2sTx, cb, n * 4, &w, pdMS_TO_TICKS(200));
}

// ---- Decoder callback: gets decoded PCM, applies volume, mixes the click, writes to I2S ----
static int16_t outBuf[2304 * 2];
static void onPcm(MP3FrameInfo &info, short *pcm, size_t len, void *) {
  if (!i2sTx || len == 0) return;
  if (info.samprate > 0 && (uint32_t)info.samprate != i2sRate) i2sSetRate(info.samprate);
  const int ch = info.nChans >= 2 ? 2 : 1;
  size_t frames = len / ch; if (frames > 2304) frames = 2304;
  static bool firstBlock = true;
  if (firstBlock) { firstBlock = false; Serial.printf("CHADAMI RADIO: first audio block rate=%d ch=%d frames=%u\n", (int)info.samprate, ch, (unsigned)frames); }
  const float vg = volumeLevel / 100.0f; const float g = muted ? 0.0f : vg * vg;
  const float cg = muted ? 0.0f : max(g, 0.25f);
  long acc = 0;
  for (size_t i = 0; i < frames; ++i) {
    const float l = pcm[i * ch], r = (ch == 2) ? pcm[i * ch + 1] : l;
    int L = (int)(l * g), R = (int)(r * g);
    acc += abs(L);
    if (clickPos >= 0) {
      const float t = clickPos / (float)i2sRate;
      const int add = (int)(sinf(6.2831853f * 1800.0f * t) * expf(-t * 90.0f) * 7000.0f * cg);
      L += add; R += add;
      if (++clickPos >= (int)(i2sRate / 20)) clickPos = -1;
    }
    outBuf[i * 2] = (int16_t)constrain(L, -32768, 32767);
    outBuf[i * 2 + 1] = (int16_t)constrain(R, -32768, 32767);
  }
  audioLevel = constrain((int)(acc / (long)max((size_t)1, frames)) / 160, 0, 100);
  if (streamState != ST_PLAYING) { streamState = ST_PLAYING; dirty = true; }
  size_t written = 0; i2s_channel_write(i2sTx, outBuf, frames * 4, &written, portMAX_DELAY);
}

#if AMP_TEST
// Plays a short tone with the amplifier pin forced to the given level.
static void ampBeep(int level, float hz) {
  digitalWrite(AMP_ENABLE_PIN, level);
  vTaskDelay(pdMS_TO_TICKS(150));
  static int16_t b[441 * 2];
  for (int rep = 0; rep < 30; ++rep) {             // about 0.3 s at 44.1 kHz
    for (int i = 0; i < 441; ++i) {
      const float t = (float)(rep * 441 + i) / 44100.0f;
      const int16_t v = (int16_t)(sinf(6.2831853f * hz * t) * 8000.0f);
      b[i * 2] = v; b[i * 2 + 1] = v;
    }
    size_t w = 0; i2s_channel_write(i2sTx, b, sizeof(b), &w, pdMS_TO_TICKS(300));
  }
  vTaskDelay(pdMS_TO_TICKS(250));
}
#endif

static void audioTask(void *) {
  const bool i2sOk = i2sStart();
  Serial.printf("CHADAMI RADIO: I2S %s (BCLK=%d LRC=%d DOUT=%d)\n", i2sOk ? "started" : "FAILED", I2S_BCLK, I2S_LRC, I2S_DOUT);
#if AMP_TEST
  if (i2sOk) {
    Serial.println("CHADAMI RADIO: BEEP 1 (low pitch) = amplifier pin HIGH");
    ampBeep(HIGH, 440.0f);
    Serial.println("CHADAMI RADIO: BEEP 2 (high pitch) = amplifier pin LOW");
    ampBeep(LOW, 880.0f);
  }
#endif
  digitalWrite(AMP_ENABLE_PIN, AMP_ON_LEVEL);
  MP3DecoderHelix mp3(onPcm); mp3.begin();
  bool started = false; static uint8_t chunk[1024];
  for (;;) {
    if (flushReq) { rTail = rHead; mp3.end(); mp3.begin(); started = false; audioLevel = 0; flushReq = false; }
    if (cmdClick) { cmdClick = false; if (streamState == ST_PLAYING) clickPos = 0; else playIdleClick(); }
    const int st = streamState;
    if (st == ST_IDLE || st == ST_ERROR || st == ST_CONNECTING) { vTaskDelay(pdMS_TO_TICKS(10)); continue; }
    const size_t used = ringUsed();
    if (!started) { if (used < PREBUFFER && !netDone) { vTaskDelay(pdMS_TO_TICKS(10)); continue; } started = true; }
    if (used == 0) {
      if (netDone) { setError("STREAM ENDED"); started = false; continue; }
      streamState = ST_BUFFERING; started = false; audioLevel = 0; dirty = true; vTaskDelay(pdMS_TO_TICKS(10)); continue;
    }
    const size_t n = ringPop(chunk, sizeof(chunk));
    if (n) { size_t off = 0; while (off < n) { const size_t w = mp3.write(chunk + off, n - off); if (w == 0) { vTaskDelay(1); break; } off += w; } }
  }
}

// ---- Network side ----
static WiFiClient plainClient;
#if USE_TLS
static WiFiClientSecure secureClient;
#endif
static Client *cl = nullptr;
static bool streaming = false;
static int metaInt = 0, toMeta = 0, metaState = 0, metaLen = 0, metaPos = 0;
static char metaBuf[4112];

static void closeClient() { if (cl) { cl->stop(); cl = nullptr; } streaming = false; }

static bool parseUrl(const String &u, bool &tls, String &host, uint16_t &port, String &path) {
  int p;
  if (u.startsWith("https://")) { tls = true; p = 8; port = 443; }
  else if (u.startsWith("http://")) { tls = false; p = 7; port = 80; }
  else return false;
  const int slash = u.indexOf('/', p);
  const String hp = slash < 0 ? u.substring(p) : u.substring(p, slash);
  path = slash < 0 ? String("/") : u.substring(slash);
  const int colon = hp.indexOf(':');
  if (colon >= 0) { host = hp.substring(0, colon); port = hp.substring(colon + 1).toInt(); } else host = hp;
  return host.length() > 0;
}
static bool readLine(Client *c, char *out, size_t maxLen, uint32_t timeoutMs) {
  size_t n = 0; const uint32_t t0 = millis();
  while (millis() - t0 < timeoutMs) {
    if (c->available()) {
      const int ch = c->read(); if (ch < 0) continue;
      if (ch == '\n') { out[n] = 0; return true; }
      if (ch != '\r' && n < maxLen - 1) out[n++] = (char)ch;
    } else { if (!c->connected()) return false; vTaskDelay(pdMS_TO_TICKS(2)); }
  }
  return false;
}

static void parseStreamTitle(const char *meta) {
  const char *p = strstr(meta, "StreamTitle='");
  if (!p) return;
  p += 13; const char *e = strstr(p, "';"); if (!e) e = p + strlen(p);
  char tmp[sizeof(titleBuf)]; size_t n = 0;
  for (; p < e && n < sizeof(tmp) - 1; ++p) {
    char c = *p; if (c >= 'a' && c <= 'z') c -= 32;
    if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == ' ' || c == '-' || c == '.' || c == ':' || c == '/' || c == '+') tmp[n++] = c;
  }
  tmp[n] = 0; strncpy(titleBuf, tmp, sizeof(titleBuf) - 1); titleBuf[sizeof(titleBuf) - 1] = 0; dirty = true;
}

// Splits incoming bytes into audio (to the ring buffer) and ICY metadata (song title).
static void feedStream(const uint8_t *b, int n) {
  int i = 0;
  while (i < n) {
    if (metaInt <= 0) { ringPush(b + i, n - i); return; }
    if (metaState == 0) {
      const int take = min(n - i, toMeta);
      ringPush(b + i, take); i += take; toMeta -= take;
      if (toMeta == 0) metaState = 1;
    } else if (metaState == 1) {
      metaLen = b[i++] * 16; metaPos = 0;
      if (metaLen == 0) { metaState = 0; toMeta = metaInt; } else metaState = 2;
    } else {
      const int take = min(n - i, metaLen - metaPos);
      memcpy(metaBuf + metaPos, b + i, take); metaPos += take; i += take;
      if (metaPos >= metaLen) { metaBuf[metaLen] = 0; parseStreamTitle(metaBuf); metaState = 0; toMeta = metaInt; }
    }
  }
}

static bool connectStream(int idx) {
  String url = stationsArr[idx].url;
  for (int hop = 0; hop < 5; ++hop) {
    bool tls; String host, path; uint16_t port;
    if (!parseUrl(url, tls, host, port, path)) { setError("BAD URL"); return false; }
    closeClient();
    bool ok = false;
    if (tls) {
#if USE_TLS
      secureClient.stop(); secureClient.setInsecure(); secureClient.setTimeout(8); cl = &secureClient; ok = secureClient.connect(host.c_str(), port);
#else
      setError("HTTPS NEEDS TLS BUILD"); return false;
#endif
    } else { plainClient.stop(); cl = &plainClient; ok = plainClient.connect(host.c_str(), port, 8000); }
    if (!ok) { cl = nullptr; setError("CONNECT FAILED"); return false; }
    cl->print(String("GET ") + path + " HTTP/1.0\r\nHost: " + host + "\r\nUser-Agent: ChadamiRadio/1.0\r\nIcy-MetaData: 1\r\nAccept: */*\r\nConnection: close\r\n\r\n");
    char line[300];
    if (!readLine(cl, line, sizeof(line), 10000)) { setError("NO RESPONSE"); closeClient(); return false; }
    int code = 0; const char *sp = strchr(line, ' '); if (sp) code = atoi(sp + 1);
    String location = ""; int mi = 0; bool notMp3 = false;
    while (readLine(cl, line, sizeof(line), 10000)) {
      if (line[0] == 0) break;
      if (!strncasecmp(line, "icy-metaint:", 12)) mi = atoi(line + 12);
      else if (!strncasecmp(line, "location:", 9)) { location = String(line + 9); location.trim(); }
      else if (!strncasecmp(line, "content-type:", 13)) { if (strcasestr(line, "aac") || strcasestr(line, "mp4") || strcasestr(line, "ogg") || strcasestr(line, "mpegurl")) notMp3 = true; }
    }
    if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
      if (!location.length()) { setError("BAD REDIRECT"); closeClient(); return false; }
      if (location.startsWith("/")) location = String(tls ? "https://" : "http://") + host + ":" + port + location;
      url = location; continue;
    }
    if (code != 200) { char m[40]; snprintf(m, sizeof(m), "HTTP %d", code); setError(m); closeClient(); return false; }
    if (notMp3) { setError("NOT MP3 STREAM"); closeClient(); return false; }
    metaInt = mi; toMeta = mi; metaState = 0; metaLen = 0; metaPos = 0; titleBuf[0] = 0;
    return true;
  }
  setError("TOO MANY REDIRECTS"); return false;
}

static void netTask(void *) {
  static uint8_t buf[2048];
  uint32_t lastData = 0;
  for (;;) {
    if (cmdStop) { cmdStop = false; cmdPlay = -1; closeClient(); netDone = false; flushReq = true; streamState = ST_IDLE; audioLevel = 0; dirty = true; }
    if (cmdPlay >= 0) {
      const int idx = cmdPlay; cmdPlay = -1;
      closeClient(); netDone = false; streamState = ST_CONNECTING; flushReq = true; dirty = true;
      for (int w = 0; w < 100 && flushReq; ++w) vTaskDelay(pdMS_TO_TICKS(5));
      if (connectStream(idx)) { streaming = true; lastData = millis(); streamState = ST_BUFFERING; setLog(String("BUFFERING ") + stationsArr[idx].name); }
    }
    if (streaming && cl) {
      if (flushReq || ringFree() < 4096) { vTaskDelay(pdMS_TO_TICKS(10)); continue; }
      const int a = cl->available();
      if (a > 0) {
        const int want = min(min(a, (int)sizeof(buf)), (int)ringFree() - 1);
        const int n = cl->read(buf, want);
        if (n > 0) { feedStream(buf, n); lastData = millis(); }
      } else if (!cl->connected()) { netDone = true; streaming = false; }
      else if (millis() - lastData > 15000) { netDone = true; streaming = false; }
      else vTaskDelay(pdMS_TO_TICKS(3));
    } else vTaskDelay(pdMS_TO_TICKS(20));
  }
}

static void playStation(int idx) {
  if (idx < 0 || idx >= stationCount) return;
  selected = idx; stationPage = idx / TILES_PER_PAGE; titleBuf[0] = 0;
  if (!wifiOnline) { playing = false; requestClick(); setLog("NO NETWORK - JOIN CHADAMI-SETUP FIRST"); return; }
  playing = true; streamState = ST_CONNECTING; cmdPlay = idx;
  setLog(String("CONNECTING ") + stationsArr[idx].name);
}
static void stopPlayback() { cmdStop = true; playing = false; titleBuf[0] = 0; requestClick(); setLog("STREAM STOPPED"); }

// =====================================================================
//     Small HTTP GET helper (replaces HTTPClient) and JSON scanner (replaces ArduinoJson)
// =====================================================================
// Fetches a URL with HTTP/1.0, follows up to 3 redirects, copies the body into out (NUL terminated).
// Returns 200 on success, the HTTP status on a server error, or a negative number on a network failure.
static int httpGetBody(const String &startUrl, uint8_t *out, size_t cap, size_t &outLen) {
  outLen = 0;
  String url = startUrl;
  for (int hop = 0; hop < 4; ++hop) {
    bool tls; String host, path; uint16_t port;
    if (!parseUrl(url, tls, host, port, path)) return -1;
    WiFiClient pc;
#if USE_TLS
    WiFiClientSecure sc;
#endif
    Client *c = &pc;
    bool ok = false;
    if (tls) {
#if USE_TLS
      sc.setInsecure(); sc.setTimeout(10); c = &sc; ok = sc.connect(host.c_str(), port);
#else
      return -5;                                     // HTTPS is not built in
#endif
    } else {
      ok = pc.connect(host.c_str(), port, 10000);
    }
    if (!ok) return -2;
    c->print(String("GET ") + path + " HTTP/1.0\r\nHost: " + host + "\r\nUser-Agent: Mozilla/5.0 ChadamiRadio\r\nAccept: */*\r\nConnection: close\r\n\r\n");
    char line[300];
    if (!readLine(c, line, sizeof(line), 12000)) { c->stop(); return -3; }
    int code = 0; const char *sp = strchr(line, ' '); if (sp) code = atoi(sp + 1);
    String loc = "";
    while (readLine(c, line, sizeof(line), 12000)) {
      if (line[0] == 0) break;
      if (!strncasecmp(line, "location:", 9)) { loc = String(line + 9); loc.trim(); }
    }
    if (code >= 300 && code < 400 && loc.length()) {
      c->stop();
      if (loc.startsWith("/")) loc = String(tls ? "https://" : "http://") + host + loc;
      url = loc; continue;
    }
    if (code != 200) { c->stop(); return code > 0 ? code : -4; }
    size_t len = 0; uint32_t last = millis();
    while (len < cap - 1 && millis() - last < 15000) {
      const int a = c->available();
      if (a > 0) { const int r = c->read(out + len, min((size_t)a, cap - 1 - len)); if (r > 0) { len += r; last = millis(); } }
      else if (!c->connected()) break;
      else vTaskDelay(pdMS_TO_TICKS(5));
    }
    c->stop();
    out[len] = 0; outLen = len;
    return 200;
  }
  return -6;
}

// Copies the JSON string that starts at p (p points at the opening quote) into out and moves p past the closing quote.
// out may be nullptr to just skip the string.
static void jsonString(const char *&p, const char *end, char *out, size_t cap) {
  size_t n = 0;
  ++p;
  while (p < end && *p != '"') {
    char c = *p++;
    if (c == '\\' && p < end) {
      const char e = *p++;
      if (e == 'u') { for (int i = 0; i < 4 && p < end; ++i) ++p; c = '?'; }
      else if (e == 'n' || e == 'r' || e == 't') c = ' ';
      else c = e;
    }
    if (out && n + 1 < cap) out[n++] = c;
  }
  if (p < end) ++p;
  if (out && cap) out[n] = 0;
}

// Finds a number that follows a key such as "\"temperature_2m\":" inside hay.
static bool jsonNumberAfter(const char *hay, const char *key, float &outVal) {
  const char *k = strstr(hay, key);
  if (!k) return false;
  k += strlen(key);
  while (*k == ' ') ++k;
  char *e = nullptr;
  const float v = strtof(k, &e);
  if (e == k) return false;
  outVal = v;
  return true;
}

static String cleanName(const char *in) {
  String s; bool lastSpace = true;
  for (; *in && s.length() < 38; ++in) {
    char c = *in;
    if (c >= 'a' && c <= 'z') c -= 32;
    if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+' || c == '/') { s += c; lastSpace = false; }
    else if (c == ' ' || c == '_') { if (!lastSpace) { s += ' '; lastSpace = true; } }
  }
  s.trim(); return s;
}

// Reads the radio-browser station array: [{"name":"..","url_resolved":"..","codec":"MP3","bitrate":128,...}, ...]
static int parseStations(const char *p, size_t len) {
  const char *end = p + len;
  int n = 0;
  while (p < end && n < MAX_STATIONS) {
    while (p < end && *p != '{') ++p;
    if (p >= end) break;
    ++p;
    char name[64] = "", url[200] = "", codec[16] = "";
    int bitrate = 0;
    while (p < end) {
      while (p < end && *p != '"' && *p != '}') ++p;
      if (p >= end) break;
      if (*p == '}') { ++p; break; }
      char key[20];
      jsonString(p, end, key, sizeof(key));
      while (p < end && *p != ':') ++p;
      if (p < end) ++p;
      while (p < end && (*p == ' ' || *p == '\n' || *p == '\r')) ++p;
      if (p >= end) break;
      if (*p == '"') {
        if (!strcmp(key, "name")) jsonString(p, end, name, sizeof(name));
        else if (!strcmp(key, "url_resolved")) jsonString(p, end, url, sizeof(url));
        else if (!strcmp(key, "codec")) jsonString(p, end, codec, sizeof(codec));
        else jsonString(p, end, nullptr, 0);
      } else {
        const char *s = p;
        while (p < end && *p != ',' && *p != '}') ++p;
        if (!strcmp(key, "bitrate")) bitrate = atoi(s);
      }
    }
    String codecS = codec; codecS.toUpperCase();
    if (codecS.indexOf("MP3") < 0) continue;                          // this lite player decodes MP3 only
    const size_t ul = strlen(url);
    if (ul < 8 || ul >= sizeof(stationsArr[0].url) || strncmp(url, "http", 4) != 0 || strstr(url, ".m3u8")) continue;
#if !USE_TLS
    if (strncmp(url, "https", 5) == 0) continue;
#endif
    String nm = cleanName(name);
    if (nm.length() < 3) nm = "HINDI STATION";
    bool dup = false;
    for (int i = 0; i < n; ++i) if (nm != "HINDI STATION" && nm.equals(stationsArr[i].name)) { dup = true; break; }
    if (dup) continue;
    strncpy(stationsArr[n].name, nm.c_str(), sizeof(stationsArr[n].name) - 1); stationsArr[n].name[sizeof(stationsArr[n].name) - 1] = 0;
    strncpy(stationsArr[n].url, url, sizeof(stationsArr[n].url) - 1); stationsArr[n].url[sizeof(stationsArr[n].url) - 1] = 0;
    stationsArr[n].bitrate = (uint16_t)bitrate;
    strcpy(stationsArr[n].codec, "MP3");
    ++n;
  }
  return n;
}

// ---------------- Hindi channel list from radio-browser.info ----------------
static String lastLoadError = "";
static bool loadStations() {
  static const char *hosts[] = {"de1.api.radio-browser.info", "at1.api.radio-browser.info", "nl1.api.radio-browser.info"};
  lastLoadError = "";
  const size_t cap = 400 * 1024;
  uint8_t *buf = (uint8_t *)heap_caps_malloc(cap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
  if (!buf) { lastLoadError = "NO PSRAM"; return false; }
  // Plain HTTP first (no TLS handshake, which this server was resetting), then HTTPS as a fallback.
  static const char *schemes[] = {"http://",
#if USE_TLS
    "https://"
#endif
  };
  for (int round = 0; round < 2; ++round) {
    for (const char *scheme : schemes) {
      for (const char *host : hosts) {
        const String url = String(scheme) + host + "/json/stations/search?language=hindi&hidebroken=true&order=clickcount&reverse=true&codec=MP3&limit=80";
        size_t len = 0;
        const int code = httpGetBody(url, buf, cap, len);
        Serial.printf("CHADAMI RADIO: %s%s -> status %d, %u bytes\n", scheme, host, code, (unsigned)len);
        if (code != 200) { lastLoadError = code > 0 ? (String("HTTP ") + code) : (String("NET ") + code); delay(1000); continue; }
        const int n = parseStations((const char *)buf, len);
        if (n > 0) { free(buf); stationCount = n; return true; }
        lastLoadError = "NO USABLE STATIONS";
      }
    }
    delay(3000);
  }
  free(buf);
  return false;
}

static void stationTask(void *) {
  for (;;) {
    if (wifiOnline && stationCount == 0) {
      loadState = 1; setLog("LOADING HINDI CHANNELS...");
      if (loadStations()) { loadState = 2; setLog(String("LOADED ") + stationCount + " HINDI CHANNELS"); }
      else { loadState = 3; setLog(String("CHANNEL LIST FAILED: ") + (lastLoadError.length() ? lastLoadError : String("NO USABLE STATIONS")) + " - RETRYING"); }
      dirty = true;
    }
    vTaskDelay(pdMS_TO_TICKS(stationCount == 0 ? 20000 : 60000));
  }
}

// ---------------- Live clock, Indian market and Rewa weather ----------------
#if USE_TLS
static void fetchQuote(const char *symbol, const char *label, String &out, uint8_t *buf, size_t cap) {
  size_t len = 0;
  const String url = String("https://query1.finance.yahoo.com/v8/finance/chart/") + symbol + "?range=1d&interval=1d";
  if (httpGetBody(url, buf, cap, len) != 200) return;
  float price = 0, prior = 0;
  if (!jsonNumberAfter((const char *)buf, "\"regularMarketPrice\":", price) || price <= 0) return;
  const bool hasPrior = jsonNumberAfter((const char *)buf, "\"chartPreviousClose\":", prior);
  const float change = hasPrior ? price - prior : 0.0f;
  char line[64]; snprintf(line, sizeof(line), "%s %.2f %c%.2f", label, price, change >= 0 ? '+' : '-', fabsf(change));
  out = line; marketColour = change >= 0 ? 0x70FFA2 : 0xFF465A;
}

static bool fetchDashboard() {
  const size_t cap = 24 * 1024;
  uint8_t *buf = (uint8_t *)heap_caps_malloc(cap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
  if (!buf) return false;
  const String beforeA = sensexText, beforeB = niftyText, beforeW = weatherText;
  fetchQuote("%5EBSESN", "SENSEX", sensexText, buf, cap);
  fetchQuote("%5ENSEI", "NIFTY 50", niftyText, buf, cap);
  size_t len = 0;
  if (httpGetBody("https://api.open-meteo.com/v1/forecast?latitude=24.5362&longitude=81.3037&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=Asia%2FKolkata", buf, cap, len) == 200) {
    const char *cur = strstr((const char *)buf, "\"current\":{");
    if (cur) {
      float temp = 0, humidity = 0, wind = 0, code = -1;
      jsonNumberAfter(cur, "\"temperature_2m\":", temp);
      jsonNumberAfter(cur, "\"relative_humidity_2m\":", humidity);
      jsonNumberAfter(cur, "\"wind_speed_10m\":", wind);
      jsonNumberAfter(cur, "\"weather_code\":", code);
      const int c = (int)code;
      const char *condition = (c == 0) ? "CLEAR" : (c <= 3) ? "CLOUDY" : (c <= 48) ? "FOG" : (c <= 67) ? "RAIN" : (c <= 77) ? "SNOW" : "SHOWERS";
      char line[80]; snprintf(line, sizeof(line), "REWA MP %s %.1fC H%d%% W%.0f", condition, temp, (int)humidity, wind);
      weatherText = line; weatherColour = (c >= 51 && c <= 82) ? 0x00EEFF : 0xFF9628;
    }
  }
  free(buf);
  dirty = true;
  return !(sensexText == beforeA && niftyText == beforeB && weatherText == beforeW);
}

static void dashboardTask(void *) {
  // Let the channel list finish first so two secure connections never start at the same moment.
  for (uint32_t waited = 0; !(wifiOnline && (stationCount > 0 || loadState == 3)) && waited < 120; ++waited) vTaskDelay(pdMS_TO_TICKS(1000));
  for (;;) {
    if (!wifiOnline) { vTaskDelay(pdMS_TO_TICKS(1000)); continue; }
    const bool ok = fetchDashboard();
    vTaskDelay(pdMS_TO_TICKS(ok ? 600000 : 30000));
  }
}
#endif

static void updateClock() {
  struct tm t;
  if (getLocalTime(&t, 20)) {
    char c[16], d[32]; strftime(c, sizeof(c), "%H:%M:%S", &t); strftime(d, sizeof(d), "%a %d %b", &t);
    clockText = c; dateText = d;
  }
}

// ---------------- Wi-Fi scan ----------------
static void startBackgroundScan() {
  if (!setupPortalOn || scanRunning) return;
  WiFi.scanDelete();
  const int result = WiFi.scanNetworks(true, true);
  if (result == WIFI_SCAN_RUNNING) scanRunning = true;
  else if (result >= 0) { scanCount = result; nextScanAt = millis() + 15000; }
  else { scanCount = -1; nextScanAt = millis() + 5000; }
}
static void finishBackgroundScan() {
  const int result = WiFi.scanComplete();
  if (result == WIFI_SCAN_RUNNING) return;
  scanRunning = false;
  if (result < 0) scanCount = -1;
  else { scanCount = min(result, 20); for (int i = 0; i < scanCount; ++i) { scanNames[i] = WiFi.SSID(i); scanChannels[i] = WiFi.channel(i); scanRssi[i] = WiFi.RSSI(i); } }
  WiFi.scanDelete();
  nextScanAt = wifiOnline ? ULONG_MAX : millis() + 15000;
}

// ---------------- Drawing primitives (RGB888 framebuffer) ----------------
static inline uint32_t rgb(uint8_t r, uint8_t g, uint8_t b) { return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b; }
static uint32_t tint(uint32_t c, int k) { return rgb(((c >> 16) & 255) * k / 255, ((c >> 8) & 255) * k / 255, (c & 255) * k / 255); }
static void box(int x, int y, int w, int h, uint32_t color) {
  const int x0 = max(0, x), y0 = max(0, y), x1 = min((int)SCREEN_W, x + w), y1 = min((int)SCREEN_H, y + h);
  if (x1 <= x0 || y1 <= y0) return;
  const uint8_t r = (color >> 16) & 0xFF, g = (color >> 8) & 0xFF, b = color & 0xFF;
  for (int py = y0; py < y1; ++py) { uint8_t *p = frame + ((size_t)py * SCREEN_W + x0) * 3; for (int px = x0; px < x1; ++px) { *p++ = r; *p++ = g; *p++ = b; } }
}
static void roundBox(int x, int y, int w, int h, int r, uint32_t c) {
  if (r * 2 > h) r = h / 2; if (r * 2 > w) r = w / 2;
  for (int row = 0; row < h; ++row) {
    int dy = row < r ? r - 1 - row : (row >= h - r ? row - (h - r) : -1);
    int inset = 0;
    if (dy >= 0) { const float d = dy + 0.5f; inset = r - (int)(sqrtf((float)r * r - d * d) + 0.5f); }
    box(x + inset, y + row, w - 2 * inset, 1, c);
  }
}
static const uint8_t glyphs[][7] = {
  {0,0,0,0,0,0,0},{14,17,17,31,17,17,17},{30,17,17,30,17,17,30},{15,16,16,16,16,16,15},{30,17,17,17,17,17,30},{31,16,16,30,16,16,31},{31,16,16,30,16,16,16},{15,16,16,23,17,17,15},{17,17,17,31,17,17,17},{14,4,4,4,4,4,14},{1,1,1,1,17,17,14},{17,18,20,24,20,18,17},{16,16,16,16,16,16,31},{17,27,21,21,17,17,17},{17,25,21,19,17,17,17},{14,17,17,17,17,17,14},{30,17,17,30,16,16,16},{14,17,17,17,21,18,13},{30,17,17,30,20,18,17},{15,16,16,14,1,1,30},{31,4,4,4,4,4,4},{17,17,17,17,17,17,14},{17,17,17,17,17,10,4},{17,17,17,21,21,21,10},{17,17,10,4,10,17,17},{17,17,10,4,4,4,4},{31,1,2,4,8,16,31},
  {14,17,19,21,25,17,14},{4,12,4,4,4,4,14},{14,17,1,2,4,8,31},{30,1,1,14,1,1,30},{2,6,10,18,31,2,2},{31,16,16,30,1,1,30},{14,16,16,30,17,17,14},{31,1,2,4,8,8,8},{14,17,17,14,17,17,14},{14,17,17,15,1,1,14},
  {14,8,8,8,8,8,14},{14,2,2,2,2,2,14},{16,8,4,2,4,8,16},{1,2,4,8,4,2,1},{0,0,0,0,0,0,31},{0,0,0,0,0,12,12},{0,12,12,0,12,12,0},{0,0,0,31,0,0,0},{0,4,4,31,4,4,0},{1,2,2,4,8,8,16},{25,25,2,4,8,19,19},{10,10,31,10,31,10,10},{4,4,4,4,4,4,4},{0,0,31,0,31,0,0},{2,4,8,8,8,4,2},{8,4,2,2,2,4,8},{0,4,21,14,21,4,0},{31,31,31,31,31,31,31},{4,4,4,4,4,0,4}
};
static int glyphIndex(char c) {
  if (c >= 'a' && c <= 'z') c -= 32;
  if (c >= 'A' && c <= 'Z') return 1 + c - 'A';
  if (c >= '0' && c <= '9') return 27 + c - '0';
  switch (c) {
    case '[': return 37; case ']': return 38; case '>': return 39; case '<': return 40; case '_': return 41; case '.': return 42; case ':': return 43;
    case '-': return 44; case '+': return 45; case '/': return 46; case '%': return 47; case '#': return 48; case '|': return 49; case '=': return 50;
    case '(': return 51; case ')': return 52; case '*': return 53; case '\x01': return 54; case '!': return 55;
  }
  return 0;
}
static int textWidth(const char *s, int scale) { return strlen(s) * 6 * scale; }
static void text(int x, int y, const char *s, int scale, uint32_t color) {
  for (; *s; ++s, x += 6 * scale) {
    const uint8_t *g = glyphs[glyphIndex(*s)];
    for (int row = 0; row < 7; ++row) for (int col = 0; col < 5; ++col) if (g[row] & (1 << (4 - col))) box(x + col * scale, y + row * scale, scale, scale, color);
  }
}
static void textCentered(int x, int w, int y, const char *s, int scale, uint32_t color) { text(x + (w - textWidth(s, scale)) / 2, y, s, scale, color); }
static String clipStr(String s, int maxChars) { if ((int)s.length() > maxChars) s = s.substring(0, maxChars); return s; }

// ---------------- Neon UI ----------------
static const uint32_t C_PINK = rgb(255, 46, 168), C_CYAN = rgb(0, 238, 255), C_LIME = rgb(112, 255, 162), C_VIOLET = rgb(180, 75, 255),
                      C_ORANGE = rgb(255, 150, 40), C_YELLOW = rgb(255, 230, 60), C_BLUE = rgb(80, 140, 255), C_RED = rgb(255, 70, 90),
                      C_WHITE = rgb(248, 250, 255), C_DIM = rgb(182, 156, 195), C_BG = rgb(16, 3, 17), C_BLACK = rgb(5, 7, 24);
static const uint32_t palette[8] = {C_PINK, C_CYAN, C_LIME, C_VIOLET, C_ORANGE, C_YELLOW, C_BLUE, C_RED};

static constexpr int TILE_W = 236, TILE_H = 100;
static void tileRect(int slot, int &x, int &y) { x = 19 + (slot % 4) * 250; y = 164 + (slot / 4) * 108; }
static constexpr int CTL_Y = 462, CTL_H = 76, CTL_W = 114;
static int ctlX(int i) { return 17 + i * 125; }

static void drawStationTile(int slot, int idx) {
  int x, y; tileRect(slot, x, y);
  const Station &st = stationsArr[idx];
  const uint32_t col = palette[idx % 8];
  const bool sel = (idx == selected), on = sel && playing;
  roundBox(x - 4, y - 4, TILE_W + 8, TILE_H + 8, 22, on ? C_WHITE : (sel ? col : tint(col, 70)));
  roundBox(x, y, TILE_W, TILE_H, 18, on ? tint(col, 70) : rgb(37 + (idx % 3) * 7, 3, 32 + (idx % 2) * 8));
  roundBox(x + 12, y + 8, TILE_W - 24, 5, 2, col);
  roundBox(x + 14, y + 22, 48, 40, 10, col);
  char num[8]; snprintf(num, sizeof(num), "%d", idx + 1); textCentered(x + 14, 48, y + 34, num, 2, C_BLACK);
  char info[24]; if (st.bitrate) snprintf(info, sizeof(info), "%s %uK", st.codec, st.bitrate); else snprintf(info, sizeof(info), "%s", st.codec);
  text(x + 72, y + 34, info, 2, col);
  String name = st.name; String l1, l2;
  if ((int)name.length() <= 17) l1 = name; else {
    int cut = name.lastIndexOf(' ', 17); if (cut < 6) cut = 17;
    l1 = name.substring(0, cut); l2 = name.substring(cut); l2.trim(); l2 = clipStr(l2, 17);
  }
  text(x + 14, y + 68, l1.c_str(), 2, C_WHITE);
  if (l2.length()) text(x + 14, y + 86, l2.c_str(), 2, tint(col, 230));
  if (on && streamState == ST_PLAYING) for (int b = 0; b < 3; ++b) { const int h = 4 + bars[(idx + b * 5) % 20] / 8; box(x + TILE_W - 44 + b * 10, y + 62 - h, 6, h, C_WHITE); }
}

static void drawControl(int i, const char *label, uint32_t col, bool filled) {
  const int x = ctlX(i), y = CTL_Y;
  roundBox(x - 3, y - 3, CTL_W + 6, CTL_H + 6, 22, tint(col, 90));
  roundBox(x, y, CTL_W, CTL_H, 20, filled ? col : tint(col, 55));
  roundBox(x + 12, y + 7, CTL_W - 24, 4, 2, filled ? C_BLACK : col);
  textCentered(x, CTL_W, y + 34, label, 3, filled ? C_BLACK : C_WHITE);
}

static void drawUI() {
  box(0, 0, SCREEN_W, SCREEN_H, C_BG);
  for (int y = 0; y < SCREEN_H; y += 24) box(0, y, SCREEN_W, 1, rgb(43, 8, 42));
  // header
  box(0, 0, SCREEN_W, 82, rgb(48, 6, 38)); box(0, 80, SCREEN_W, 4, C_PINK);
  roundBox(18, 12, 58, 58, 16, C_PINK); textCentered(18, 58, 26, "C", 5, C_BG);
  text(92, 10, "CHADAMI", 5, C_WHITE); text(92, 54, "HINDI NEON RADIO", 2, C_CYAN);
  roundBox(560, 12, 230, 58, 16, rgb(35, 5, 35));
  box(580, 33, 14, 14, wifiOnline ? C_LIME : C_ORANGE);
  text(606, 20, wifiOnline ? "WIFI ONLINE" : "PHONE SETUP", 2, C_WHITE);
  if (wifiOnline) text(606, 44, clipStr(connectedSsid, 20).c_str(), 2, C_LIME); else text(606, 44, "SETUP ACTIVE", 2, C_CYAN);
  roundBox(805, 12, 201, 58, 16, rgb(21, 8, 35));
  textCentered(805, 201, 19, clockText.c_str(), 3, C_CYAN);
  textCentered(805, 201, 47, dateText.c_str(), 1, C_DIM);

  // Live information deck: Indian market quotes and Rewa, Madhya Pradesh weather.
  roundBox(15, 90, 994, 62, 16, rgb(35, 5, 35));
  text(30, 101, sensexText.c_str(), 2, marketColour ? marketColour : C_YELLOW);
  text(350, 101, niftyText.c_str(), 2, marketColour ? marketColour : C_YELLOW);
  text(650, 101, weatherText.c_str(), 2, weatherColour ? weatherColour : C_ORANGE);
  text(30, 130, "LIVE MARKET + REWA WEATHER", 1, C_DIM);
  text(780, 130, wifiOnline ? "AUTO UPDATE 10 MIN" : "WAITING FOR WIFI", 1, C_CYAN);

  // tiles
  const int n = stationCount;
  if (n == 0) {
    roundBox(19, 164, 986, 214, 24, rgb(41, 5, 42));
    const char *msg = !wifiOnline ? "CONNECT WIFI FIRST" : (loadState == 3 ? "LOAD FAILED - RETRYING" : "LOADING HINDI CHANNELS...");
    textCentered(19, 986, 220, msg, 4, C_CYAN);
    textCentered(19, 986, 280, !wifiOnline ? "JOIN CHADAMI-SETUP ON YOUR PHONE" : "PLEASE WAIT", 2, C_DIM);
  } else {
    for (int s = 0; s < TILES_PER_PAGE; ++s) { const int idx = stationPage * TILES_PER_PAGE + s; if (idx >= n) break; drawStationTile(s, idx); }
  }

  // now-playing strip
  const int st = streamState;
  roundBox(15, 388, 650, 66, 20, tint(C_PINK, 150)); roundBox(19, 392, 642, 58, 18, rgb(41, 5, 42));
  const char *nm = (n > 0) ? stationsArr[selected].name : "NO CHANNEL";
  text(36, 398, clipStr(nm, 24).c_str(), 3, C_WHITE);
  String info; uint32_t infoCol = C_DIM;
  if (st == ST_CONNECTING) { info = "CONNECTING..."; infoCol = C_YELLOW; }
  else if (st == ST_BUFFERING) { info = "BUFFERING..."; infoCol = C_YELLOW; }
  else if (st == ST_PLAYING) { info = muted ? String("MUTED") : (titleBuf[0] ? String(titleBuf) : String("ON AIR")); infoCol = muted ? C_YELLOW : C_LIME; }
  else if (st == ST_ERROR) { info = String("ERROR: ") + errBuf; infoCol = C_RED; }
  else info = "READY";
  text(36, 428, clipStr(info, 44).c_str(), 2, infoCol);
  text(430, 428, muted ? "VOL MUTE" : "VOL", 2, C_CYAN);
  char v[8]; snprintf(v, sizeof(v), "%d", volumeLevel); if (!muted) text(480, 428, v, 2, C_CYAN);
  // equalizer reacts only to decoded PCM while a station is really playing.
  roundBox(679, 388, 326, 66, 20, tint(C_CYAN, 120)); roundBox(683, 392, 318, 58, 18, rgb(10, 8, 34));
  for (int i = 0; i < 20; ++i) {
    const int h = bars[i] * 43 / 100; const int bx = 694 + i * 15;
    const uint32_t c = rgb(255 - i * 12, 46 + i * 10, 168 + i * 4);
    for (int s = 0; s < h; s += 6) box(bx, 440 - s, 11, 4, c);
  }

  // controls
  drawControl(0, "PREV", C_CYAN, false);
  drawControl(1, playing ? "STOP" : "PLAY", playing ? C_RED : C_LIME, true);
  drawControl(2, "NEXT", C_CYAN, false);
  drawControl(3, "MUTE", C_YELLOW, muted);
  drawControl(4, "VOL-", C_ORANGE, false);
  drawControl(5, "VOL+", C_PINK, false);
  drawControl(6, "PG-", C_VIOLET, false);
  drawControl(7, "PG+", C_VIOLET, false);

  // footer
  box(0, 546, SCREEN_W, 3, C_PINK);
  text(20, 558, (String("> ") + clipStr(logLine, 54)).c_str(), 2, C_CYAN);
  char pg[32]; snprintf(pg, sizeof(pg), "PAGE %d/%d  CH %d", stationPage + 1, pageCount(), n); text(1004 - textWidth(pg, 2), 558, pg, 2, C_LIME);
  text(20, 580, "TAP TILE: PLAY   SWIPE: CHANNEL PAGES   MARKET/WEATHER AUTO REFRESH", 1, C_DIM);
}

static void refreshScreen() { dirty = false; drawUI(); panel->getLcd()->drawBitmap(0, 0, SCREEN_W, SCREEN_H, (const uint8_t *)frame); }

// hit codes: 100+idx = station tile, 200+i = control tile, -1 = nothing
static int hit(uint16_t x, uint16_t y) {
  for (int s = 0; s < TILES_PER_PAGE; ++s) {
    int tx, ty; tileRect(s, tx, ty); const int idx = stationPage * TILES_PER_PAGE + s;
    if (idx < stationCount && x >= tx && x <= tx + TILE_W && y >= ty && y <= ty + TILE_H) return 100 + idx;
  }
  if (y >= CTL_Y && y <= CTL_Y + CTL_H) for (int i = 0; i < 8; ++i) if (x >= ctlX(i) && x <= ctlX(i) + CTL_W) return 200 + i;
  return -1;
}

static void doControl(int i) {
  const int n = stationCount;
  switch (i) {
    case 0: if (n) playStation((selected + n - 1) % n); else requestClick(); break;
    case 1: if (playing) stopPlayback(); else if (n) playStation(selected); else requestClick(); break;
    case 2: if (n) playStation((selected + 1) % n); else requestClick(); break;
    case 3: muted = !muted; applyVolume(); requestClick(); setLog(muted ? "AUDIO MUTED" : "AUDIO UNMUTED"); break;
    case 4: volumeLevel = max(0, volumeLevel - 5); muted = false; applyVolume(); requestClick(); break;
    case 5: volumeLevel = min(100, volumeLevel + 5); muted = false; applyVolume(); requestClick(); break;
    case 6: stationPage = max(0, stationPage - 1); dirty = true; requestClick(); break;
    case 7: stationPage = min(pageCount() - 1, stationPage + 1); dirty = true; requestClick(); break;
  }
}

// ---------------- Phone Wi-Fi setup page (tiny raw web server) ----------------
static String htmlSafe(String value) { value.replace("&", "&amp;"); value.replace("<", "&lt;"); value.replace(">", "&gt;"); value.replace("\"", "&quot;"); value.replace("'", "&#39;"); return value; }
static String setupPage() {
  String networks;
  if (scanRunning || scanCount == -2) networks = "<p class=hint>SCANNING FOR NEARBY WIFI. THIS PAGE WILL REFRESH IN 3 SECONDS.</p><meta http-equiv=refresh content=3>";
  else if (scanCount < 0) networks = "<p class=hint>THE WIFI SCAN DID NOT FINISH. PRESS SCAN AGAIN AND WAIT A FEW SECONDS.</p>";
  else if (scanCount == 0) networks = "<p class=hint>NO NETWORKS WERE FOUND. MOVE THE PANEL CLOSER TO A 2.4 GHZ ROUTER, WAIT 15 SECONDS, THEN PRESS SCAN AGAIN.</p>";
  else {
    networks = "<label>CHOOSE A NEARBY WIFI NETWORK</label><div class=list>";
    for (int i = 0; i < scanCount; ++i) {
      String name = htmlSafe(scanNames[i]); const int channel = scanChannels[i];
      const char *band = channel >= 1 && channel <= 14 ? "2.4 GHz" : "5 GHz";
      networks += "<button class=network type=button data-ssid=\"" + name + "\" onclick=\"pick(this.dataset.ssid)\"><b>" + name + "</b><span>" + band + " - CHANNEL " + String(channel) + " - " + String(scanRssi[i]) + " dBm</span></button>";
    }
    networks += "</div>";
  }
  return String(F("<!doctype html><html><meta name=viewport content='width=device-width,initial-scale=1'><style>body{font-family:Arial,sans-serif;background:#10030f;color:#fff;max-width:560px;margin:18px auto;padding:18px}h1{color:#ff2ea8;margin-bottom:4px}.hint,label,p{color:#e3c6f0}.list{margin:12px 0}.network{display:block;text-align:left;width:100%;padding:13px;margin:7px 0;background:#30061f;color:#fff;border:1px solid #00eeff;border-radius:12px;font-size:15px}.network b{display:block;color:#00eeff}.network span{display:block;color:#e3c6f0;font-size:13px;margin-top:4px}input{box-sizing:border-box;width:100%;padding:14px;margin:8px 0;font-size:16px;border-radius:8px;border:0}button.action{background:#ff2ea8;color:#fff;border:0;border-radius:10px;padding:14px 24px;font-size:17px}a{color:#00eeff}</style><h1>CHADAMI RADIO</h1><p>Pick a 2.4 GHz network below, then enter its password.</p><p><a href='/'>Scan again</a></p>")) + networks + F("<form method=post action=/save><label>Wi-Fi name</label><input id=ssid required name=s placeholder='Choose from the list or type a name'><label>Wi-Fi password</label><input required name=p type=password placeholder='Password'><button class=action>CONNECT RADIO</button></form><p class=hint>This panel connects to 2.4 GHz Wi-Fi.</p><script>function pick(n){document.getElementById('ssid').value=n;window.scrollTo(0,document.body.scrollHeight)}</script></html>");
}

static String urlDecode(const String &in) {
  String out; out.reserve(in.length());
  for (size_t i = 0; i < in.length(); ++i) {
    const char c = in[i];
    if (c == '+') out += ' ';
    else if (c == '%' && i + 2 < in.length()) { char h[3] = {in[i + 1], in[i + 2], 0}; out += (char)strtol(h, nullptr, 16); i += 2; }
    else out += c;
  }
  return out;
}
static String formValue(const String &body, const char *key) {
  int start = 0;
  while (start <= (int)body.length()) {
    int amp = body.indexOf('&', start); if (amp < 0) amp = body.length();
    const int eq = body.indexOf('=', start);
    if (eq >= 0 && eq < amp && body.substring(start, eq) == key) return urlDecode(body.substring(eq + 1, amp));
    start = amp + 1;
  }
  return "";
}
static void sendHtml(WiFiClient &c, int code, const String &body) {
  c.print(String("HTTP/1.1 ") + code + (code == 200 ? " OK" : " Bad Request") + "\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\nContent-Length: " + body.length() + "\r\n\r\n");
  c.print(body);
  c.flush(); delay(10); c.stop();
}

static void handlePortal() {
  WiFiClient c = portal.accept();
  if (!c) return;
  char first[160];
  if (!readLine(&c, first, sizeof(first), 1500)) { c.stop(); return; }
  const bool post = strncmp(first, "POST", 4) == 0;
  int contentLen = 0; char h[200];
  while (readLine(&c, h, sizeof(h), 1500)) { if (!h[0]) break; if (!strncasecmp(h, "content-length:", 15)) contentLen = atoi(h + 15); }
  if (contentLen > 512) contentLen = 512;
  if (post && strstr(first, "/save")) {
    String body; uint32_t t0 = millis();
    while ((int)body.length() < contentLen && millis() - t0 < 3000) { if (c.available()) body += (char)c.read(); else if (!c.connected()) break; else delay(2); }
    const String ssid = formValue(body, "s"); const String password = formValue(body, "p");
    if (ssid.length() == 0 || password.length() < 8) { sendHtml(c, 400, "<h2>Please use a network name and a password of at least 8 characters.</h2>"); return; }
    wifiPrefs.begin("radio", false); wifiPrefs.putString("ssid", ssid); wifiPrefs.putString("pass", password); wifiPrefs.end();
    sendHtml(c, 200, "<h2>Saved. The radio is now joining your Wi-Fi.</h2>");
    WiFi.begin(ssid.c_str(), password.c_str()); wifiJoining = true; wifiJoinStarted = millis();
    return;
  }
  sendHtml(c, 200, setupPage());
}

static void startPhoneSetup() {
  if (setupPortalOn) return;
  WiFi.mode(WIFI_AP_STA);
  WiFi.softAP("CHADAMI-SETUP", "radio2026");
  portal.begin(); setupPortalOn = true;
  scanCount = -2; startBackgroundScan();
  Serial.println("CHADAMI RADIO: join CHADAMI-SETUP (password radio2026) and open http://192.168.4.1");
}
static void connectStoredWifi() {
  wifiPrefs.begin("radio", true); String ssid = wifiPrefs.getString("ssid", ""); String password = wifiPrefs.getString("pass", ""); wifiPrefs.end();
  if (ssid.length() == 0) { ssid = DEFAULT_WIFI_SSID; password = DEFAULT_WIFI_PASS; }
  startPhoneSetup();
  if (ssid.length() == 0) return;
  WiFi.begin(ssid.c_str(), password.c_str()); wifiJoining = true; wifiJoinStarted = millis();
}

// ---------------- Arduino entry points ----------------
void setup() {
  Serial.begin(115200); delay(250);
  pinMode(AMP_ENABLE_PIN, OUTPUT); digitalWrite(AMP_ENABLE_PIN, AMP_ON_LEVEL);
  pinMode(LCD_POWER_PIN, OUTPUT); pinMode(LCD_BACKLIGHT_PIN, OUTPUT);
  digitalWrite(LCD_POWER_PIN, LOW); digitalWrite(LCD_BACKLIGHT_PIN, LOW); delay(30);
  panel = new ESP_Panel(); panel->init(); panel->begin();
  frame = (uint8_t *)heap_caps_malloc((size_t)SCREEN_W * SCREEN_H * 3, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
  stationsArr = (Station *)heap_caps_calloc(MAX_STATIONS, sizeof(Station), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
  ringBuf = (uint8_t *)heap_caps_malloc(RING_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
  if (frame == nullptr || stationsArr == nullptr || ringBuf == nullptr) { Serial.println("CHADAMI RADIO: PSRAM allocation failed - enable PSRAM."); return; }
  refreshScreen(); digitalWrite(LCD_BACKLIGHT_PIN, HIGH);

  // ESP32-P4 <-> ESP32-C6 SDIO link (V1.2 mapping). Must run before any WiFi call.
  if (!WiFi.setPins(18, 19, 17, 16, 15, 14, 32)) { Serial.println("CHADAMI RADIO: C6 SDIO pin setup failed."); startPhoneSetup(); }
  else { delay(1000); WiFi.mode(WIFI_STA); delay(1000); WiFi.disconnect(false, true, 1000); connectStoredWifi(); }

  xTaskCreatePinnedToCore(audioTask, "audio", 20480, nullptr, 3, nullptr, 1);
  xTaskCreatePinnedToCore(netTask, "net", 16384, nullptr, 2, nullptr, 0);
  xTaskCreatePinnedToCore(stationTask, "stations", 24576, nullptr, 1, nullptr, 0);
#if USE_TLS
  xTaskCreatePinnedToCore(dashboardTask, "dashboard", 16384, nullptr, 1, nullptr, 0);
#endif
  setLog("READY");
  Serial.println(panel->getTouch() ? "CHADAMI RADIO: GT911 touch is ready." : "CHADAMI RADIO: GT911 touch did not start.");
}

void loop() {
  if (setupPortalOn) {
    handlePortal();
    if (scanRunning) finishBackgroundScan();
    if (!wifiOnline && !scanRunning && millis() >= nextScanAt) startBackgroundScan();
  }
  if (wifiJoining && WiFi.status() == WL_CONNECTED) {
    wifiJoining = false; wifiOnline = true; connectedSsid = WiFi.SSID(); nextScanAt = ULONG_MAX;
    configTime(19800, 0, "pool.ntp.org", "time.nist.gov"); updateClock();
    setLog("WIFI CONNECTED " + WiFi.localIP().toString());
  }
  if (wifiJoining && millis() - wifiJoinStarted > 20000) { wifiJoining = false; setLog("WIFI JOIN FAILED - USE PHONE SETUP"); }

  auto touch = panel->getTouch();
  if (touch != nullptr) {
    esp_panel::drivers::TouchPoint point;
    const bool down = touch->readPoints(&point, 1, 0) > 0;
    if (down) { if (!touchWasDown) { touchStartX = point.x; touchStartY = point.y; } touchLastX = point.x; touchLastY = point.y; }
    if (!down && touchWasDown) {
      const int dx = (int)touchLastX - (int)touchStartX;
      if (abs(dx) > 90 && touchStartY >= 90 && touchStartY <= 440) {
        const int np = constrain(stationPage + (dx < 0 ? 1 : -1), 0, pageCount() - 1);
        if (np != stationPage) { stationPage = np; requestClick(); dirty = true; }
      } else {
        const int hh = hit(touchStartX, touchStartY);
        if (hh >= 100 && hh < 200) playStation(hh - 100);
        else if (hh >= 200) doControl(hh - 200);
      }
    }
    touchWasDown = down;
  }

  if (millis() - lastAnim > 250) {
    lastAnim = millis();
    for (int i = 0; i < 20; ++i) {
      const int target = (streamState == ST_PLAYING && !muted) ? (audioLevel * random(40, 100)) / 100 : 3;
      bars[i] = (bars[i] * 2 + constrain(target, 3, 100)) / 3;
    }
    dirty = true;
  }
  if (millis() - lastClockDraw > 1000) { lastClockDraw = millis(); updateClock(); dirty = true; }
  if (dirty) refreshScreen();
  delay(20);
}

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