Community project

M5Retro-TV

ESP32
Photo of M5Retro-TV

Eliel Felipe Junior

Last updated September 23, 2026

M5Retro-TV transforms an ESP32-based M5Stack Core2 into a retro television that displays live aircraft radar data and plays video content via RCA output. The project combines an RCA video module, SD card storage, and WiFi connectivity to fetch real-time aircraft positions from an ADS-B API, rendering them on a classic CRT-style display with VHS-inspired visual effects.

This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for stacking the RCA module, preparing the SD card with configuration files, and deploying the Arduino firmware. Builders will learn how to configure WiFi credentials, set up the aircraft radar display with custom location parameters, and route audio output through RCA connectors to vintage television equipment.

Wiring diagram

Assemble it in 4 steps

1. Stack the RCA module

Turn the Core2 off, line up the Module13.2 RCA M125 underneath it, and press the two units together evenly so the M5-Bus connector is fully seated. This stacked connection carries the PAL-M video and external sound signals.

  • Set the M125 physical video selector switch to GPIO26 before powering the stack.
  • Do not force the connector or stack it while USB power is connected — bent pins or a shifted connector can damage the boards.

2. Prepare the SD card

Format a microSD card as FAT32 and create the M5RETRO folder. Put videos under M5RETRO/videos/program-name/ and put settings.json, secrets.json, and ca.pem under M5RETRO/config/.

  • The firmware creates missing M5RETRO folders after a successful SD mount, but secrets.json and media files must be supplied by you.
  • Do not put your Wi-Fi password or API token in the firmware; keep them only in secrets.json on the SD card.

3. Connect the television

Plug a composite-video cable from the yellow RCA socket into the TV composite-video input. Plug the white and red RCA sockets into the TV left and right audio inputs.

  • Select the TV input usually labelled AV, Composite, or Video.
  • Make sure the yellow video plug is not connected to an audio socket — otherwise the picture will not appear correctly.

4. Power and deploy

Insert the prepared microSD card, plug the Core2 into USB, and use Schematik’s Deploy button to flash the firmware. The CRT should show the boot screen before the home menu.

  • Open the Deploy panel serial output to see the once-per-second M5RETRO status line without exposing secrets.
  • If there is no picture, first confirm the M125 switch is on GPIO26 and the television is set to its composite input.

Deploy the firmware

#include <Arduino.h>
#include <M5Unified.h>
#include <M5GFX.h>
#include <M5ModuleRCA.h>
#include <SD.h>
#include <SPI.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <JPEGDEC.h>
#include <driver/i2s.h>
#include <esp_heap_caps.h>
#include <esp_timer.h>
#include "InputManager.h"
#include "LocalizationPTBR.h"
#include "SecretsManager.h"
#include "ConfigurationPortal.h"

// M5Stack Core2 + physically stacked Module13.2 RCA M125.

enum UiState { BOOT, HOME, VIDEO_LIBRARY, VIDEO_PLAYBACK, AIRCRAFT_RADAR, SETTINGS, SYSTEM_INFO, SETUP_PORTAL, ERROR_SCREEN };
enum class AudioOutput : uint8_t { RCA, INTERNAL, MUTED };

struct Settings { double lat = 0, lon = 0; int rangeKm = 250, refreshSeconds = 5, volume = 75; bool experimental320 = false; bool vhsOsd = true; AudioOutput audioOutput = AudioOutput::RCA; } settings;

struct Secrets { String ssid, password, base, endpoint, authMode, authHeader, authPrefix, token; bool insecure = false; } secrets;

struct Aircraft { String icao, callsign, aircraft_type; double latitude = 0, longitude = 0; float altitude_ft = 0, speed_kt = 0, heading_deg = 0; bool valid_position = false; };


// Forward declarations
void dualText(const String &line1, const String &line2);
void setError(const String &message);
bool makeDirectories();
bool loadConfiguration();
void saveSettings();
void serviceWiFi();
void createSecretsExample();
bool initExternalAudio();
bool openWavAndReadHeader();
void audioTask(void *);
int jpegDraw(JPEGDRAW *draw);
bool validateMjpegStream();
bool readAndShowOneFrame();
bool startProgram(const String &dir);
void stopProgram();
void videoTick();
String stringAlias(JsonObject o,const char*a,const char*b,const char*c);
double numberAlias(JsonObject o,const char*a,const char*b,const char*c);
bool parseAircraft(JsonDocument &doc);
void pollAircraft();
void drawHome();
void drawLibrary();
void drawRadar();
void drawSettings();
void drawInfo();
void drawSetupPortal();
void startSetupPortal();
void portalSaved(const SecretsConfig &savedSecrets, const RadarConfig &savedSettings);
void handleTouch();
void handleNavigation(NavAction action);
void drawControllerLabels(const char *left, const char *center, const char *right);
void drawPlaybackOsd();
void drawPlaybackController();
void setAudioOutput(AudioOutput output);
void servicePowerButton();
String playbackClock();
String libraryRoot();
String normalizeSdPath(const String &path);
String libraryChildPath(const String &root, const String &entryName);
bool isProgramFolder(const String &path);
int libraryProgramCount();
String libraryProgramAt(int wantedIndex);

static constexpr uint8_t CVBS_PIN = 26;
static constexpr uint8_t RCA_BCK = 19;
static constexpr uint8_t RCA_DATA = 2;
static constexpr uint8_t RCA_LRCK = 0;
// Core2 microSD slot is on the display's VSPI bus. GPIO38 is its MISO;
// GPIO19 is intentionally unavailable because the stacked RCA module uses it for PCM BCK.
static constexpr uint8_t SD_CS = 4;
static constexpr uint8_t SD_SCK = 18;
static constexpr uint8_t SD_MISO = 38;
static constexpr uint8_t SD_MOSI = 23;
static constexpr size_t MAX_JPEG = 128 * 1024;
static constexpr size_t AUDIO_CHUNK = 4096;
static constexpr int CRT_W = 320, CRT_H = 240;

const char *ROOT = "/M5RETRO";
const char *VIDEOS = "/M5RETRO/videos";
const char *CONFIG = "/M5RETRO/config";
const char *SETTINGS_FILE = "/M5RETRO/config/settings.json";
const char *SECRETS_FILE = "/M5RETRO/config/secrets.json";
const char *CA_FILE = "/M5RETRO/config/ca.pem";
const char *CACHE_FILE = "/M5RETRO/cache/aircraft.json";






M5ModuleRCA rca(CRT_W, CRT_H, CRT_W, CRT_H, M5ModuleRCA::signal_type_t::PAL_M,
                M5ModuleRCA::use_psram_t::psram_half_use, CVBS_PIN, 200);
JPEGDEC jpeg;
UiState state = BOOT;
File mjpegFile, wavFile;
SemaphoreHandle_t sdMutex;
TaskHandle_t audioTaskHandle = nullptr;
uint8_t *jpegBuffer = nullptr;
Aircraft aircraft[64];
int aircraftCount = 0;
String currentTitle;
float fps = 15.0f;
uint32_t sampleRate = 22050, wavDataStart = 0, wavDataEnd = 0;
uint16_t wavChannels = 2;
uint16_t wavBlockAlign = 4;
volatile uint64_t samplesPlayed = 0;
volatile bool playing = false, paused = false, playbackFinished = false;
volatile AudioOutput audioOutput = AudioOutput::RCA;
uint32_t lcdFramesRendered = 0, lcdPreviewDivider = 2, lastPlayerUiDraw = 0;
uint32_t videoFrameIndex = 0, decodedFrames = 0, renderedFrames = 0, droppedFrames = 0, jpegErrors = 0, audioUnderruns = 0;
uint32_t lastTouch = 0, lastRadarDraw = 0, lastApiPoll = 0, lastApiGood = 0, lastStats = 0, jpegDecodeTotalMs = 0, jpegDecodeMaxMs = 0;
String lastError, apiStatus = "NAO CONFIGURADA";
bool networkConfigPresent = false;
uint32_t wifiAttemptAt = 0, wifiRetryAt = 0;
uint8_t wifiRetryExponent = 0;
InputManager input;
int homeSelection = 0, librarySelection = 0, radarSelection = -1, settingsSelection = 0, infoPage = 0;
bool settingsEditing = false, radarDetails = false;
uint32_t osdUntil = 0;
SecretsManager secretsStore;
ConfigurationPortal portal;
bool portalSavePending = false;
uint32_t portalSaveStarted = 0;
bool powerOffPending = false;
uint32_t powerOffAt = 0;

void dualText(const String &line1, const String &line2 = "") {
  for (auto *d : {static_cast<M5GFX *>(&rca), static_cast<M5GFX *>(&M5.Display)}) {
    d->fillScreen(TFT_NAVY); d->setTextDatum(middle_center); d->setTextColor(TFT_WHITE, TFT_NAVY); d->setTextSize(2);
    d->drawString(line1, 160, 94); d->setTextColor(TFT_CYAN, TFT_NAVY); d->setTextSize(1); d->drawString(line2, 160, 130);
  }
}
void setError(const String &message) { lastError = message; state = ERROR_SCREEN; dualText("ERRO DO SISTEMA", message); Serial.printf("[M5RETRO] ERRO: %s\n", message.c_str()); }
bool makeDirectories() {
  // FAT directories are created one level at a time. Check each result so a
  // missing config folder never becomes a misleading file-open failure.
  const bool rootReady = SD.exists(ROOT) || SD.mkdir(ROOT);
  const bool videosReady = rootReady && (SD.exists(VIDEOS) || SD.mkdir(VIDEOS));
  const bool configReady = rootReady && (SD.exists(CONFIG) || SD.mkdir(CONFIG));
  const bool cacheReady = rootReady && (SD.exists("/M5RETRO/cache") || SD.mkdir("/M5RETRO/cache"));
  if (!rootReady || !videosReady || !configReady || !cacheReady) {
    Serial.printf("[M5RETRO] ERRO: pastas SD indisponiveis root:%d videos:%d config:%d cache:%d\n", rootReady, videosReady, configReady, cacheReady);
    return false;
  }
  return true;
}

void createSecretsExample() {
  if (SD.exists("/M5RETRO/config/secrets.example.json")) return;
  File example = SD.open("/M5RETRO/config/secrets.example.json", FILE_WRITE);
  if (!example) return;
  example.println("{");
  example.println("  \"wifi_ssid\": \"NOME_DA_REDE\",");
  example.println("  \"wifi_password\": \"SENHA_DA_REDE\",");
  example.println("  \"api_base_url\": \"https://app.meulab.fun\",");
  example.println("  \"aircraft_endpoint\": \"/ENDPOINT\",");
  example.println("  \"api_token\": \"TOKEN\"");
  example.println("}");
  example.close();
}

bool loadConfiguration() {
  File f;
  // A new card has no saved files yet. Test first so first boot is quiet and
  // still creates only the non-secret example file for the portal workflow.
  if (SD.exists(SETTINGS_FILE)) {
    f = SD.open(SETTINGS_FILE, FILE_READ);
    if (f) { JsonDocument d; if (!deserializeJson(d, f)) { settings.lat=d["radar_center_lat"]|0.0; settings.lon=d["radar_center_lon"]|0.0; settings.rangeKm=d["radar_radius_km"]|250; settings.refreshSeconds=d["refresh_seconds"]|5; settings.volume=d["volume"]|75; settings.experimental320=String((const char *)(d["video_quality"]|"240x160"))=="320x240"; settings.vhsOsd=d["vhs_osd"]|true; String audio=(const char *)(d["audio_output"]|"rca"); settings.audioOutput=audio=="interno"?AudioOutput::INTERNAL:audio=="mudo"?AudioOutput::MUTED:AudioOutput::RCA; } f.close(); }
  }
  if (!SD.exists(SECRETS_FILE)) { createSecretsExample(); return false; }
  f = SD.open(SECRETS_FILE, FILE_READ); if (!f) { return false; }
  JsonDocument d; DeserializationError e = deserializeJson(d, f); f.close(); if (e) return false;
  secrets.ssid=(const char *)(d["wifi_ssid"]|""); secrets.password=(const char *)(d["wifi_password"]|"");
  secrets.base=(const char *)(d["api_base_url"]|""); secrets.endpoint=(const char *)(d["aircraft_endpoint"]|"");
  secrets.authMode=(const char *)(d["auth_mode"]|"bearer"); secrets.authHeader=(const char *)(d["auth_header"]|"Authorization");
  secrets.authPrefix=(const char *)(d["auth_prefix"]|"Bearer "); secrets.token=(const char *)(d["api_token"]|""); secrets.insecure=d["allow_insecure_tls"]|false;
  networkConfigPresent = secrets.ssid.length() > 0;
  return networkConfigPresent;
}

void serviceWiFi() {
  if (!networkConfigPresent || playing) return;
  if (WiFi.status() == WL_CONNECTED) {
    if (wifiAttemptAt != 0) {
      wifiAttemptAt = 0;
      wifiRetryExponent = 0;
      apiStatus = "CONECTADA";
      Serial.printf("[M5RETRO] Wi-Fi conectado: IP %s RSSI %d\n", WiFi.localIP().toString().c_str(), WiFi.RSSI());
    }
    return;
  }
  const uint32_t now = millis();
  if (wifiAttemptAt && now - wifiAttemptAt >= 10000) {
    WiFi.disconnect(false, false);
    wifiAttemptAt = 0;
    uint8_t shift = wifiRetryExponent < 3 ? wifiRetryExponent++ : 3;
    wifiRetryAt = now + min<uint32_t>(60000, 5000UL << shift);
    apiStatus = "SEM WI-FI";
  }
  if (!wifiAttemptAt && now >= wifiRetryAt) {
    WiFi.mode(WIFI_STA);
    WiFi.begin(secrets.ssid.c_str(), secrets.password.c_str());
    wifiAttemptAt = now;
    apiStatus = "CONECTANDO WI-FI";
  }
}
void saveSettings() { JsonDocument d; d["radar_center_lat"]=settings.lat; d["radar_center_lon"]=settings.lon; d["radar_radius_km"]=settings.rangeKm; d["refresh_seconds"]=settings.refreshSeconds; d["volume"]=settings.volume; d["video_quality"]=settings.experimental320?"320x240":"240x160"; d["vhs_osd"]=settings.vhsOsd; d["audio_output"]=settings.audioOutput==AudioOutput::INTERNAL?"interno":settings.audioOutput==AudioOutput::MUTED?"mudo":"rca"; if (!makeDirectories()) return; File f=SD.open(SETTINGS_FILE, FILE_WRITE); if (f) { serializeJson(d,f); f.close(); } else Serial.println("[M5RETRO] ERRO: nao foi possivel gravar settings.json"); }

void drawSetupPortal() {
  String line1 = "CONFIGURACAO";
  String line2 = portal.active() ? "WI-FI: " + portal.apSsid() : "INICIANDO...";
  dualText(line1, line2);
  if (portal.active()) {
    rca.setTextDatum(top_left); rca.setTextColor(TFT_WHITE, TFT_NAVY); rca.setTextSize(1);
    rca.drawString("SENHA: " + portal.apPassword(), 26, 144);
    rca.drawString("ABRA: 192.168.4.1", 26, 162);
    rca.drawString(portal.status() == PortalStatus::CONFIGURANDO ? "CONFIGURACAO ATIVA" : "AGUARDANDO CELULAR...", 26, 180);
    M5.Display.fillScreen(TFT_NAVY); M5.Display.setTextDatum(top_left); M5.Display.setTextColor(TFT_WHITE,TFT_NAVY); M5.Display.setTextSize(2);
    M5.Display.drawString("M5 RETRO TV",12,8); M5.Display.drawFastHLine(8,36,304,TFT_CYAN); M5.Display.drawString("CONFIGURACAO",12,48); M5.Display.setTextSize(1); M5.Display.drawString("WI-FI: "+portal.apSsid(),20,88); M5.Display.drawString("SENHA: "+portal.apPassword(),20,112); M5.Display.drawString("ABRA: 192.168.4.1",20,136);
    drawControllerLabels("VOLTAR", "STATUS", "SAIR");
  }
}

void startSetupPortal() {
  if (playing) stopProgram();
  SecretsConfig saved; saved.ssid=secrets.ssid; saved.password=secrets.password; saved.baseUrl=secrets.base; saved.endpoint=secrets.endpoint; saved.authMode=secrets.authMode; saved.authHeader=secrets.authHeader; saved.authPrefix=secrets.authPrefix; saved.token=secrets.token; saved.allowInsecureTls=secrets.insecure;
  RadarConfig radar; radar.latitude=settings.lat; radar.longitude=settings.lon; radar.rangeKm=settings.rangeKm; radar.refreshSeconds=settings.refreshSeconds; radar.volume=settings.volume; radar.experimental320=settings.experimental320;
  portal.begin(secretsStore, saved, radar, portalSaved);
  state=SETUP_PORTAL;
  drawSetupPortal();
}

void portalSaved(const SecretsConfig &savedSecrets, const RadarConfig &savedSettings) {
  secrets.ssid=savedSecrets.ssid; secrets.password=savedSecrets.password; secrets.base=savedSecrets.baseUrl; secrets.endpoint=savedSecrets.endpoint; secrets.authMode=savedSecrets.authMode; secrets.authHeader=savedSecrets.authHeader; secrets.authPrefix=savedSecrets.authPrefix; secrets.token=savedSecrets.token; secrets.insecure=savedSecrets.allowInsecureTls;
  settings.lat=savedSettings.latitude; settings.lon=savedSettings.longitude; settings.rangeKm=savedSettings.rangeKm; settings.refreshSeconds=savedSettings.refreshSeconds; settings.volume=savedSettings.volume; settings.experimental320=savedSettings.experimental320;
  networkConfigPresent=true; portalSavePending=true; portalSaveStarted=millis(); wifiAttemptAt=0; wifiRetryAt=millis(); apiStatus="CONECTANDO WI-FI";
  dualText("CONFIGURACAO SALVA", "CONECTANDO AO WI-FI...");
}

bool initExternalAudio() {
  i2s_config_t c = {}; c.mode=(i2s_mode_t)(I2S_MODE_MASTER|I2S_MODE_TX); c.sample_rate=22050; c.bits_per_sample=I2S_BITS_PER_SAMPLE_16BIT;
  c.channel_format=I2S_CHANNEL_FMT_RIGHT_LEFT; c.communication_format=I2S_COMM_FORMAT_STAND_I2S; c.intr_alloc_flags=ESP_INTR_FLAG_LEVEL1;
  c.dma_buf_count=8; c.dma_buf_len=256; c.use_apll=false; c.tx_desc_auto_clear=true;
  if (i2s_driver_install(I2S_NUM_1, &c, 0, nullptr) != ESP_OK) return false;
  i2s_pin_config_t p = {}; p.bck_io_num=RCA_BCK; p.ws_io_num=RCA_LRCK; p.data_out_num=RCA_DATA; p.data_in_num=I2S_PIN_NO_CHANGE;
  return i2s_set_pin(I2S_NUM_1, &p) == ESP_OK;
}

bool openWavAndReadHeader() {
  // PCM WAV files are RIFF containers. Do not assume that the fmt and data
  // chunks are adjacent: common encoders insert LIST, JUNK, or fact chunks.
  uint8_t riff[12];
  if (wavFile.read(riff, sizeof(riff)) != sizeof(riff) || memcmp(riff, "RIFF", 4) || memcmp(riff + 8, "WAVE", 4)) {
    Serial.println("[M5RETRO] WAV invalido: RIFF/WAVE ausente");
    return false;
  }
  bool haveFormat = false;
  while (wavFile.available() >= 8) {
    uint8_t chunk[8];
    if (wavFile.read(chunk, sizeof(chunk)) != sizeof(chunk)) break;
    const uint32_t chunkSize = (uint32_t)chunk[4] | ((uint32_t)chunk[5] << 8) | ((uint32_t)chunk[6] << 16) | ((uint32_t)chunk[7] << 24);
    const uint32_t chunkStart = wavFile.position();
    if (!memcmp(chunk, "fmt ", 4)) {
      uint8_t fmt[16];
      if (chunkSize < sizeof(fmt) || wavFile.read(fmt, sizeof(fmt)) != sizeof(fmt)) break;
      const uint16_t format = fmt[0] | (fmt[1] << 8);
      wavChannels = fmt[2] | (fmt[3] << 8);
      sampleRate = (uint32_t)fmt[4] | ((uint32_t)fmt[5] << 8) | ((uint32_t)fmt[6] << 16) | ((uint32_t)fmt[7] << 24);
      wavBlockAlign = fmt[12] | (fmt[13] << 8);
      const uint16_t bits = fmt[14] | (fmt[15] << 8);
      if (format != 1 || wavChannels != 2 || bits != 16 || sampleRate != 22050 || wavBlockAlign != 4) {
        Serial.printf("[M5RETRO] WAV nao suportado: formato:%u canais:%u bits:%u taxa:%lu bloco:%u\n", format, wavChannels, bits, sampleRate, wavBlockAlign);
        return false;
      }
      haveFormat = true;
    } else if (!memcmp(chunk, "data", 4)) {
      if (!haveFormat) { Serial.println("[M5RETRO] WAV invalido: data antes de fmt"); return false; }
      wavDataStart = chunkStart;
      wavDataEnd = chunkStart + chunkSize;
      if (wavDataEnd > wavFile.size()) wavDataEnd = wavFile.size();
      wavFile.seek(wavDataStart);
      Serial.printf("[M5RETRO] WAV PCM pronto: %lu Hz stereo, %lu bytes\n", sampleRate, wavDataEnd - wavDataStart);
      return wavDataEnd > wavDataStart;
    }
    uint32_t next = chunkStart + chunkSize + (chunkSize & 1U);
    if (next <= chunkStart || next > wavFile.size() || !wavFile.seek(next)) break;
  }
  Serial.println("[M5RETRO] WAV invalido: bloco fmt ou data ausente");
  return false;
}

void audioTask(void *) {
  // These buffers remain in internal RAM. The speaker queue also needs the source
  // blocks to remain valid after playRaw returns, so three blocks are rotated.
  uint8_t *buffers[3] = {};
  for (auto &buffer : buffers) buffer=(uint8_t *)heap_caps_malloc(AUDIO_CHUNK, MALLOC_CAP_DMA|MALLOC_CAP_INTERNAL);
  if (!buffers[0] || !buffers[1] || !buffers[2]) { audioUnderruns++; vTaskDelete(nullptr); }
  uint8_t bufferIndex=0; AudioOutput active=AudioOutput::RCA;
  bool outputPaused = false;
  int64_t pcmDueUs=0;
  for (;;) {
    AudioOutput wanted=audioOutput;
    if (wanted != active) {
      // Core2 internal audio and the RCA module share DATA and LRCK. Stop I2S1,
      // rather than merely filling it with zeroes, so it no longer drives those lines.
      if (active == AudioOutput::RCA) { i2s_zero_dma_buffer(I2S_NUM_1); i2s_stop(I2S_NUM_1); }
      if (active == AudioOutput::INTERNAL) M5.Speaker.end();
      if (wanted == AudioOutput::INTERNAL) {
        M5.Speaker.begin();
        M5.Speaker.setVolume(settings.volume);
        Serial.printf("[M5RETRO] Audio: alto-falante interno, volume:%d\n", settings.volume);
      } else if (wanted == AudioOutput::RCA) {
        i2s_pin_config_t pins = {}; pins.bck_io_num=RCA_BCK; pins.ws_io_num=RCA_LRCK; pins.data_out_num=RCA_DATA; pins.data_in_num=I2S_PIN_NO_CHANGE;
        i2s_set_pin(I2S_NUM_1, &pins);
        i2s_set_sample_rates(I2S_NUM_1, sampleRate);
        i2s_start(I2S_NUM_1);
        Serial.println("[M5RETRO] Audio: RCA selecionado");
      } else {
        Serial.println("[M5RETRO] Audio: mudo selecionado");
      }
      active=wanted;
      pcmDueUs=0;
    }
    // Pause must silence the physical output as well as stop file reads. Clear
    // the RCA DMA queue so a tap does not leave already-buffered PCM playing.
    if (!playing || paused || !wavFile) {
      if (!outputPaused && active == AudioOutput::RCA) {
        i2s_zero_dma_buffer(I2S_NUM_1);
        i2s_stop(I2S_NUM_1);
      }
      if (!outputPaused && active == AudioOutput::INTERNAL) M5.Speaker.end();
      outputPaused = true;
      pcmDueUs=0;
      vTaskDelay(pdMS_TO_TICKS(8));
      continue;
    }
    if (outputPaused) {
      if (active == AudioOutput::RCA) i2s_start(I2S_NUM_1);
      else if (active == AudioOutput::INTERNAL) { M5.Speaker.begin(); M5.Speaker.setVolume(settings.volume); }
      outputPaused = false;
      pcmDueUs=0;
    }
    uint8_t *buf=buffers[bufferIndex];
    if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(100)) != pdTRUE) { audioUnderruns++; continue; }
    const uint32_t position = wavFile.position();
    if (position >= wavDataEnd) {
      xSemaphoreGive(sdMutex);
      playbackFinished = true;
      playing = false;
      Serial.println("[M5RETRO] Fim do audio: programa concluido");
      vTaskDelay(pdMS_TO_TICKS(8));
      continue;
    }
    const size_t remaining = wavDataEnd - position;
    size_t bytes=wavFile.read(buf, min(AUDIO_CHUNK, remaining));
    xSemaphoreGive(sdMutex);
    if (!bytes) {
      audioUnderruns++;
      playbackFinished = true;
      playing = false;
      Serial.println("[M5RETRO] ERRO: leitura WAV terminou antes do esperado");
      vTaskDelay(pdMS_TO_TICKS(8));
      continue;
    }
    size_t delivered=bytes;
    if (active == AudioOutput::RCA) {
      size_t written=0; esp_err_t ok=i2s_write(I2S_NUM_1,buf,bytes,&written,pdMS_TO_TICKS(100));
      if (ok != ESP_OK || written != bytes) audioUnderruns++; delivered=written;
    } else if (active == AudioOutput::INTERNAL) {
      if (!M5.Speaker.playRaw((const int16_t *)buf, bytes / sizeof(int16_t), sampleRate, true, 1, -1, false)) audioUnderruns++;
    }
    // i2s_write and playRaw may accept a block before it has physically played.
    // Pace every destination from the PCM sample count, so the video clock is
    // 15 FPS for this file instead of running at the SD/queue feed rate.
    const uint32_t frames=delivered / wavBlockAlign;
    if (!pcmDueUs) pcmDueUs=esp_timer_get_time();
    pcmDueUs += ((int64_t)frames * 1000000LL) / sampleRate;
    const int64_t waitUs=pcmDueUs-esp_timer_get_time();
    if (waitUs>0) vTaskDelay(pdMS_TO_TICKS((waitUs+999)/1000));
    // Advance the player clock only after this PCM block has had time to leave
    // the selected output. A queued block must not make the timer/video jump.
    if (playing && !paused) samplesPlayed += frames;
    bufferIndex=(bufferIndex+1)%3;
  }
}

int jpegDraw(JPEGDRAW *draw) {
  // One JPEGDEC callback feeds both targets; JPEG data is decoded only once.
  rca.pushImage(draw->x, draw->y, draw->iWidth, draw->iHeight, (uint16_t *)draw->pPixels);
  // LCD preview occupies its upper area; the lower 70 pixels remain controller UI.
  if ((videoFrameIndex % lcdPreviewDivider) == 0) {
    // Keep the LCD preview entirely above the fixed 160..239 status/control area.
    // JPEGDEC coordinates are unsigned, so calculate the shifted LCD position
    // as signed values before clipping it.
    const int lcdX = (int)draw->x;
    const int lcdY = (int)draw->y - 40;
    if (lcdY >= 0 && lcdY + (int)draw->iHeight <= 160) {
      M5.Display.pushImage(lcdX, lcdY, draw->iWidth, draw->iHeight, (uint16_t *)draw->pPixels);
    }
  }
  return 1;
}

bool validateMjpegStream() {
  if (!mjpegFile || !sdMutex) return false;
  const uint32_t savedPosition = mjpegFile.position();
  bool sawStart = false, sawCompleteFrame = false;
  uint8_t previous = 0;
  uint8_t block[512];
  size_t scanned = 0;
  if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(250)) != pdTRUE) return false;
  mjpegFile.seek(0);
  mjpegFile.seek(0);
  // A raw MJPEG stream must contain an SOI/EOI JPEG pair near its beginning.
  // Limit this preflight to 512 KiB so a damaged multi-gigabyte file never
  // keeps the menu blocked indefinitely.
  while (scanned < 512UL * 1024UL) {
    const size_t got = mjpegFile.read(block, sizeof(block));
    if (!got) break;
    for (size_t i = 0; i < got; ++i) {
      const uint8_t value = block[i];
      if (!sawStart && previous == 0xFF && value == 0xD8) sawStart = true;
      else if (sawStart && previous == 0xFF && value == 0xD9) { sawCompleteFrame = true; break; }
      previous = value;
    }
    scanned += got;
    if (sawCompleteFrame) break;
    vTaskDelay(1);
  }
  mjpegFile.seek(savedPosition);
  xSemaphoreGive(sdMutex);
  if (!sawCompleteFrame) {
    Serial.printf("[M5RETRO] MJPEG invalido: JPEG SOI/EOI ausente nos primeiros %u bytes\n", (unsigned)scanned);
    return false;
  }
  Serial.printf("[M5RETRO] MJPEG pronto: quadro JPEG encontrado em %u bytes\n", (unsigned)scanned);
  return true;
}

bool readAndShowOneFrame() {
  if (!mjpegFile || !jpegBuffer) return false; int previous=-1, value; size_t used=0;
  if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(150)) != pdTRUE) return false;
  while ((value=mjpegFile.read()) >= 0) {
    if (!used) { if (previous==0xFF && value==0xD8) { jpegBuffer[0]=0xFF; jpegBuffer[1]=0xD8; used=2; } previous=value; continue; }
    if (used >= MAX_JPEG) { jpegErrors++; used=0; previous=value; continue; }
    jpegBuffer[used++]=(uint8_t)value;
    if (previous==0xFF && value==0xD9) {
      xSemaphoreGive(sdMutex);
      uint32_t t=millis();
      // JPEGDEC openRAM returns a nonzero value when the JPEG header opened.
      // JPEG_SUCCESS is an error-code value, not this function's success value.
      // Comparing the two made every valid first frame look like a failure.
      const int openResult = jpeg.openRAM(jpegBuffer, used, jpegDraw);
      if (!openResult) {
        Serial.printf("[M5RETRO] JPEG nao abriu: frame:%lu bytes:%u resultado:%d erro:%d\n", (unsigned long)videoFrameIndex, (unsigned)used, openResult, jpeg.getLastError());
        jpegErrors++;
        return false;
      }
      // M5GFX pushImage and the RCA framebuffer receive native ESP32 RGB565
      // uint16_t pixels. Keep JPEGDEC in little-endian/native order; swapping
      // bytes here corrupts the red, green, and blue channel fields.
      jpeg.setPixelType(RGB565_LITTLE_ENDIAN);
      const bool decoded = jpeg.decode((CRT_W-240)/2, (CRT_H-160)/2, 0);
      jpeg.close();
      if (!decoded) {
        Serial.printf("[M5RETRO] JPEG nao decodificou: frame:%lu bytes:%u erro:%d\n", (unsigned long)videoFrameIndex, (unsigned)used, jpeg.getLastError());
        jpegErrors++;
        return false;
      }
      decodedFrames++;
      renderedFrames++;
      if ((videoFrameIndex % lcdPreviewDivider) == 0) lcdFramesRendered++;
      uint32_t dt=millis()-t;
      jpegDecodeTotalMs+=dt;
      if(dt>jpegDecodeMaxMs) jpegDecodeMaxMs=dt;
      lcdPreviewDivider = dt > 45 ? 3 : 2;
      return true;
    }
    previous=value;
  }
  mjpegFile.seek(0); xSemaphoreGive(sdMutex); return false;
}

bool startProgram(const String &dir) {
  // meta.json is optional for simple card folders. Without it, the standard
  // file names and 15 FPS are used so a valid MJPEG/WAV pair is still playable.
  String video="video.mjpeg", audio="audio.wav";
  currentTitle=dir.substring(dir.lastIndexOf('/') + 1); fps=15.0f;
  File meta=SD.open(dir+"/meta.json",FILE_READ);
  if(meta) { JsonDocument d; DeserializationError error=deserializeJson(d,meta); meta.close(); if(error) { Serial.println("[M5RETRO] meta.json invalido"); return false; } currentTitle=(const char *)(d["title"]|currentTitle.c_str()); fps=d["fps"]|15.0f; video=(const char *)(d["video"]|video.c_str()); audio=(const char *)(d["audio"]|audio.c_str()); }
  if (fps < 1.0f || fps > 30.0f) { Serial.printf("[M5RETRO] FPS invalido no meta.json: %.2f\n", fps); return false; }
  mjpegFile=SD.open(dir+"/"+video,FILE_READ);
  wavFile=SD.open(dir+"/"+audio,FILE_READ);
  if (!mjpegFile) Serial.printf("[M5RETRO] MJPEG nao abriu: %s/%s\n", dir.c_str(), video.c_str());
  if (!wavFile) Serial.printf("[M5RETRO] WAV nao abriu: %s/%s\n", dir.c_str(), audio.c_str());
  if(!mjpegFile || !wavFile || !openWavAndReadHeader() || !validateMjpegStream()) {
    if(mjpegFile)mjpegFile.close();
    if(wavFile)wavFile.close();
    return false;
  }
  samplesPlayed=0; videoFrameIndex=0; playbackFinished=false; playing=true; paused=false; state=VIDEO_PLAYBACK;
  rca.fillScreen(TFT_BLACK); M5.Display.fillScreen(TFT_BLACK); osdUntil=millis()+3000;
  Serial.printf("[M5RETRO] Reproduzindo: %s video:%lu bytes wav:%lu bytes\n", dir.c_str(), mjpegFile.size(), wavDataEnd-wavDataStart);
  drawPlaybackController();
  return true;
}
void stopProgram() { playing=false; vTaskDelay(pdMS_TO_TICKS(20)); if(mjpegFile)mjpegFile.close(); if(wavFile)wavFile.close(); state=VIDEO_LIBRARY; }
void videoTick() {
  if(!playing || paused) return;
  const uint32_t target=(uint32_t)((samplesPlayed*fps)/sampleRate);
  while(videoFrameIndex+1<target) {
    if(!readAndShowOneFrame()) { playbackFinished=true; playing=false; Serial.println("[M5RETRO] Fim do MJPEG: programa concluido"); return; }
    videoFrameIndex++;
    droppedFrames++;
  }
  if(target>videoFrameIndex) {
    if(readAndShowOneFrame()) videoFrameIndex++;
    else { playbackFinished=true; playing=false; Serial.println("[M5RETRO] Fim do MJPEG: programa concluido"); }
  }
}

String stringAlias(JsonObject o,const char*a,const char*b=nullptr,const char*c=nullptr){ if(o[a].is<const char*>())return o[a].as<const char*>(); if(b&&o[b].is<const char*>())return o[b].as<const char*>(); if(c&&o[c].is<const char*>())return o[c].as<const char*>(); return ""; }
double numberAlias(JsonObject o,const char*a,const char*b=nullptr,const char*c=nullptr){if(!o[a].isNull())return o[a].as<double>();if(b&&!o[b].isNull())return o[b].as<double>();if(c&&!o[c].isNull())return o[c].as<double>();return 0;}
bool parseAircraft(JsonDocument &doc) { JsonArray a; if(doc.is<JsonArray>())a=doc.as<JsonArray>(); else if(doc["aircraft"].is<JsonArray>())a=doc["aircraft"].as<JsonArray>(); else if(doc["data"].is<JsonArray>())a=doc["data"].as<JsonArray>(); else return false; aircraftCount=0; for(JsonObject o:a){if(aircraftCount>=64)break; Aircraft &p=aircraft[aircraftCount]; p.icao=stringAlias(o,"icao","icao24","hex");p.callsign=stringAlias(o,"callsign","flight");p.latitude=numberAlias(o,"lat","latitude");p.longitude=numberAlias(o,"lon","lng","longitude");p.altitude_ft=numberAlias(o,"altitude","altitude_ft","baro_altitude");p.speed_kt=numberAlias(o,"speed","velocity","ground_speed");if(!p.speed_kt)p.speed_kt=numberAlias(o,"ground_speed_kt");p.heading_deg=numberAlias(o,"heading","track");p.aircraft_type=stringAlias(o,"aircraft_type","type");p.valid_position=!(o["lat"].isNull()&&o["latitude"].isNull());if(p.valid_position)aircraftCount++;} return true; }
void pollAircraft() {
  if(playing || millis()-lastApiPoll < (uint32_t)settings.refreshSeconds*1000) return; lastApiPoll=millis();
  if(!networkConfigPresent || secrets.endpoint.indexOf("REPLACE")>=0){apiStatus="NAO CONFIGURADA";return;}
  if(WiFi.status()!=WL_CONNECTED){apiStatus="SEM WI-FI";return;}
  WiFiClientSecure client; if(secrets.insecure){client.setInsecure();Serial.println("[M5RETRO] AVISO: TLS inseguro habilitado explicitamente");}else {File ca=SD.open(CA_FILE,FILE_READ);if(!ca){apiStatus="CONFIGURACAO INVALIDA";return;}String pem=ca.readString();ca.close();client.setCACert(pem.c_str());}
  HTTPClient http;   if(!http.begin(client,secrets.base+secrets.endpoint)){apiStatus="SERVIDOR INDISPONIVEL";return;}
  String credential=secrets.authMode=="bearer" ? secrets.authPrefix+secrets.token : secrets.token; http.addHeader(secrets.authHeader,credential); int code=http.GET();
  if(code==HTTP_CODE_OK){JsonDocument doc; DeserializationError e=deserializeJson(doc,http.getStream());if(!e&&parseAircraft(doc)){File f=SD.open(CACHE_FILE,FILE_WRITE);if(f){serializeJson(doc,f);f.close();}lastApiGood=millis();apiStatus="CONECTADA";}else apiStatus="RESPOSTA INVALIDA DA API";} else if(code==401||code==403) apiStatus="ERRO DE AUTENTICACAO DA API"; else apiStatus="SERVIDOR INDISPONIVEL"; http.end();
}

String normalizeSdPath(const String &path) {
  // ESP32 FS directory entries can be reported with the internal VFS mount
  // prefix (/sd). SD.open and SD.exists require the public card path instead.
  if (path.startsWith("/sd/")) return path.substring(3);
  if (path == "/sd") return "/";
  return path;
}

String libraryChildPath(const String &root, const String &entryName) {
  // FAT directory iteration may return only a child name (for example
  // "primeiro-teste") instead of an absolute SD path. Always rebuild the
  // physical card path from the root currently being scanned.
  const String child = normalizeSdPath(entryName);
  if (child.startsWith("/")) return child;
  return root + "/" + child;
}

bool isProgramFolder(const String &path) {
  // A complete package can provide metadata, or use the documented default
  // stream names when it was prepared by an older exporter.
  const String cardPath = normalizeSdPath(path);
  const bool valid = SD.exists(cardPath + "/meta.json") ||
                     (SD.exists(cardPath + "/video.mjpeg") && SD.exists(cardPath + "/audio.wav"));
  Serial.printf("[M5RETRO] Pasta SD: %s %s\n", cardPath.c_str(), valid ? "PROGRAMA" : "IGNORADA");
  return valid;
}

String libraryRoot() {
  // New cards use /M5RETRO/videos. Older prepared cards often use /videos.
  // Select the first location that actually contains a playable program folder.
  const char *roots[] = { VIDEOS, "/videos" };
  for (const char *candidate : roots) {
    File root = SD.open(candidate);
    if (!root || !root.isDirectory()) { if (root) root.close(); continue; }
    bool found = false;
    for (File entry = root.openNextFile(); entry; entry = root.openNextFile()) {
      if (entry.isDirectory() && isProgramFolder(libraryChildPath(candidate, String(entry.name())))) found = true;
      entry.close();
      if (found) break;
    }
    root.close();
    if (found) return String(candidate);
  }
  return String(VIDEOS);
}

int libraryProgramCount() {
  int count = 0;
  const String rootPath = libraryRoot();
  File root = SD.open(rootPath);
  if (!root) return 0;
  for (File entry = root.openNextFile(); entry; entry = root.openNextFile()) {
    if (entry.isDirectory() && isProgramFolder(libraryChildPath(rootPath, String(entry.name())))) ++count;
    entry.close();
  }
  root.close();
  Serial.printf("[M5RETRO] Biblioteca: %d programa(s) em %s\n", count, rootPath.c_str());
  return count;
}

String libraryProgramAt(int wantedIndex) {
  int index = 0;
  File root = SD.open(libraryRoot());
  if (!root) return "";
  for (File entry = root.openNextFile(); entry; entry = root.openNextFile()) {
    if (entry.isDirectory() && isProgramFolder(libraryChildPath(libraryRoot(), String(entry.name())))) {
      if (index++ == wantedIndex) { String path = libraryChildPath(libraryRoot(), String(entry.name())); entry.close(); root.close(); return path; }
    }
    entry.close();
  }
  root.close();
  return "";
}

String playbackClock() {
  uint32_t seconds = sampleRate ? samplesPlayed / sampleRate : 0;
  char text[12]; snprintf(text, sizeof(text), "%02lu:%02lu:%02lu", seconds / 3600UL, (seconds / 60UL) % 60UL, seconds % 60UL); return String(text);
}

void drawPlaybackController() {
  const uint32_t bytesPerSecond = sampleRate * 4UL;
  uint32_t total = bytesPerSecond ? (wavDataEnd - wavDataStart) / bytesPerSecond : 0;
  char totalText[12]; snprintf(totalText, sizeof(totalText), "%02lu:%02lu:%02lu", total / 3600UL, (total / 60UL) % 60UL, total % 60UL);
  M5.Display.fillRect(0, 160, 320, 24, TFT_NAVY);
  M5.Display.setTextDatum(top_left); M5.Display.setTextSize(1); M5.Display.setTextColor(TFT_WHITE, TFT_NAVY);
  M5.Display.drawString(String(paused ? "PAUSA" : "PLAY") + "   " + playbackClock() + " / " + totalText, 10, 162);
  int progress = total ? constrain((int)((samplesPlayed / (float)sampleRate) * 292.0f / total), 0, 292) : 0;
  M5.Display.drawRect(14, 177, 292, 4, TFT_CYAN); M5.Display.fillRect(14, 177, progress, 4, TFT_YELLOW);
  drawControllerLabels("ANTERIOR", paused ? "PLAY" : "PAUSA", "PROXIMO");
}

void drawPlaybackOsd() {
  if (millis() < osdUntil) {
    rca.fillRect(0, 202, 320, 38, TFT_NAVY);
    rca.setTextDatum(top_left); rca.setTextSize(1); rca.setTextColor(TFT_WHITE, TFT_NAVY);
    if (settings.vhsOsd) rca.drawString(String(paused ? "PAUSE" : "PLAY  SP") + "   " + playbackClock(), 6, 205);
    else rca.drawString(String(PTBR::APP) + "  " + currentTitle, 6, 205);
    rca.setTextColor(TFT_CYAN, TFT_NAVY);
    rca.drawString(String("[ ") + PTBR::ANTERIOR + " ]  [ " + (paused ? PTBR::REPRODUZIR : PTBR::PAUSAR) + " ]  [ " + PTBR::PROXIMO + " ]", 6, 222);
  }
  if (millis() - lastPlayerUiDraw >= 250) { lastPlayerUiDraw = millis(); drawPlaybackController(); }
}

void setAudioOutput(AudioOutput output) {
  // Output changes are consumed by audioTask between PCM blocks, never from the UI path.
  settings.audioOutput = output; audioOutput = output; saveSettings(); osdUntil = millis() + 3000;
}

void servicePowerButton() {
  // M5.BtnPWR is M5Unified's debounced event for the Core2 side Power key.
  // M5.update() runs before this function, so one physical press becomes one
  // shutdown request. The AXP192 then restores power on the next Power press.
  if (!powerOffPending && M5.BtnPWR.wasClicked()) {
    powerOffPending = true;
    powerOffAt = millis() + 120;
    playing = false;
    paused = true;
    if (portal.active()) portal.stop();
    WiFi.disconnect(false, false);
    Serial.println("[M5RETRO] Botao Power: desligamento solicitado");
    dualText("DESLIGANDO...", "APERTE POWER PARA LIGAR");
    return;
  }
  if (powerOffPending && (int32_t)(millis() - powerOffAt) >= 0) {
    Serial.println("[M5RETRO] Alimentacao do Core2 desligada pelo AXP192");
    Serial.flush();
    i2s_zero_dma_buffer(I2S_NUM_1);
    i2s_stop(I2S_NUM_1);
    if (audioOutput == AudioOutput::INTERNAL) M5.Speaker.end();
    M5.Power.powerOff();
  }
}

void drawControllerLabels(const char *left, const char *center, const char *right) {
  const char *labels[3] = {left, center, right};
  M5.Display.fillRect(0, 184, 320, 56, TFT_NAVY);
  M5.Display.setTextDatum(middle_center); M5.Display.setTextSize(1);
  for (int i = 0; i < 3; ++i) {
    const int x = i == 0 ? 6 : i == 1 ? 110 : 214;
    const int w = i == 1 ? 100 : 100;
    const bool hot = input.highlightActive() && input.highlightedButton() == i;
    uint32_t fill = hot ? TFT_CYAN : TFT_BLUE;
    uint32_t ink = hot ? TFT_NAVY : TFT_WHITE;
    M5.Display.fillRoundRect(x, 190, w, 43, 4, fill);
    M5.Display.drawRoundRect(x, 190, w, 43, 4, TFT_WHITE);
    M5.Display.setTextColor(ink, fill);
    M5.Display.drawString(labels[i], x + w / 2, 211);
  }
  if(millis()<osdUntil){rca.fillRect(0,220,320,20,TFT_NAVY);rca.setTextDatum(middle_center);rca.setTextSize(1);rca.setTextColor(TFT_CYAN,TFT_NAVY);rca.drawString(String("[ ")+left+" ]  [ "+center+" ]  [ "+right+" ]",160,230);}
}
void drawHome(){
  const char *items[]={PTBR::VIDEOS,PTBR::TRAFEGO,PTBR::CONFIGURACOES,PTBR::INFO_SISTEMA};
  M5.Display.fillScreen(TFT_NAVY); M5.Display.setTextDatum(top_left); M5.Display.setTextColor(TFT_WHITE,TFT_NAVY); M5.Display.setTextSize(2); M5.Display.drawString(PTBR::APP,12,8);
  M5.Display.drawFastHLine(8,36,304,TFT_CYAN); M5.Display.setTextSize(2);
  for(int i=0;i<4;i++){int y=48+i*32;bool selected=i==homeSelection; if(selected)M5.Display.fillRoundRect(12,y-3,296,28,4,TFT_CYAN); M5.Display.setTextColor(selected?TFT_NAVY:TFT_WHITE,selected?TFT_CYAN:TFT_NAVY); M5.Display.drawString(String(selected?"> ":"  ")+items[i],24,y);}
  rca.fillScreen(TFT_NAVY); rca.setTextDatum(top_left); rca.setTextSize(2); rca.setTextColor(TFT_WHITE,TFT_NAVY); rca.drawString(PTBR::APP,12,8); rca.drawFastHLine(8,36,304,TFT_CYAN); rca.setTextSize(1); for(int i=0;i<4;i++){int y=55+i*30;rca.setTextColor(i==homeSelection?TFT_CYAN:TFT_WHITE,TFT_NAVY);rca.drawString(String(i==homeSelection?"> ":"  ")+items[i],28,y);}
  drawControllerLabels("ACIMA","OK","ABAIXO");
}
void drawLibrary(){
  M5.Display.fillScreen(TFT_NAVY); M5.Display.setTextDatum(top_left); M5.Display.setTextSize(2); M5.Display.setTextColor(TFT_WHITE,TFT_NAVY); M5.Display.drawString(PTBR::VIDEOS,12,8); M5.Display.drawFastHLine(8,36,304,TFT_CYAN);
  rca.fillScreen(TFT_NAVY); rca.setTextDatum(top_left); rca.setTextSize(2); rca.setTextColor(TFT_WHITE,TFT_NAVY); rca.drawString(PTBR::VIDEOS,12,8); rca.drawFastHLine(8,36,304,TFT_CYAN);
  const String rootPath=libraryRoot(); int y=52,index=0; File root=SD.open(rootPath); for(File e=root.openNextFile();e&&index<4;e=root.openNextFile()){if(e.isDirectory()&&isProgramFolder(libraryChildPath(rootPath, String(e.name())))){bool selected=index==librarySelection;M5.Display.fillRoundRect(12,y-2,296,28,4,selected?TFT_CYAN:TFT_NAVY);M5.Display.setTextSize(selected?2:1);M5.Display.setTextColor(selected?TFT_NAVY:TFT_WHITE,selected?TFT_CYAN:TFT_NAVY);M5.Display.drawString(String(selected?"> ":"  ")+String(e.name()),24,y+(selected?1:5));rca.setTextSize(1);rca.setTextColor(selected?TFT_CYAN:TFT_WHITE,TFT_NAVY);rca.drawString(String(selected?"> ":"  ")+String(e.name()),24,y+6);y+=32;index++;}e.close();}root.close();if(!index){M5.Display.setTextColor(TFT_YELLOW,TFT_NAVY);M5.Display.drawString("SEM VIDEOS NO CARTAO",30,92);rca.drawString("SEM VIDEOS NO CARTAO",30,92);Serial.printf("[M5RETRO] Biblioteca vazia. Formato esperado: %s/NOME/meta.json, video.mjpeg e audio.wav\n", libraryRoot().c_str());}drawControllerLabels("ANTERIOR","PLAY","PROXIMO");
}
void drawRadar(){
  for(auto *d:{static_cast<M5GFX*>(&rca),static_cast<M5GFX*>(&M5.Display)}){d->fillScreen(TFT_NAVY);d->setTextDatum(top_left);d->setTextSize(1);d->setTextColor(TFT_WHITE,TFT_NAVY);d->drawString(PTBR::APP,10,7);d->drawString(PTBR::TRAFEGO,10,22);d->setTextColor(TFT_CYAN,TFT_NAVY);d->drawCircle(105,105,62,TFT_CYAN);d->drawFastHLine(43,105,124,TFT_DARKCYAN);d->drawFastVLine(105,43,124,TFT_DARKCYAN);for(int i=0;i<aircraftCount;i++){double y=(aircraft[i].latitude-settings.lat)*111.0;double x=(aircraft[i].longitude-settings.lon)*111.0*cos(settings.lat*DEG_TO_RAD);int px=105+(int)(x/settings.rangeKm*62),py=105-(int)(y/settings.rangeKm*62);if(sq(px-105)+sq(py-105)<sq(62))d->fillTriangle(px,py-4,px-3,py+3,px+3,py+3,i==radarSelection?TFT_CYAN:TFT_YELLOW);}d->setTextColor(TFT_WHITE,TFT_NAVY); if(radarSelection>=0&&radarSelection<aircraftCount){Aircraft&p=aircraft[radarSelection];d->drawString(p.callsign,190,62);d->drawString("FL "+String((int)(p.altitude_ft/100)),190,82);d->drawString(String((int)p.speed_kt)+" KT",190,98);}else d->drawString(String(PTBR::AERONAVES_RASTREADAS)+": "+aircraftCount,190,72);d->drawString(apiStatus,190,122);if(!aircraftCount)d->drawString(PTBR::NENHUMA_AERONAVE,28,172);}drawControllerLabels("ANTERIOR","DETALHES","PROXIMO");
}
void drawSettings(){
  const char* names[]={"VIDEO","QUALIDADE VIDEO","VOLUME","ALCANCE RADAR","ATUALIZACAO",PTBR::SAIDA_AUDIO,PTBR::OSD_ESTILO_VHS,"WI-FI","API","CARTAO SD","IDIOMA","CONFIGURAR REDE"};
  String audio=settings.audioOutput==AudioOutput::RCA?PTBR::RCA:settings.audioOutput==AudioOutput::INTERNAL?PTBR::ALTO_FALANTE_INTERNO:PTBR::MUDO;
  String value=settingsSelection==0?"PAL-M":settingsSelection==1?(settings.experimental320?"320x240":"240x160"):settingsSelection==2?String(settings.volume)+"%":settingsSelection==3?String(settings.rangeKm)+" km":settingsSelection==4?String(settings.refreshSeconds)+" s":settingsSelection==5?audio:settingsSelection==6?(settings.vhsOsd?PTBR::ATIVADO:PTBR::DESATIVADO):settingsSelection==7?(WiFi.status()==WL_CONNECTED?PTBR::CONECTADO:PTBR::DESCONECTADO):settingsSelection==8?apiStatus:settingsSelection==9?PTBR::DISPONIVEL:settingsSelection==10?"PORTUGUES BR":"ABRIR";
  M5.Display.fillScreen(TFT_NAVY);M5.Display.setTextDatum(top_left);M5.Display.setTextSize(2);M5.Display.setTextColor(TFT_WHITE,TFT_NAVY);M5.Display.drawString(PTBR::CONFIGURACOES,12,8);M5.Display.drawFastHLine(8,36,304,TFT_CYAN);M5.Display.setTextSize(2);M5.Display.setTextColor(TFT_CYAN,TFT_NAVY);M5.Display.drawString(String(settingsEditing?"> ":"  ")+names[settingsSelection],20,68);M5.Display.setTextSize(3);M5.Display.setTextColor(TFT_WHITE,TFT_NAVY);M5.Display.drawString(value,34,110);rca.fillScreen(TFT_NAVY);rca.setTextDatum(top_left);rca.setTextSize(2);rca.setTextColor(TFT_WHITE,TFT_NAVY);rca.drawString(PTBR::CONFIGURACOES,12,8);rca.drawFastHLine(8,36,304,TFT_CYAN);rca.setTextSize(1);rca.setTextColor(TFT_CYAN,TFT_NAVY);rca.drawString(String(settingsEditing?"> ":"  ")+names[settingsSelection],20,74);rca.setTextColor(TFT_WHITE,TFT_NAVY);rca.drawString(value,34,105);drawControllerLabels(settingsEditing?"-":"ACIMA",settingsEditing?"SALVAR":"OK",settingsEditing?"+":"ABAIXO");
}
void drawInfo(){String status=WiFi.status()==WL_CONNECTED?PTBR::CONECTADO:PTBR::DESCONECTADO;String detalhe;if(infoPage){detalhe=String("WI-FI: ")+status+"  "+PTBR::SINAL+": "+WiFi.RSSI()+" dBm";if(WiFi.status()==WL_CONNECTED) detalhe+="  "+String(PTBR::ENDERECO_IP)+": "+WiFi.localIP().toString();}else detalhe=String("MEMORIA: ")+ESP.getFreeHeap()+"  PSRAM: "+ESP.getFreePsram();dualText(PTBR::INFO_SISTEMA,detalhe);drawControllerLabels(PTBR::ANTERIOR,PTBR::DETALHES,PTBR::PROXIMO);}

void handleNavigation(NavAction a){
  if(a==NavAction::NONE)return; osdUntil=millis()+3000;
  if(a==NavAction::HOME){state=HOME;settingsEditing=false;drawHome();return;}
  if(state==HOME){if(a==NavAction::LEFT)homeSelection=(homeSelection+3)%4;else if(a==NavAction::RIGHT)homeSelection=(homeSelection+1)%4;else if(a==NavAction::SELECT){state=(UiState)(VIDEO_LIBRARY+homeSelection);if(state==VIDEO_LIBRARY)drawLibrary();else if(state==AIRCRAFT_RADAR)drawRadar();else if(state==SETTINGS)drawSettings();else drawInfo();return;}drawHome();return;}
  if(state==VIDEO_LIBRARY){
    int count=libraryProgramCount();
    if(a==NavAction::BACK){state=HOME;drawHome();return;}
    if(count&&a==NavAction::LEFT)librarySelection=(librarySelection+count-1)%count;
    else if(count&&a==NavAction::RIGHT)librarySelection=(librarySelection+1)%count;
    else if(a==NavAction::SELECT&&count){
      if(!startProgram(libraryProgramAt(librarySelection))) { setError(PTBR::VIDEO_CORROMPIDO); return; }
      // startProgram already changed to VIDEO_PLAYBACK and drew its controller.
      // Do not redraw the library over the player after a successful PLAY.
      return;
    }
    drawLibrary();
    return;
  }
  if(state==VIDEO_PLAYBACK){if(a==NavAction::SELECT||a==NavAction::PLAY_PAUSE){paused=!paused; osdUntil=millis()+3000; drawPlaybackOsd();}else if(a==NavAction::LEFT||a==NavAction::PREVIOUS||a==NavAction::RIGHT||a==NavAction::NEXT){int count=libraryProgramCount();if(count){int delta=(a==NavAction::LEFT||a==NavAction::PREVIOUS)?count-1:1;int wanted=(librarySelection+delta)%count;stopProgram();librarySelection=wanted;if(!startProgram(libraryProgramAt(librarySelection)))setError(PTBR::VIDEO_CORROMPIDO);drawPlaybackOsd();}}else if(a==NavAction::BACK){stopProgram();drawLibrary();}return;}
  if(state==AIRCRAFT_RADAR){if(a==NavAction::BACK){state=HOME;drawHome();return;}if((a==NavAction::LEFT||a==NavAction::RIGHT)&&aircraftCount)radarSelection=radarSelection<0?0:(radarSelection+(a==NavAction::LEFT?aircraftCount-1:1))%aircraftCount;else if(a==NavAction::SELECT)radarDetails=!radarDetails;drawRadar();drawControllerLabels(PTBR::AERONAVE,PTBR::DETALHES,PTBR::AERONAVE);return;}
  if(state==SETTINGS){
    constexpr int SETTINGS_COUNT=12;
    if(a==NavAction::BACK){if(settingsEditing)settingsEditing=false;else state=HOME;if(state==HOME)drawHome();else drawSettings();return;}
    if(a==NavAction::SELECT){if(!settingsEditing&&settingsSelection==11){startSetupPortal();return;}settingsEditing=!settingsEditing;if(!settingsEditing)saveSettings();}
    else if(!settingsEditing&&(a==NavAction::LEFT||a==NavAction::RIGHT))settingsSelection=(settingsSelection+(a==NavAction::LEFT?SETTINGS_COUNT-1:1))%SETTINGS_COUNT;
    else if(settingsEditing&&(a==NavAction::LEFT||a==NavAction::RIGHT)){
      int d=a==NavAction::LEFT?-1:1;
      if(settingsSelection==1)settings.experimental320=!settings.experimental320;
      else if(settingsSelection==2)settings.volume=constrain(settings.volume+d*5,0,100);
      else if(settingsSelection==3){int v[]={50,100,250,500},i=0;while(i<3&&v[i]!=settings.rangeKm)i++;settings.rangeKm=v[(i+d+4)%4];}
      else if(settingsSelection==4){int v[]={5,10,30},i=0;while(i<2&&v[i]!=settings.refreshSeconds)i++;settings.refreshSeconds=v[(i+d+3)%3];}
      else if(settingsSelection==5){AudioOutput next=settings.audioOutput==AudioOutput::RCA?AudioOutput::INTERNAL:settings.audioOutput==AudioOutput::INTERNAL?AudioOutput::MUTED:AudioOutput::RCA; setAudioOutput(next);}
      else if(settingsSelection==6)settings.vhsOsd=!settings.vhsOsd;
    }drawSettings();return;
  }
  if(state==SYSTEM_INFO){if(a==NavAction::BACK){state=HOME;drawHome();}else if(a==NavAction::LEFT||a==NavAction::RIGHT||a==NavAction::SELECT){infoPage=1-infoPage;drawInfo();}return;}
}
void handleTouch(){
  static bool down=false; static int8_t button=-1; static uint32_t downAt=0;
  const uint32_t now=millis();
  if(!M5.Touch.getCount()){
    if(down){uint32_t held=now-downAt; NavAction action=(button==0?NavAction::LEFT:button==2?NavAction::RIGHT:held>=1500?NavAction::HOME:held>=700?NavAction::BACK:state==VIDEO_PLAYBACK?NavAction::PLAY_PAUSE:NavAction::SELECT); input.inject(action,InputSource::LCD_BUTTON);}
    down=false; button=-1; return;
  }
  const auto &p=M5.Touch.getDetail(); if(!p.isPressed())return;
  if(!down){
    down=true; downAt=now;
    if(p.y>=184) button=p.x<107?0:p.x<214?1:2;
    else { button=-1; if(state==HOME){homeSelection=constrain((int)((p.y-48)/32),0,3); input.inject(NavAction::SELECT,InputSource::LCD_BUTTON);} else if(state==VIDEO_PLAYBACK) input.inject(NavAction::PLAY_PAUSE,InputSource::LCD_BUTTON); }
  }
}

void setup(){
  Serial.begin(115200);
  auto cfg=M5.config();
  // Do not start the Core2 speaker at boot: RCA is the default and owns the
  // shared audio lines. The internal speaker starts only after the user selects it.
  cfg.internal_spk=false;
  cfg.external_spk=false;
  M5.begin(cfg);
  M5.Display.setRotation(1);
  input.begin();
  input.setAutoRepeat(true);
  jpegBuffer=(uint8_t *)ps_malloc(MAX_JPEG);
  rca.init();
  rca.setOutputBoost(true);
  dualText(PTBR::APP, PTBR::INICIANDO);
  if(!jpegBuffer){setError(PTBR::MEMORIA_INSUFICIENTE);return;}
  dualText(PTBR::APP, PTBR::VERIFICANDO_SD);

  // Core2 TF card wiring: SCK=18, MISO=38, MOSI=23, CS=4. GPIO19 is
  // deliberately not used by SPI: the stacked M125 RCA module owns it for PCM BCK.
  // Explicit setup avoids GPIO19, which is reserved for RCA audio.
  SPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
  // Try the normal 25 MHz rate first, then the conservative 4 MHz rate used by
  // marginal/older FAT32 cards before reporting a real card error.
  bool sdMounted = SD.begin(SD_CS, SPI, 25000000);
  if (!sdMounted) {
    SD.end();
    delay(30);
    sdMounted = SD.begin(SD_CS, SPI, 4000000);
  }
  if(!sdMounted){
    setError(PTBR::CARTAO_SD_NAO_ENCONTRADO);
    return;
  }
  Serial.println("[M5RETRO] SD mounted on Core2 TF bus (CS4 SCK18 MISO38 MOSI23)");
  sdMutex=xSemaphoreCreateMutex();
  if(!sdMutex){setError(PTBR::ERRO_SD);return;}
  if (!makeDirectories()) {
    setError(PTBR::ERRO_SD);
    return;
  }
  // Always inspect the card at boot. This keeps the video library usable when
  // Wi-Fi has not been configured and leaves serial evidence of the detected path.
  libraryProgramCount();
  dualText(PTBR::APP, PTBR::CARREGANDO_CONFIG);
  if (!loadConfiguration()) {
    apiStatus = PTBR::CONFIG_REDE_AUSENTE;
    dualText(PTBR::CONFIG_REDE_AUSENTE, PTBR::EDITE_SECRETS);
  } else {
    dualText(PTBR::APP, PTBR::CONECTANDO_WIFI);
    wifiRetryAt = millis(); // serviceWiFi starts the attempt without blocking the UI.
  }
  dualText(PTBR::APP, PTBR::INICIANDO_PALM);
  if(!initExternalAudio()){setError(PTBR::FALHA_AUDIO);return;}
  audioOutput=settings.audioOutput;
  xTaskCreatePinnedToCore(audioTask,"RCA_PCM",4096,nullptr,4,&audioTaskHandle,0);
  dualText(PTBR::APP, PTBR::SISTEMA_PRONTO);
  // Offline playback must not be hidden behind network setup. The portal stays
  // available from CONFIGURACOES when the owner wants to add Wi-Fi later.
  state=HOME;
  drawHome();
}
void loop(){
  M5.update();
  servicePowerButton();
  if (powerOffPending) return;
  input.update();
  if(state==SETUP_PORTAL){
    portal.update();
    handleTouch();
    NavAction portalAction=input.getAction();
    if(portalAction==NavAction::BACK || portalAction==NavAction::RIGHT || portalAction==NavAction::HOME){portal.stop(); state=HOME; drawHome();}
    else if(portalAction!=NavAction::NONE){drawSetupPortal();}
    if(portalSavePending){
      if(WiFi.status()==WL_CONNECTED){portalSavePending=false; portal.stop(); apiStatus="CONECTADA"; dualText(PTBR::WIFI_CONECTADO,WiFi.localIP().toString()); state=HOME; drawHome();}
      else if(millis()-portalSaveStarted>=10000){portalSavePending=false; apiStatus="SEM WI-FI"; drawSetupPortal();}
    }
  } else {
    serviceWiFi();
    handleTouch();
    handleNavigation(input.getAction());
  }
  if(state==VIDEO_PLAYBACK){
    videoTick();
    drawPlaybackOsd();
    if (playbackFinished) {
      playbackFinished=false;
      stopProgram();
      drawLibrary();
      Serial.println("[M5RETRO] PLAY encerrado no fim do programa");
    }
  }
  if(state==AIRCRAFT_RADAR){pollAircraft();if(millis()-lastRadarDraw>500){lastRadarDraw=millis();drawRadar();}}
  if(millis()-lastStats>=1000){lastStats=millis();uint32_t avg=decodedFrames?jpegDecodeTotalMs/decodedFrames:0;Serial.printf("[M5RETRO] FPS RCA:%lu FPS JPEG:%lu FPS LCD:%lu frames descartados:%lu JPEG medio:%lu JPEG maximo:%lu underruns audio:%lu heap livre:%u heap minimo:%u PSRAM livre:%u RSSI:%d API:%s\n",renderedFrames,decodedFrames,lcdFramesRendered,droppedFrames,avg,jpegDecodeMaxMs,audioUnderruns,ESP.getFreeHeap(),ESP.getMinFreeHeap(),ESP.getFreePsram(),WiFi.RSSI(),apiStatus.c_str());renderedFrames=decodedFrames=0;lcdFramesRendered=0;jpegDecodeTotalMs=jpegDecodeMaxMs=0;}
  delay(4);
}

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