/*
 * ATLAS LIBRARY - base station firmware
 * Print and Play Creative Manufacturing, Hamilton, Ontario
 *
 * VERSION: 0.1.0
 *
 * A self-powered box with an SD card and a speaker. Atlas units connect to it
 * over WiFi in Library mode and stream 1-bit film reels; the audio stays here,
 * because Atlas has no speaker and never will.
 *
 * HARDWARE  Waveshare ESP32-S3-Zero (ESP32-S3FH4R2, 4MB flash, 2MB QUAD PSRAM)
 *
 *   microSD breakout (SPI)        MAX98357A I2S amp
 *     VCC  -> 3V3                   VIN  -> 3V3
 *     GND  -> GND                   GND  -> GND
 *     SCK  -> GPIO12                BCLK -> GPIO4
 *     MISO -> GPIO13                LRC  -> GPIO5
 *     MOSI -> GPIO11                DIN  -> GPIO6
 *     CS   -> GPIO10                GAIN -> floating (9 dB)
 *                                   SD   -> floating (see note)
 *                                   +/-  -> speaker
 *
 *   SD uses SPI2/FSPI on its native IO_MUX pins, the only set that bypasses
 *   the GPIO matrix. NOTE: the core's waveshare_esp32_s3_zero variant file
 *   declares SS=34/MOSI=35/MISO=37/SCK=36, none of which are led out on this
 *   board - so SPI.begin() MUST be called with explicit pins. Never rely on
 *   the variant defaults here.
 *
 *   MAX98357A SD pin: left floating the breakout averages (L+R)/2. We output
 *   the same sample to both slots (SetOutputModeMono), so averaging is
 *   correct and full-volume. Do NOT tie SD to 3V3 unless you switch to
 *   left-slot-only output, or levels will be wrong.
 *
 * ARDUINO IDE
 *   Board            ESP32S3 Dev Module
 *   USB CDC On Boot  Enabled
 *   PSRAM            QSPI PSRAM      <- quad, not octal. The FH4R2 is R2.
 *   Flash Size       4MB (32Mb)
 *   Partition Scheme Huge APP (3MB No OTA / 1MB SPIFFS)
 *   USB Mode         Hardware CDC and JTAG   (see LIB_ENABLE_USB_MSC below)
 *
 * LIBRARIES
 *   ESP8266Audio  >= 2.4.2   <- version matters, see note at the include
 *
 * BRING-UP ORDER - do these in sequence, they isolate one fault at a time:
 *   1. Power up on USB, open Serial at 115200. You should get an SD report
 *      and a catalogue listing. If not, it is wiring or card format (FAT32).
 *   2. Type 'tone' at the serial prompt. A 440 Hz tone proves the amp and
 *      speaker before any file decoding is involved.
 *   3. Type 'play <name.mp3>'. That proves SD reads feeding the decoder.
 *   4. Join the WiFi network and open http://192.168.4.1 in a browser.
 *      Everything above is provable without touching Atlas firmware.
 */

#include <Arduino.h>
#include <SPI.h>
#include <SD.h>
#include <WiFi.h>
// A quarter second, not five. These guard how long handleClient() will
// BLOCK THE ENTIRE LOOP waiting on one client - and an abandoned client
// (Atlas scrubbed away, a poke gave up) waits the full budget. At the stock
// five seconds, a handful of corpses froze everything this box does -
// stream pumping, the control listener, the dead-man - for half a minute;
// measured three separate ways before the cause was cornered here. A real
// Atlas request completes in tens of milliseconds; anything slower than a
// quarter second is already dead.
#define HTTP_MAX_DATA_WAIT   250
#define HTTP_MAX_SEND_WAIT   600   // 250 aborted BROWSE replies mid-send
                                    // under load: empty shelves, and the
                                    // status reads the occupancy rule needs
#define HTTP_MAX_CLOSE_WAIT  100
#include <WebServer.h>
#include <ESP_I2S.h>
#include <lwip/sockets.h> // raw non-blocking send(); see streamPump()
#include <errno.h>
#include <esp_wifi.h>     // esp_wifi_set_inactive_time(); see setup()
#include <Preferences.h>  // the volume knob remembers; see volumeLoad()

// ESP8266Audio 2.4.2+ ONLY. Releases 2.3.0-2.4.1 fail to compile against
// core 3.3.x (AudioOutputPDM.cpp references i2s_pdm_tx_gpio_config_t::dout2,
// which no longer exists) - and that file builds even though we use plain
// I2S. Conversely anything before 2.3.0 wants the removed IDF4 I2S driver.
#include <AudioFileSourceSD.h>
#include <AudioFileSourceBuffer.h>
#include <AudioGeneratorMP3.h>
#include <AudioGeneratorWAV.h>
#include <AudioOutputI2S.h>

// ---------------------------------------------------------------- config --
#define LIB_VERSION       "1.10.0"

#define PIN_SD_SCK        12
#define PIN_SD_MISO       13
#define PIN_SD_MOSI       11
#define PIN_SD_CS         10
#define SD_SPI_HZ         20000000UL   // 4 MHz default would cap us at ~400 KB/s

#define PIN_I2S_BCLK      4
#define PIN_I2S_LRC       5
#define PIN_I2S_DOUT      6

// The network is CLOSED and HIDDEN. It exists for Atlas units and nothing else.
//
// It was an open, broadcast AP, which meant anyone in range could join and,
// with no authentication anywhere in this firmware, browse the whole card,
// download any file on it and drive the speaker. Hiding the SSID is not
// security on its own - a hidden network is still discoverable by anyone
// watching a client associate - so the passphrase is what actually protects
// it. Hiding it just keeps the box off every phone's network list in a room
// full of people, which is the behaviour we want from an appliance.
//
// AP_PASSWORD must match LIBNET_PASS in the Atlas firmware. Change one and you
// must change the other; there is no pairing step and no way to enter it by
// hand, which is the point - only a unit built from this source can join.
#define AP_SSID           "ATLAS-LIBRARY"
#define AP_PASSWORD       "odyssey-reel-1929"   // >= 8 chars, or softAP falls back to open
#define AP_HIDDEN         1            // 1 = do not broadcast the SSID
// Ten, which is the practical ceiling. The binding constraint is not the radio
// but CONFIG_LWIP_MAX_SOCKETS = 16 and CONFIG_LWIP_MAX_ACTIVE_TCP = 16 in the
// stock Arduino IDF build: every HTTP request is a TCP connection, and the
// listener holds one of those sixteen permanently. Twenty-four handhelds is
// not reachable without rebuilding the IDF with a larger socket pool.
// TEN. Measured, not assumed: requesting 15 and reading the config back showed
// the driver clamping to 10, which is the ceiling inside the closed-source
// WiFi library for this IDF. It is not reachable by configuration.
#define AP_MAX_CLIENTS    10

// How many file streams can be in flight at once. One Atlas needs one; the
// header and keyframe-index fetches that precede a stream are separate, very
// short-lived requests, so four covers two or three handhelds comfortably.
// Eight, not four. A slot is a File handle plus a 1460-byte buffer - about
// 1.5 KB - and Library has over 200 KB of internal RAM spare. Reading a
// document takes a slot too (a 1 KB chunk at a time), so four slots put a
// ceiling on READERS as well as viewers, which is the opposite of what this
// box is for.
#define LIB_STREAM_SLOTS   10         // one per classroom unit
#define LIB_STREAM_CHUNK   1460       // one TCP segment - larger just fragments
#define LIB_STREAM_BUDGET  6          // chunks per slot per loop pass
// How long a stream may make no progress before we give up on it. Deliberately
// generous: a PAUSED film is indistinguishable from a stalled one at the
// socket level - Atlas simply stops reading and the window closes - and a
// pause is a thing people do for minutes at a time. A peer that has actually
// gone is caught by connected(), and by the keepalive armed in handleFile();
// this timer only has to catch the rare case where neither fires.
#define LIB_STREAM_DEAD_MS 180000

// A client that has gone away must not leave the room playing a soundtrack
// nobody is listening to. Atlas polls /apos every 4 s while a reel runs, so
// thirty seconds of total silence means it is genuinely gone, not merely busy.
// Atlas polls every four seconds while anything is playing, so twenty seconds
// is five missed conversations - comfortably beyond a hiccup, and short enough
// that a room does not sit listening to a handheld that is no longer there.
#define LIB_IDLE_STOP_MS   20000

// USB Mass Storage: exposes the SD card as a drive when plugged into a
// computer. OFF by default because it needs Tools > USB Mode = "USB-OTG
// (TinyUSB)", which re-points Serial from HWCDCSerial to USBSerial and
// re-enumerates the COM port. Get everything else working first, then turn
// this on as a separate build.
#define LIB_ENABLE_USB_MSC 0
// WiFi guard kept as a switch: the thermal test proved the radio is the
// dominant heat source in this box, so being able to build without it is the
// only way to tell a radio-warm board from a genuine fault.
#define LIB_ENABLE_WIFI 1

// Transmit power. The default is +19.5 dBm, which is a power amplifier at full
// tilt continuously - the AP beacons whether or not anyone is listening, so
// this is idle heat, not load heat. Atlas sits in the same room, usually the
// same few metres, and 11 dBm is still roughly 30 m indoors. Dropping ~8.5 dB
// takes a large bite out of the PA current and therefore out of the hot spot
// next to the module. Raise it if range ever turns out to matter more.
// FULL POWER, and the earlier note claiming this was the heat source was
// wrong. The measured breakdown: an open beacon every ~102 ms is about 1.2%
// transmit duty, worth ~3 mA at 19.5 dBm and ~1.3 mA at 11 dBm. What is
// actually hot is the RECEIVER, which an access point can never turn off
// (~90-100 mA, three quarters of the idle budget) and the CPU at 160 MHz.
// Dropping to 11 dBm bought about 1.5% of the heat and cost roughly two and a
// half times the range - a bad trade for a box meant to serve a classroom.
//
// If a room ever needs less reach than more, this is the line to change.
#define AP_TX_POWER  WIFI_POWER_19_5dBm

// ------------------------------------------------------------- catalogue --
// Fixed arrays, no heap: this box runs for days at a time.
#define LIB_MAX_ITEMS     128     // per directory page, not per card
#define LIB_PATH_MAX      96
#define LIB_TITLE_MAX     64

// Folders on the card ARE the categories. That is self-describing, survives
// the card being edited on any computer, and needs no manifest to drift out
// of sync. Loose files dropped in the root are auto-sorted by extension, so
// someone who just dumps files still gets sensible behaviour.
//
//   /video/...            reels
//   /audio/music/...      music
//   /audio/books/...      audiobooks
//   /reader/books/...     ebooks
//   /reader/docs/...      documents
//   /gif/...              short loops (converted to .reel by the encoder)
//   /games/               reserved - see the games note in handleGames()
enum ItemKind : uint8_t {
  KIND_DIR = 0, KIND_REEL = 1, KIND_AUDIO = 2, KIND_TEXT = 3,
  KIND_GAME = 4, KIND_OTHER = 5
};
static const char *KIND_NAMES[] = { "dir", "reel", "audio", "text", "game", "other" };

struct CatItem {
  char     path[LIB_PATH_MAX];    // full path, e.g. "/video/odyssey.reel"
  char     title[LIB_TITLE_MAX];  // display name (reel header, else filename)
  uint32_t bytes;
  uint32_t secs;                  // runtime if known, else 0
  uint8_t  kind;
  uint8_t  fps;                   // reels only
};

static CatItem  cat[LIB_MAX_ITEMS];   // holds ONE directory listing at a time
static uint16_t catCount = 0;
static char     catPath[LIB_PATH_MAX] = "/";
static bool     sdReady  = false;
// Card geometry, sampled at mount. See the note where these are filled.
static uint32_t cardTotalMb = 0;
static uint32_t cardUsedMb  = 0;
static const char *cardKind = "?";
// Every file on the card, counted once at mount by cardCountWalk (defined
// down with the mount code - a function this early moves the sketch
// preprocessor's prototype hoist above the structs and breaks the build).
// The catalogue deliberately lists one directory at a time, so its count is
// "entries here" - which made /status report a 30 GB card as holding 4
// items, because the root holds four FOLDERS. This is the number a person
// means by "how much is on it".
static uint16_t cardFiles = 0;

// ----------------------------------------------------------------- audio --
// Runs in its own task. The decoder must be fed steadily; sharing a loop with
// HTTP handling produces audible dropouts the moment a client connects.
static AudioGeneratorMP3   *mp3   = nullptr;
static AudioGeneratorWAV   *wav   = nullptr;
static AudioFileSourceSD   *afile = nullptr;
static AudioFileSourceBuffer *abuf = nullptr;
static AudioOutputI2S      *aout  = nullptr;

static SemaphoreHandle_t audioMx  = nullptr;
static volatile bool     audioBusy = false;
// Playback loaded but held: the decoder keeps its state and the I2S channel is
// torn down, so a pause is silent and a resume is instant. See audioPause().
static volatile bool     audioHeld = false;
static char              nowPlaying[LIB_PATH_MAX] = {0};
// Track length in ms, estimated at play time from file size and bitrate.
// Needed to turn the decoder's BYTE position into a TIME position, which is
// the only thing a sync check can compare against.
static uint32_t          audioTotalMs = 0;
// Where the audio actually starts: byte length of the ID3v2 tag, if any.
// See the note at the open path - the tag holds cover art, and cover art
// counted as playing time.
static uint32_t          audioDataOff = 0;
static uint32_t audioDataEndTrim = 0;  // trailing tag bytes, not play time
static float             volume    = 0.6f;      // 0.0 .. 1.0
// The knob REMEMBERS (1.9.2). Every reboot used to reset volume to the
// compiled default: one evening it came back at a 5 percent bench clamp and
// read as "the speaker is broken"; another boot came back at full sail.
// Saved to NVS, debounced so a dial sweep costs one write, loaded in setup.
static bool     volSavePend  = false;
static uint32_t volSaveDueMs = 0;
// (the three volume functions live below the CtlSlot forward declaration -
// a function defined up here becomes the builder's prototype hoist point,
// above the very types those prototypes need)

// A raw I2S handle used only for the bring-up test tone, so a wiring fault
// can be diagnosed without involving the decoder or the card at all.
static I2SClass toneI2S;
static volatile bool toneRequested = false;

// Decoder working memory, reserved once in INTERNAL RAM.
//
// The installed IDF sets CONFIG_SPIRAM_USE_MALLOC=y with
// CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096, so every malloc larger than 4 KB
// goes to PSRAM first. AudioGeneratorMP3::begin() mallocs libmad's mad_frame
// (~20 KB: sbsample, overlap, xr_raw) and mad_synth (~8 KB polyphase
// filterbank) - the decoder's two hottest structures - which therefore landed
// in 80 MHz quad PSRAM, sharing SPI0 and the data cache with flash instruction
// fetch. That is the mechanism behind audio that is fine when idle and breaks
// up under WiFi load. preAllocSize() is constexpr and sums the exact aligned
// sizes for THIS fork of libmad, so it cannot drift out of step with the
// library the way a hardcoded number would.
static uint8_t mp3Space[AudioGeneratorMP3::preAllocSize()];
// The read-ahead buffer is a sequential memcpy target, so PSRAM suits it fine
// - and keeping it out of internal RAM leaves that for the decoder.
static uint8_t *abufSpace = nullptr;

static WebServer server(80);

// ------------------------------------------------------ detached streams --
// WebServer is single-threaded, and server.streamFile() writes the WHOLE file
// from inside the request handler. For a film that means loop() does not run
// again for minutes: no serial console, no status LED, and - fatally - no
// answer to /apos, which is the only thing A/V sync has to work with. If the
// peer dies mid-write it is worse still, because the write never finishes at
// all and the box is deaf until it is power-cycled. That is the whole of the
// "goes deaf and hot" fault.
//
// So /file now sends only the HEADERS inside the handler and hands the socket
// to a slot. loop() pumps every live slot a bounded number of bytes between
// calls to server.handleClient(), and never writes to a socket that is not
// ready to take bytes right now. Library therefore keeps answering everything
// else for the entire length of a stream.
//
// Detaching is safe, and that is checked against the installed core rather
// than assumed: WebServer::handleClient() releases its client with
// `_currentClient = NetworkClient()` - an ASSIGNMENT, not stop(). WiFiClient
// holds the descriptor in a shared_ptr whose destructor closes it, so while
// our copy lives the socket lives, and the server is free to accept the next
// request on the very next pass. (stop() WOULD close it out from under us, so
// nothing here may call stop() on the server's own client.)
// Each slot carries its own residue buffer. A partial write is normal - the
// peer's receive window closes constantly - so the leftover has to survive
// until the next pass, and sharing one buffer between slots would mean losing
// it the moment a second stream ran.
// IN-STREAM SEEK.
//
// Scrubbing used to mean closing the reel socket and opening a new one with a
// different ?off= - a TCP connect plus a fresh HTTP exchange through a server
// that is single-threaded and already busy pumping that same film. Per dial
// gesture. It was slow, it failed often, and every failure burned sockets
// until nothing could connect at all.
//
// So the client can now seek down the connection it already has. It writes one
// short line and Library repositions the file underneath the same stream.
// Nothing reconnects, so there is nothing to fail, exhaust or wait for.
//
//   client -> "S<byte offset>\n"
//   server -> LIB_SEEK_MARK, then the file from that offset
//
// The marker exists because bytes already in flight cannot be recalled: when
// the client asks to seek, some amount of the OLD position is still on the
// wire. It discards everything until it sees the marker, and knows that
// whatever follows is the new position. Eight bytes, chosen to be unlikely in
// packed 1-bit video.
static const uint8_t LIB_SEEK_MARK[8] = { 0xA5, 0x5A, 0xA5, 0x5A, 'S', 'E', 'E', 'K' };

struct StreamSlot {
  WiFiClient cli;
  File       f;
  uint32_t   left;                  // bytes of the file still to send
  uint32_t   lastMs;                // last time the peer accepted anything
  uint16_t   len;                   // bytes held in buf
  uint16_t   pos;                   // how many of them are already out
  bool       busy;
  bool       markerOnly;            // buf currently holds only seek markers
  char       cmd[24];               // partial inbound seek command
  uint8_t    cmdLen;
  char       id[10];                // announcing unit, for the ownership rule
  bool       admin;                 // and whether it claimed admin
  uint8_t    buf[LIB_STREAM_CHUNK];
};
static StreamSlot sslot[LIB_STREAM_SLOTS];

// Declared up HERE, hundreds of lines before it is defined, because the
// Arduino builder hoists a prototype for every function to just below this
// point - and ctlCommand() now takes a CtlSlot*, so the hoisted prototype
// would name a type the compiler has not met yet. A pointer to an incomplete
// type is all a declaration needs.
struct CtlSlot;

// The volume knob's memory - state lives with the volume declaration above;
// the bodies live here, BELOW the forward declarations, so the prototype
// hoist point stays after every type it needs.
static void volumeTouched() { volSavePend = true; volSaveDueMs = millis() + 3000; }
static void volumeSaveTick() {
  if (!volSavePend || (int32_t)(millis() - volSaveDueMs) < 0) return;
  volSavePend = false;
  Preferences p;
  p.begin("lib", false);
  p.putUChar("vol", (uint8_t)(volume * 100.0f + 0.5f));
  p.end();
  Serial.printf("[vol] saved %d\n", (int)(volume * 100.0f + 0.5f));
}
static void volumeLoad() {
  Preferences p;
  p.begin("lib", true);
  uint8_t v = p.getUChar("vol", 60);
  p.end();
  if (v > 100) v = 100;
  volume = v / 100.0f;
}

// Last moment ANY client asked for anything, including a byte accepted by a
// running stream. The audio idle watchdog reads this and nothing else.
static uint32_t   lastReqMs   = 0;
// True when the current playback was started over HTTP. Playback started from
// the serial console is deliberately exempt from the idle watchdog: there is
// no client to go away, and cutting it off after 30 s would break the bring-up
// sequence in the header comment.
static bool       httpStarted = false;

static inline void touchClient() { lastReqMs = millis(); }

// ------------------------------------------------------------ status LED --
// The S3-Zero carries one WS2812 on GPIO21. Library runs from the wall, so
// there is no reason to keep it dark - it is the only way to read the box at
// a glance with no screen attached.
//
//   red     no SD card
//   blue    up and idle
//   green   playing audio
//   white   a client is joined but nothing is moving
//   cyan    a file stream is actually in flight
#include <Adafruit_NeoPixel.h>
#define PIN_STATUS_LED  21
// NEO_RGB, not NEO_GRB. Declared as GRB first and the fault colour came out
// GREEN - which is the worst possible way to be wrong, because green reads as
// "all good" at a glance when it actually meant "no SD card". Verified by eye
// on the board rather than assumed from the part number.
static Adafruit_NeoPixel statusLed(1, PIN_STATUS_LED, NEO_RGB + NEO_KHZ800);

static void statusUpdate() {
  static uint32_t last = 0;
  static uint32_t shown = 0xFFFFFFFF;
  if (millis() - last < 250) return;
  last = millis();
  bool streaming = false;
  for (uint8_t i = 0; i < LIB_STREAM_SLOTS; i++) if (sslot[i].busy) { streaming = true; break; }
  uint32_t c;
  if      (!sdReady)                        c = statusLed.Color(40, 0, 0);   // red
  else if (streaming)                       c = statusLed.Color(0, 35, 35);  // cyan
  else if (audioBusy)                       c = statusLed.Color(0, 40, 0);   // green
  else if (WiFi.softAPgetStationNum() > 0)  c = statusLed.Color(30, 30, 30); // white
  else                                      c = statusLed.Color(0, 0, 40);   // blue
  if (c != shown) { statusLed.setPixelColor(0, c); statusLed.show(); shown = c; }
}

// ============================================================== event log ==
// The shop runs without a PC attached, so incidents used to be stories.
// This is a small NVS-backed ring of the last 48 events - plays, stops,
// holds, seizures, refusals, channel churn, decoder trouble - surviving
// power cycles. Dump with the console command `log`. Write-behind every
// few events plus on the moments that matter, so flash wear stays trivial.
#define ELOG_N    48
#define ELOG_W    40
static char    elogRing[ELOG_N][ELOG_W];
static uint8_t elogHead = 0, elogCnt = 0, elogDirty = 0;

static void elogSave() {
  Preferences p; p.begin("elog", false);
  p.putBytes("r", elogRing, sizeof(elogRing));
  p.putUChar("h", elogHead); p.putUChar("c", elogCnt);
  p.end();
  elogDirty = 0;
}
static void elogLoad() {
  Preferences p; p.begin("elog", true);
  if (p.getBytes("r", elogRing, sizeof(elogRing)) != sizeof(elogRing))
    memset(elogRing, 0, sizeof(elogRing));
  elogHead = p.getUChar("h", 0) % ELOG_N;
  elogCnt  = p.getUChar("c", 0); if (elogCnt > ELOG_N) elogCnt = ELOG_N;
  p.end();
}
static void elog(bool flushNow, const char *fmt, ...) {
  char *dst = elogRing[elogHead];
  int n = snprintf(dst, ELOG_W, "%lu ", (unsigned long)(millis() / 1000));
  va_list ap; va_start(ap, fmt);
  vsnprintf(dst + n, ELOG_W - n, fmt, ap);
  va_end(ap);
  elogHead = (uint8_t)((elogHead + 1) % ELOG_N);
  if (elogCnt < ELOG_N) elogCnt++;
  if (++elogDirty >= 6 || flushNow) elogSave();
}
static void elogDump() {
  Serial.printf("[elog] %u event(s), oldest first (seconds since that boot):\n", elogCnt);
  for (uint8_t i = 0; i < elogCnt; i++) {
    uint8_t idx = (uint8_t)((elogHead + ELOG_N - elogCnt + i) % ELOG_N);
    Serial.print("  "); Serial.println(elogRing[idx]);
  }
}

// ============================================================== utilities ==

static bool endsWithNoCase(const char *s, const char *suffix) {
  size_t ls = strlen(s), lx = strlen(suffix);
  if (lx > ls) return false;
  return strcasecmp(s + (ls - lx), suffix) == 0;
}

static uint32_t rd32(const uint8_t *p) {
  return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
         ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}

// Pull title / runtime / fps out of an ATLASRL4 header so the shelf can show
// real names instead of filenames. Layout is documented in
// ATLAS_READER_SPEC.md section 5.
static bool readReelHeader(const char *path, CatItem *it) {
  File f = SD.open(path, FILE_READ);
  if (!f) return false;
  uint8_t h[128];
  bool ok = (f.read(h, 128) == 128) && (memcmp(h, "ATLASRL4", 8) == 0) && (h[8] == 4);
  if (ok) {
    it->fps  = h[11];
    it->secs = rd32(h + 20);
    memcpy(it->title, h + 64, 63);
    it->title[63] = 0;
    // trim trailing padding
    for (int i = 62; i >= 0; i--) {
      if (it->title[i] == ' ' || it->title[i] == 0) it->title[i] = 0; else break;
    }
  }
  f.close();
  return ok;
}

// ============================================================== catalogue ==

static uint8_t kindFromName(const char *nm) {
  if (endsWithNoCase(nm, ".reel")) return KIND_REEL;
  if (endsWithNoCase(nm, ".mp3") || endsWithNoCase(nm, ".wav")) return KIND_AUDIO;
  if (endsWithNoCase(nm, ".txt") || endsWithNoCase(nm, ".md"))  return KIND_TEXT;
  return KIND_OTHER;
}

// Pretty names for the fixed top-level folders. Anything else shows as its
// own folder name, so users can invent their own sub-categories freely.
static const char *prettyDir(const char *name) {
  if (!strcasecmp(name, "video"))  return "Video";
  if (!strcasecmp(name, "audio"))  return "Audio";
  if (!strcasecmp(name, "reader")) return "Reader";
  if (!strcasecmp(name, "gif"))    return "GIF";
  if (!strcasecmp(name, "music"))  return "Music";
  if (!strcasecmp(name, "books"))  return "Books";
  if (!strcasecmp(name, "docs"))   return "Documents";
  return name;
}

// List ONE directory. Deliberately not a whole-card scan: a 32 GB card can
// hold thousands of files and Atlas has neither the RAM to hold that tree nor
// any reason to. Browsing is a page at a time, which also means adding files
// never makes the listing slower.
static uint16_t browseDir(const char *path) {
  catCount = 0;
  snprintf(catPath, sizeof(catPath), "%s", (path && *path) ? path : "/");
  if (!sdReady) return 0;

  File dir = SD.open(catPath);
  if (!dir || !dir.isDirectory()) {
    if (dir) dir.close();
    Serial.printf("[cat] not a directory: %s\n", catPath);
    return 0;
  }

  bool atRoot = (strcmp(catPath, "/") == 0);
  bool isBookDir = (strstr(catPath, "/audiobooks") != nullptr);

  for (File e = dir.openNextFile(); e && catCount < LIB_MAX_ITEMS; e = dir.openNextFile()) {
    // COPY the name out BEFORE the handle closes. e.name() points into the
    // handle's own buffer; using it after e.close() is a read of freed
    // memory that usually got away with it - until the heap churn of the
    // soundtrack-pairing SD.exists below started landing on top of it, and
    // music shelves grew empty titles and path-residue names ("ic/Water
    // Slide...") while video and reader listings stayed lucky.
    char nmBuf[80];
    {
      const char *full = e.name();                // core 3.x returns a full path
      const char *nm   = strrchr(full, '/');
      snprintf(nmBuf, sizeof(nmBuf), "%s", nm ? nm + 1 : full);
    }
    const char *nm = nmBuf;

    // Skip the junk every desktop OS sprinkles on a removable card.
    if (nm[0] == '.' || !strcasecmp(nm, "System Volume Information")) { e.close(); continue; }

    CatItem *it = &cat[catCount];
    memset(it, 0, sizeof(CatItem));
    snprintf(it->path, sizeof(it->path), "%s%s%s", atRoot ? "" : catPath, atRoot ? "/" : "/", nm);
    it->bytes = e.isDirectory() ? 0 : (uint32_t)e.size();
    it->kind  = e.isDirectory() ? KIND_DIR : kindFromName(nm);
    bool isDir = e.isDirectory();
    e.close();

    if (isDir) {
      snprintf(it->title, sizeof(it->title), "%s", prettyDir(nm));
      catCount++;
      continue;
    }

    // A soundtrack paired to a reel is not content in its own right. The
    // pairing is by name - foo.reel plays alongside foo.mp3 - so listing both
    // put "full_odyssey_a" on the shelf next to "Odyssey Trailer A", with no
    // runtime and nothing useful behind it: picking it played the score with
    // no picture. Eight of the twenty-three entries in /video were these.
    // Hidden only when the reel actually exists, so a stray mp3 still shows.
    if (it->kind == KIND_AUDIO) {
      char mate[LIB_PATH_MAX];
      snprintf(mate, sizeof(mate), "%s", it->path);
      char *dot = strrchr(mate, '.');
      if (dot && (size_t)(dot - mate) + 6 < sizeof(mate)) {
        strcpy(dot, ".reel");
        if (SD.exists(mate)) continue;          // its reel owns it
      }
    }

    // Title defaults to the bare filename; reels overwrite it from the header.
    snprintf(it->title, sizeof(it->title), "%s", nm);
    char *dot = strrchr(it->title, '.');
    if (dot) *dot = 0;

    if (it->kind == KIND_REEL && !readReelHeader(it->path, it)) {
      Serial.printf("[cat] %s is not a valid ATLASRL4 reel - skipped\n", it->path);
      continue;                                   // do not advance catCount
    }
    catCount++;
  }
  dir.close();

  // SORT. openNextFile() hands entries back in FAT directory order, which is
  // whatever order they happened to be written to the card - so a shelf built
  // from it is arbitrary, and creating the folders in a nice order does not
  // help. Ordering has to happen here.
  //
  //  * at the root, by category: reading, then listening, then watching, then
  //    playing. Most-used first, and games last because they are a promise
  //    rather than a feature yet.
  //  * anywhere else, folders before files and then by name - which for the
  //    audiobooks means chapter order, since every file is "NN Chapter ...".
  for (uint16_t i = 1; i < catCount; i++) {
    CatItem key = cat[i];
    int16_t j = (int16_t)i - 1;
    while (j >= 0 && catRankLess(key, cat[j], atRoot)) { cat[j + 1] = cat[j]; j--; }
    cat[j + 1] = key;
  }
  (void)isBookDir;
  return catCount;
}

// Root listing with nothing in it is the first-run case: rather than show an
// empty box, create the category folders so the card explains itself the
// moment it is plugged into a computer.
static void ensureCategoryFolders() {
  if (!sdReady) return;
  // "/gif" is gone: nothing on either board can decode a GIF, so the folder
  // was an empty promise. GIFs convert to reels on a computer - see
  // odyssey_reel/Make-Reel.ps1 - and live in /video with everything else.
  const char *dirs[] = { "/reader", "/reader/books", "/reader/docs",
                         "/audio", "/audio/music", "/audio/audiobooks",
                         "/video" };
  for (size_t i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
    if (!SD.exists(dirs[i])) {
      if (SD.mkdir(dirs[i])) Serial.printf("[cat] created %s\n", dirs[i]);
    }
  }
}

static void catalogueReport() {
  browseDir("/");
  Serial.printf("[cat] %s -> %u entr%s\n", catPath, catCount, catCount == 1 ? "y" : "ies");
  for (uint16_t i = 0; i < catCount; i++) {
    Serial.printf("  %-2u %-5s %-28s", i, KIND_NAMES[cat[i].kind], cat[i].title);
    if (cat[i].kind == KIND_DIR)   Serial.print(F("  <dir>"));
    else                           Serial.printf("  %8u B", cat[i].bytes);
    if (cat[i].secs) Serial.printf("  %u:%02u", cat[i].secs / 60, cat[i].secs % 60);
    Serial.println();
  }
}

// ================================================================== audio ==

static void audioStopLocked() {
  if (mp3) { if (mp3->isRunning()) mp3->stop(); delete mp3; mp3 = nullptr; }
  if (wav) { if (wav->isRunning()) wav->stop(); delete wav; wav = nullptr; }
  if (abuf)  { delete abuf;  abuf  = nullptr; }
  if (afile) { delete afile; afile = nullptr; }
  audioBusy = false;
  audioHeld = false;        // a stop is not a pause; never leave the hold armed
  httpStarted = false;
  nowPlaying[0] = 0;
}

// Forward-declared so audioStop() can disarm a pending resume, and so the
// audio task can publish its position; both live down with the HTTP handlers
// that consume them.
static void audioForgetPause();
static void audioPosPublishLocked();

static void audioStop() {
  xSemaphoreTake(audioMx, portMAX_DELAY);
  audioStopLocked();
  xSemaphoreGive(audioMx);
  // An explicit stop cancels any armed pause. Without this, /stop between a
  // pause and a resume left pausedPath set, so the next /apause?p=0 restarted
  // a track the user had deliberately killed - and /status went on reporting
  // it as paused forever.
  audioForgetPause();
}

// Play, optionally starting partway in. startMs = 0 is a normal play.
// MP3 frames are self-contained, so seeking by proportion of file size lands
// within a frame or two - close enough for picture sync, and far simpler than
// parsing a frame index for a format that has no reliable one.
static bool audioPlayFrom(const char *path, uint32_t startMs);
static bool audioPlay(const char *path) { return audioPlayFrom(path, 0); }

static bool audioPlayFrom(const char *path, uint32_t startMs) {
  xSemaphoreTake(audioMx, portMAX_DELAY);
  audioStopLocked();

  if (!SD.exists(path)) {
    Serial.printf("[audio] not found: %s\n", path);
    xSemaphoreGive(audioMx);
    return false;
  }

  afile = new AudioFileSourceSD(path);
  // A buffer in front of the card smooths over SPI latency spikes; without it
  // a slow sector read is directly audible.
  //
  // THE TAG IS NOT MUSIC. Every time value here - total, position, seek - is
  // derived from byte offsets at 96 kbps, and an ID3v2 tag sits in front of
  // the audio counting toward all of them. Archive rips carry embedded cover
  // art in that tag, hundreds of kilobytes of it, which at 12 kB per second
  // read as half a minute of phantom time: tracks "started at 0:40", seeking
  // to 0:00 played the real start while the bar snapped back to 0:40, and
  // the reported length ran long. Measure the tag once and keep every
  // calculation inside the audio bytes.
  audioDataOff = 0;
  {
    uint8_t h[10];
    if (afile->read(h, 10) == 10 && h[0]=='I' && h[1]=='D' && h[2]=='3') {
      // Syncsafe 28-bit size, exclusive of this 10-byte header.
      audioDataOff = 10 + (((uint32_t)(h[6] & 0x7F) << 21) |
                           ((uint32_t)(h[7] & 0x7F) << 14) |
                           ((uint32_t)(h[8] & 0x7F) <<  7) |
                            (uint32_t)(h[9] & 0x7F));
      if (h[5] & 0x10) audioDataOff += 10;         // rare footer flag
    }
    afile->seek(0, SEEK_SET);
    uint32_t sz = afile->getSize();
    if (audioDataOff >= sz) audioDataOff = 0;      // nonsense tag: ignore it
    // The TAIL gets the same treatment as the head: ID3v1 (128 bytes of
    // "TAG") and APE tags sit AFTER the audio and counted as play time, so
    // track ends carried a stub of non-music the position math walked into.
    audioDataEndTrim = 0;
    if (sz > audioDataOff + 128) {
      uint8_t tail[32];
      afile->seek((int32_t)(sz - 128), SEEK_SET);
      if (afile->read(tail, 3) == 3 &&
          tail[0]=='T' && tail[1]=='A' && tail[2]=='G') audioDataEndTrim += 128;
      if (sz > audioDataOff + audioDataEndTrim + 32) {
        afile->seek((int32_t)(sz - audioDataEndTrim - 32), SEEK_SET);
        if (afile->read(tail, 8) == 8 && !memcmp(tail, "APETAGEX", 8)) {
          // APE footer: 32-byte footer whose size field covers the items.
          afile->seek((int32_t)(sz - audioDataEndTrim - 20), SEEK_SET);
          uint8_t szb[4];
          if (afile->read(szb, 4) == 4) {
            uint32_t apeSz = (uint32_t)szb[0] | ((uint32_t)szb[1] << 8) |
                             ((uint32_t)szb[2] << 16) | ((uint32_t)szb[3] << 24);
            if (apeSz < sz / 2) audioDataEndTrim += apeSz + 32;
          }
        }
      }
      afile->seek(0, SEEK_SET);
    }
    uint32_t dataSz = sz - audioDataOff - audioDataEndTrim;
    audioTotalMs = dataSz ? (uint32_t)((uint64_t)dataSz * 8ULL / 96ULL) : 0;
    // The 96 kbps rule is the MP3 house format. A WAV states its own data
    // rate in the header - a 44.1 kHz stereo one runs ~1411 kbps, and the
    // house assumption read a 3-minute file as 44 minutes long, which skewed
    // the reported position ~15x fast and every proportional seek with it.
    if (endsWithNoCase(path, ".wav")) {
      uint8_t wh[36];
      if (afile->read(wh, 36) == 36 &&
          !memcmp(wh, "RIFF", 4) && !memcmp(wh + 8, "WAVE", 4)) {
        uint32_t br = (uint32_t)wh[28] | ((uint32_t)wh[29] << 8) |
                      ((uint32_t)wh[30] << 16) | ((uint32_t)wh[31] << 24);
        if (br > 1000) audioTotalMs = (uint32_t)((uint64_t)sz * 1000ULL / br);
      }
      afile->seek(0, SEEK_SET);
    }
    if (audioDataOff > 4096)
      Serial.printf("[audio] ID3 tag %lu B skipped (was %lu s of phantom time)\n",
                    (unsigned long)audioDataOff,
                    (unsigned long)(audioDataOff / 12000));
  }
  // Seek by proportion of the AUDIO bytes. MP3 frames are self-contained, so
  // this lands within a frame or two - close enough for picture sync, and far
  // simpler than parsing an index a format like this has no reliable version of.
  if (startMs && audioTotalMs) {
    uint32_t dataSz = afile->getSize() - audioDataOff;
    uint32_t off = audioDataOff +
                   (uint32_t)((uint64_t)dataSz * startMs / audioTotalMs);
    if (off < afile->getSize()) afile->seek(off, SEEK_SET);
  }
  // Preallocated forms of both: no 33 KB of malloc/free churn on every play,
  // seek and resume, and libmad's tables stay in internal RAM.
  abuf  = abufSpace ? new AudioFileSourceBuffer(afile, abufSpace, 24576)
                    : new AudioFileSourceBuffer(afile, 24576);
  aout->SetGain(volume);

  bool ok = false;
  if (endsWithNoCase(path, ".mp3")) {
    mp3 = new AudioGeneratorMP3(mp3Space, sizeof(mp3Space));
    ok  = mp3->begin(abuf, aout);
  } else if (endsWithNoCase(path, ".wav")) {
    wav = new AudioGeneratorWAV();
    ok  = wav->begin(abuf, aout);
  } else {
    Serial.printf("[audio] unsupported: %s\n", path);
  }

  if (ok) {
    snprintf(nowPlaying, sizeof(nowPlaying), "%s", path);
    audioBusy = true;
    Serial.printf("[audio] playing %s\n", path);
    { const char *b = strrchr(path, '/'); elog(true, "play %.28s", b ? b + 1 : path); }
  } else {
    audioStopLocked();
    Serial.printf("[audio] failed to start %s\n", path);
  }
  xSemaphoreGive(audioMx);
  return ok;
}

// 440 Hz for one second, straight out of I2S. This deliberately bypasses the
// SD card and the decoder so a silent speaker can be blamed on exactly one
// thing.
static void audioTestTone() {
  Serial.println(F("[tone] 440 Hz for 1 s"));
  xSemaphoreTake(audioMx, portMAX_DELAY);
  bool wasPlaying = audioBusy;
  audioStopLocked();

  const uint32_t rate = 22050;
  toneI2S.setPins(PIN_I2S_BCLK, PIN_I2S_LRC, PIN_I2S_DOUT, -1, -1);
  if (!toneI2S.begin(I2S_MODE_STD, rate, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO)) {
    Serial.printf("[tone] I2S begin failed, err=%d\n", toneI2S.lastError());
    xSemaphoreGive(audioMx);
    return;
  }
  static int16_t buf[512];
  float phase = 0.0f;
  const float step = 2.0f * PI * 440.0f / (float)rate;
  // Ramp in and out over ~5 ms. Jumping from silence to full amplitude in one
  // sample is a step discontinuity - it clicks, and the click sounds exactly
  // like a wiring fault, which is the last thing a diagnostic tone should do.
  const uint32_t ramp = rate / 200;
  for (uint32_t written = 0; written < rate; written += 256) {
    for (int i = 0; i < 256; i++) {
      uint32_t n = written + i;
      float env = 1.0f;
      if (n < ramp)            env = (float)n / (float)ramp;
      else if (n > rate - ramp) env = (float)(rate - n) / (float)ramp;
      if (env < 0.0f) env = 0.0f;
      // 26000 of a possible 32767 - about 79% of full scale, so "vol 95"
      // genuinely means near-maximum. The first version used 6000, which is
      // 18% of full scale, so a reported 95% was quiet enough to look like a
      // wiring fault. A reference tone has to be at a reference LEVEL, or the
      // number on screen means nothing.
      int16_t s = (int16_t)(sinf(phase) * 26000.0f * volume * env);
      buf[i * 2] = s; buf[i * 2 + 1] = s;      // same sample to both slots
      phase += step;
      if (phase >= 2.0f * PI) phase -= 2.0f * PI;
    }
    toneI2S.write((const uint8_t *)buf, 256 * 2 * sizeof(int16_t));
  }
  toneI2S.end();
  xSemaphoreGive(audioMx);
  Serial.println(F("[tone] done"));
  if (wasPlaying) Serial.println(F("[tone] (playback was stopped)"));
}

// Dedicated task. Decoding must not compete with HTTP handling for the loop.
// Seeks are TARGETS, not calls. The control channel delivers a scrub's
// seeks faster than the decoder can survive being yanked - back-to-back
// buffer seeks mid-frame were killing libmad, and the death-revive blips
// were the "glitch loop". Commands write the newest target here; the audio
// task applies it BETWEEN decode passes, in its own context, at most every
// 250 ms. A dial spin becomes one clean landing instead of a beating.
static volatile uint32_t audioSeekWantMs = 0xFFFFFFFF;
static volatile uint32_t audioPosPub = 0;
static uint32_t audioSeekAppliedMs = 0;
static uint8_t  audioHealTries = 0;

static void audioTask(void *) {
  for (;;) {
    if (toneRequested) { toneRequested = false; audioTestTone(); }

    xSemaphoreTake(audioMx, portMAX_DELAY);
    if (audioSeekWantMs != 0xFFFFFFFF && audioBusy && mp3 &&
        millis() - audioSeekAppliedMs > 250) {
      uint32_t tgt = audioSeekWantMs;
      audioSeekWantMs   = 0xFFFFFFFF;
      audioSeekAppliedMs = millis();
      uint32_t sz = afile ? afile->getSize() : 0;
      if (sz > audioDataOff && audioTotalMs) {
        if (tgt > audioTotalMs) tgt = audioTotalMs;
        uint32_t off = audioDataOff +
                       (uint32_t)((uint64_t)(sz - audioDataOff) * tgt / audioTotalMs);
        if (abuf && abuf->seek((int32_t)off, SEEK_SET)) mp3->desync();
      }
    }
    if (audioHeld) {
      // Held: do not pump the generator. The I2S channel is gone, so a write
      // would fail, and not advancing is what makes this a pause rather than
      // a mute.
    } else if (mp3 && mp3->isRunning()) {
      if (!mp3->loop()) {
        // Dead mid-track is a WOUND, not an ending - heal in place instead
        // of stopping and letting the handheld's reviver blip the intro.
        uint32_t posMs = audioPosPub;
        if (audioTotalMs && posMs + 3000 < audioTotalMs && audioHealTries < 3) {
          audioHealTries++;
          Serial.printf("[audio] decoder died at %lu ms - rebuilding there (%u)\n",
                        (unsigned long)posMs, (unsigned)audioHealTries);
          // Straight to the rebuild. Once loop() has answered false the
          // generator has already stopped internally - a desync alone left
          // a zombie: busy, "playing", pumping nothing, and the glitch
          // just sat there. The rebuild lands on the same position.
          {
            char path[LIB_PATH_MAX];
            snprintf(path, sizeof(path), "%s", nowPlaying);
            bool held = audioHeld;
            xSemaphoreGive(audioMx);
            audioPlayFrom(path, posMs);
            xSemaphoreTake(audioMx, portMAX_DELAY);
            if (held) audioHeld = true;
          }
        } else {
          Serial.println(F("[audio] end of file"));
          audioStopLocked();
        }
      } else if (audioHealTries && millis() - audioSeekAppliedMs > 4000) {
        audioHealTries = 0;                     // stable again; refill budget
      }
    } else if (wav && wav->isRunning()) {
      if (!wav->loop()) { Serial.println(F("[audio] end of file")); audioStopLocked(); }
    }
    audioPosPublishLocked();   // the only place afile is safe to dereference
    xSemaphoreGive(audioMx);

    // Yield briefly when idle; when playing, loop() returns quickly and this
    // still leaves the scheduler room for WiFi.
    vTaskDelay((audioBusy && !audioHeld) ? 1 : 20);
  }
}

// =================================================================== HTTP ==

static const char *kindName(uint8_t k) { return KIND_NAMES[k]; }

// Where a root category sits on the shelf. Anything unlisted sorts after the
// named ones, so a folder someone adds by hand still appears somewhere
// sensible instead of jumping to the top.
static uint8_t catRootRank(const char *path) {
  if (!strcmp(path, "/reader")) return 0;
  if (!strcmp(path, "/audio"))  return 1;
  if (!strcmp(path, "/video"))  return 2;
  return 5;
}

// True if a sorts before b.
static bool catRankLess(const CatItem &a, const CatItem &b, bool atRoot) {
  if (atRoot) {
    uint8_t ra = catRootRank(a.path), rb = catRootRank(b.path);
    if (ra != rb) return ra < rb;
  } else {
    // Folders first: a book's chapters live below its cover, not mixed in.
    bool da = (a.kind == KIND_DIR), db = (b.kind == KIND_DIR);
    if (da != db) return da;
  }
  return strcasecmp(a.title, b.title) < 0;
}

// One rule for every path that arrives over the network, applied everywhere.
//
// The old guard was `indexOf("..") >= 0`, which is wrong in both directions: it
// rejected honest names like "And Then... Nothing.mp3" while a request for
// "/audio/../../secret" still contains no bare ".." segment check and sailed
// through wherever the guard was simply absent - and /play had no guard at all,
// so it would happily open anything on the card.
//
// What actually matters is a ".." PATH SEGMENT, not the characters appearing
// somewhere in a filename.
static bool libPathOk(const String &p) {
  if (!p.length() || p[0] != '/') return false;
  if (p.length() >= LIB_PATH_MAX)  return false;
  int i = 0;
  while (i < (int)p.length()) {
    int slash = p.indexOf('/', i);
    int end   = (slash < 0) ? p.length() : slash;
    if (end - i == 2 && p[i] == '.' && p[i + 1] == '.') return false;
    if (slash < 0) break;
    i = slash + 1;
  }
  return true;
}

// Machine-readable directory listing for Atlas.
//
//   ATLASLIB <TAB> version <TAB> count <TAB> path
//   kind <TAB> path <TAB> bytes <TAB> secs <TAB> fps <TAB> title
//
// Deliberately not JSON: Atlas parses this with strtok into a fixed buffer,
// and a tab-separated line costs no parser and no allocation. One directory
// per request keeps the response bounded however large the card gets.
static void handleBrowse() {
  touchClient();
  String p = server.hasArg("path") ? server.arg("path") : "/";
  if (!p.startsWith("/")) p = "/" + p;
  if (!libPathOk(p)) { server.send(400, "text/plain", "bad path"); return; }
  browseDir(p.c_str());

  String out;
  out.reserve(catCount * 80 + 96);
  out += "ATLASLIB\t" LIB_VERSION "\t";
  out += catCount; out += '\t'; out += catPath; out += '\n';
  for (uint16_t i = 0; i < catCount; i++) {
    out += kindName(cat[i].kind); out += '\t';
    out += cat[i].path;           out += '\t';
    out += cat[i].bytes;          out += '\t';
    out += cat[i].secs;           out += '\t';
    out += cat[i].fps;            out += '\t';
    out += cat[i].title;          out += '\n';
  }
  server.send(200, "text/plain", out);
}

// Games are deliberately NOT a Library category. The handhelds already do
// same-room multiplayer over their own radio, peer to peer, no box needed -
// brokering it through Library would add a dependency to something that
// works better without one. (Decision: Jesse, Aug 2026.)

// ------------------------------------------------------------- control --
// A dedicated, always-open command socket per handheld. Speaker commands
// used to ride the DATA stream, and the bench tape caught the flaw: scrub
// churn evicts stream slots, the handheld's socket still looks alive, and
// its pause commands pour into a closed pipe while the film plays on. This
// channel carries commands and nothing else, so no amount of data-plane
// churn can touch it.
// A SOUNDLESS screening, registered by the owner's 'F' picture ticks. While
// fresh, /status reports it as playing so the room system can see it.
static char     soundlessPath[LIB_PATH_MAX] = {0};
static char     audioOwnerId[10] = {0};   // who started what the amp is playing
static uint32_t soundlessAtMs = 0;

// The slot carries the unit's IDENTITY, not just its socket. Pause and seek
// arrive here as raw lines with no query string to read an id out of, so
// without this the arbiter below has no idea who is asking - which is exactly
// how a locked-out unit's dial kept driving the room.
struct CtlSlot { WiFiClient cli; char cmd[96]; uint8_t cmdLen; bool busy; char id[10]; bool admin; };
#define LIB_CTL_SLOTS 12                   // ten units plus churn headroom
static CtlSlot ctlslot[LIB_CTL_SLOTS];

// The ownership rule itself lives with the rest of the lock, far below, but
// BOTH transports have to consult it and both are up here. One rule, one
// arbiter - a second copy of it is a second thing to forget to update.
static bool ctlAllowId(const char *id, bool isAdmin);

static void ctlCommand(const char *cmd, CtlSlot *s) {
  touchClient();

  // Who is asking. A slot that never announced is an older Atlas that never
  // could, so it gets the same anonymous "?" the HTTP path already uses and
  // is judged by exactly the same rule.
  const char *who   = (s && s->id[0]) ? s->id : "?";
  bool        whoAd = s ? s->admin : false;

  if (cmd[0] == 'I') {
    // Identity announce, and NOTHING else. This must never seize the room: a
    // unit announces on every attach and every reconnect, so an announce that
    // took control would hand the room to whoever rebooted last.
    if (s) {
      const char *tb = strchr(cmd + 1, '\t');
      size_t n = tb ? (size_t)(tb - (cmd + 1)) : strlen(cmd + 1);
      if (n >= sizeof(s->id)) n = sizeof(s->id) - 1;
      memcpy(s->id, cmd + 1, n);
      s->id[n] = 0;
      s->admin = (tb && tb[1] == '1');
      Serial.printf("[ctl] identified %s%s\n", s->id, s->admin ? " ADMIN" : "");
      elog(false, "unit %s%s attached", s->id, s->admin ? " ADM" : "");
    }
    return;
  }

  // THE GATE. ctlAllow() only ever guarded the five HTTP endpoints, and pause
  // and seek do not travel over HTTP - they land here - so the admin lock was
  // decorative: a locked-out unit's thumb still moved the room. 'F' stays out
  // of this on purpose; it is a picture clock, telemetry rather than control,
  // and gating it would silently desync a silent-film screening.
  if (cmd[0] == 'P' || cmd[0] == 'A' || cmd[0] == 'M') {
    if (!ctlAllowId(who, whoAd)) {
      Serial.printf("[ctl] refused %s from %s\n", cmd, who);
      // Tell it, rather than just dropping the command. A unit that saw its
      // dial do nothing would read the lock as a fault.
      if (s) { uint8_t line[2] = { (uint8_t)'L', (uint8_t)'\n' }; s->cli.write(line, 2); }
      return;
    }
  }

  if (cmd[0] == 'P') {
    if (cmd[1] == '1') audioPause(); else audioResume();
    Serial.printf("[ctl] %s\n", cmd[1] == '1' ? "hold" : "resume");
  } else if (cmd[0] == 'A') {
    uint32_t sec = (uint32_t)strtoul(cmd + 1, nullptr, 10);
    if (audioBusy && mp3) { audioSeekWantMs = sec * 1000UL; ctlBroadcastSeek(sec * 1000UL); }
  } else if (cmd[0] == 'M') {
    // Millisecond-exact seek - same target mechanism, no floor error.
    uint32_t ms = (uint32_t)strtoul(cmd + 1, nullptr, 10);
    if (audioBusy && mp3) { audioSeekWantMs = ms; ctlBroadcastSeek(ms); }
  } else if (cmd[0] == 'F') {
    // The owner's PICTURE clock, offered for relay. A room with no
    // soundtrack has no audio ticks and therefore no shared clock at all -
    // every screen free-runs and a silent-film screening drifts visibly
    // apart. When the speaker is idle, the owner's frame position is the
    // room's time: relayed as 'G' (never 'T' - a real track's clock and a
    // picture clock must not be confusable, or a music track playing under
    // a silent film would drive the film's sync).
    //
    // The line may carry the screening's identity after a tab. That is
    // what makes a SOUNDLESS screening visible to the room system at all:
    // /status reports it as playing, so lobbies can list it, joiners can
    // walk into it, and the occupancy rule protects it - none of which
    // worked when a screening only existed as an audio track.
    if (!audioBusy) {
      uint32_t ms = (uint32_t)strtoul(cmd + 1, nullptr, 10);
      const char* tb = strchr(cmd + 1, '\t');
      if (tb && tb[1]) {
        snprintf(soundlessPath, sizeof(soundlessPath), "%s", tb + 1);
        soundlessAtMs = millis();
      }
      char line[16];
      int n = snprintf(line, sizeof(line), "G%lu\n", (unsigned long)ms);
      for (uint8_t i = 0; i < LIB_CTL_SLOTS; i++) {
        if (!ctlslot[i].busy) continue;
        ctlslot[i].cli.write((const uint8_t*)line, n);
      }
    }
  }
}

static void ctlPump() {
  for (uint8_t i = 0; i < LIB_CTL_SLOTS; i++) {
    CtlSlot &c = ctlslot[i];
    if (!c.busy) continue;
    if (c.cli.fd() < 0 || !c.cli.connected()) {
      c.cli.stop(); c.busy = false;
      c.id[0] = 0; c.admin = false;   // a freed slot must not carry an admin bit
      Serial.println(F("[ctl] channel closed"));
      continue;
    }
    while (c.cli.available()) {
      int ch = c.cli.read();
      if (ch < 0) break;
      if (ch == '\n') {
        c.cmd[c.cmdLen] = 0;
        if (c.cmdLen) {
          ctlCommand(c.cmd, &c);
          // ACK every processed line. Delivery into a half-open socket
          // "succeeds" at the sender, so the sender counts these instead of
          // trusting send() - a command with no ack condemns the channel.
          c.cli.write((const uint8_t*)"#", 1);
        }
        c.cmdLen = 0;
      }
      else if (c.cmdLen < sizeof(c.cmd) - 1) c.cmd[c.cmdLen++] = (char)ch;
      else c.cmdLen = 0;                     // garbage; resync at next newline
    }
  }
}

// The control channel's own front door: a raw TCP listener that loop()
// accepts directly. Attaching via the web server meant queueing behind its
// jam of abandoned HTTP clients - live capture showed a reattach waiting 30
// seconds behind corpses while pause commands had nowhere to go.
static WiFiServer ctlServer(8081);

// The room hears about transport changes the moment they happen: one byte
// down every control channel - 'H' held, 'R' rolling. Viewers used to learn
// of a pause from their next status poll, up to four seconds late, so their
// pictures ran on past the owner's thumb. Now the speaker announces.
static void ctlBroadcast(char c) {
  for (uint8_t i = 0; i < LIB_CTL_SLOTS; i++) {
    if (!ctlslot[i].busy) continue;
    uint8_t line[2] = { (uint8_t)c, (uint8_t)'\n' };
    ctlslot[i].cli.write(line, 2);
  }
}

// The same, minus one unit. A seizing admin must not be told it is locked out
// of the room it has just taken - skipping by id rather than by slot also
// covers a unit attached twice, which every Atlas is: raw 8081 and /ctl.
static uint8_t ctlBroadcastExcept(char c, const char *skipId) {
  uint8_t line[2] = { (uint8_t)c, (uint8_t)'\n' };
  uint8_t sent = 0;
  for (uint8_t i = 0; i < LIB_CTL_SLOTS; i++) {
    if (!ctlslot[i].busy) continue;
    if (skipId && skipId[0] && ctlslot[i].id[0] && !strcmp(ctlslot[i].id, skipId)) continue;
    if (ctlslot[i].cli.write(line, 2) == 2) sent++;
  }
  return sent;
}

// Answer ONE unit down its command wire, wherever it happens to be attached.
// A refusal raised on a STREAM socket cannot reply on that socket - those
// bytes are picture, and a stray 'L' among them is a corrupt frame - so the
// answer goes out the channel that carries commands instead.
static void ctlPokeId(const char *id, char c) {
  if (!id || !id[0]) return;
  uint8_t line[2] = { (uint8_t)c, (uint8_t)'\n' };
  for (uint8_t i = 0; i < LIB_CTL_SLOTS; i++) {
    if (!ctlslot[i].busy) continue;
    if (strcmp(ctlslot[i].id, id)) continue;
    ctlslot[i].cli.write(line, 2);
  }
}

// A landed transport seek is a ROOM EVENT, exactly like hold and resume.
// Without this, only the unit that scrubbed knew the move was deliberate:
// the room OWNER's drift corrector saw the soundtrack leap away from its
// picture, called it gross drift, and yanked the sound straight back - so
// an admin's scrub always lost a tug-of-war with the owner and "bounced".
// Announcing the position makes the speaker the single primary: every unit
// follows the announcement with its own picture, and nobody corrects it.
static void ctlBroadcastSeek(uint32_t ms) {
  char line[16];
  int n = snprintf(line, sizeof(line), "K%lu\n", (unsigned long)ms);
  for (uint8_t i = 0; i < LIB_CTL_SLOTS; i++) {
    if (!ctlslot[i].busy) continue;
    ctlslot[i].cli.write((const uint8_t*)line, n);
  }
}

// THE ROOM TICK. Every two seconds while the speaker plays, one write puts
// the same clock value on every unit's wire at the same instant. Units used
// to ASK for the time over HTTP, and each request's own latency skewed its
// answer differently - that asymmetry was the last few milliseconds between
// screens. A broadcast has no request leg: everyone hears the same sample,
// disciplines against it together, and the room converges inside a frame.
static void ctlTick() {
  static uint32_t nextMs = 0;
  if (!audioBusy || audioHeld) return;
  if ((int32_t)(millis() - nextMs) < 0) return;
  nextMs = millis() + 2000;
  char line[16];
  int n = snprintf(line, sizeof(line), "T%lu\n", (unsigned long)audioPosPub);
  for (uint8_t i = 0; i < LIB_CTL_SLOTS; i++)
    if (ctlslot[i].busy) ctlslot[i].cli.write((const uint8_t*)line, n);
}

static void ctlAccept() {
  WiFiClient c = ctlServer.accept();
  if (!c) return;
  int8_t k = -1;
  for (uint8_t i = 0; i < LIB_CTL_SLOTS; i++) if (!ctlslot[i].busy) { k = (int8_t)i; break; }
  if (k < 0) { ctlslot[0].cli.stop(); ctlslot[0].busy = false; k = 0; }
  CtlSlot &s = ctlslot[k];
  s.cli = c;
  s.cmdLen = 0;
  // Blank the identity BEFORE the slot goes live. A recycled slot that kept
  // the previous unit's admin bit would hand the room to whoever landed in
  // it - a student inheriting a teacher's authority by pure socket churn.
  s.id[0] = 0;
  s.admin = false;
  s.busy = true;
  int fd = s.cli.fd();
  if (fd >= 0) {
    struct linger sl; sl.l_onoff = 1; sl.l_linger = 0;
    setsockopt(fd, SOL_SOCKET, SO_LINGER, &sl, sizeof(sl));
    int on = 1, idle = 20, intvl = 5, cnt = 3;
    setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));
  }
  Serial.println(F("[ctl] control channel attached (8081)"));
}

// The DEAD-MAN HOLD. Every failure this system has ever shown erred the
// same way: audio playing when it should not. So when the station falls
// silent - no polls, no commands, nothing for five seconds - the speaker
// holds ITSELF. A wrongly-held track heals on the next resume or poll; a
// wrongly-playing one ruins the room until someone walks to the shelf.
static void audioDeadMan() {
  if (!audioBusy || audioHeld || !httpStarted) return;
  // TWENTY seconds, and QUIETLY. The 8-second fuse tripped during ordinary
  // multi-unit life (one unit reading a document was enough), and because
  // it held through audioPause() it BROADCAST the hold - freezing every
  // viewer's picture for a transient. A failsafe holds the speaker without
  // announcing anything; when the station's polls return, the owner's
  // reconciler notices held-vs-playing and resumes on its own - on tape.
  if (millis() - lastReqMs > 20000) {
    xSemaphoreTake(audioMx, portMAX_DELAY);
    audioHeld = true;
    if (aout) aout->stop();
    xSemaphoreGive(audioMx);
    Serial.println(F("[audio] dead-man hold: station silent"));
  }
}

static void handleCtl() {
  touchClient();
  server.sendHeader("Cache-Control", "no-store");
  server.setContentLength(CONTENT_LENGTH_UNKNOWN);
  server.send(200, "application/octet-stream", "");
  int8_t k = -1;
  for (uint8_t i = 0; i < LIB_CTL_SLOTS; i++) if (!ctlslot[i].busy) { k = (int8_t)i; break; }
  if (k < 0) { ctlslot[0].cli.stop(); ctlslot[0].busy = false; k = 0; }  // newest wins
  CtlSlot &c = ctlslot[k];
  c.cli = server.client();
  c.cmdLen = 0;
  c.id[0] = 0;                     // see ctlAccept(): never inherit an identity
  c.admin = false;
  c.busy = true;
  int fd = c.cli.fd();
  if (fd >= 0) {
    struct linger sl; sl.l_onoff = 1; sl.l_linger = 0;
    setsockopt(fd, SOL_SOCKET, SO_LINGER, &sl, sizeof(sl));
    int on = 1, idle = 20, intvl = 5, cnt = 3;
    setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));
  }
  Serial.println(F("[ctl] control channel attached"));
}

static void streamSlotClose(StreamSlot &s, const char *why) {
  if (!s.busy) return;
  if (s.left) Serial.printf("[file] stream ended early (%s), %lu B unsent\n",
                            why, (unsigned long)s.left);
  s.f.close();
  s.cli.stop();
  s.busy = false;
  s.left = 0;
  s.len  = s.pos = 0;
  s.id[0] = 0;                      // see ctlAccept(): never inherit an identity
  s.admin = false;
}

// Deliberately NOT WiFiClient::write(). That function selects on the socket
// with a hardcoded one-second timeout and retries ten times, so a single call
// against a peer whose receive window has closed can sit here for the better
// part of ten seconds - which is precisely the stall being removed. A raw
// MSG_DONTWAIT send has no such loop: it moves what fits and returns.
//
// Returns bytes sent, 0 for "would block, come back later", -1 for a dead
// socket.
static int sockSendNow(WiFiClient &c, const uint8_t *buf, size_t len) {
  int fd = c.fd();
  if (fd < 0) return -1;
  int r = send(fd, buf, len, MSG_DONTWAIT);
  if (r >= 0) return r;
  if (errno == EAGAIN || errno == EWOULDBLOCK) return 0;
  return -1;
}

// Called from loop(). Bounded work per pass, per slot: no call in here can
// block, so the worst case is a few SD reads and a few sends.
//
// The audio carve-out is a GLOBAL POOL, not a per-slot ration. Per-slot,
// three handhelds each got half a budget - one and a half budgets of SD
// pressure in total, which starved the music harder than one unthrottled
// stream ever did: the third unit joining sent the room's soundtrack into
// a heal loop until units left. The pool makes N units share what ONE was
// allowed: pictures fill slower as the room grows (they hold minutes of
// margin), and the soundtrack notices nothing at all.
static uint8_t streamPool = 0;
static uint8_t streamRR   = 0;   // rotates who gets served first

static void streamPump() {
  // While the soundtrack runs, the WHOLE ROOM shares one unit's budget - the
  // note above this function is the law here, and the Aug-18 afternoon
  // change that scaled the pool with the room (cap x3) re-learned it the
  // measured way: a double join slipped the AUDIO 1.2 s, and every picture
  // in the room then had to chase it back down. Pictures hold minutes of
  // buffered margin; the sound holds none. The rotation below is what keeps
  // the single shared ration fair - that part of the afternoon work stays.
  // TWO budgets while the music plays - measured both brackets on the bench
  // (Aug 18): x3 slipped the soundtrack 1.2 s under a double join; x1 let
  // two simultaneous join-sprints starve the OWNER's own fetch into a
  // stall at 30 s. x2 is the midpoint under test; the soundtrack's
  // integrity is the tiebreaker if it ever wobbles again.
  streamPool = audioBusy ? (uint8_t)(2 * LIB_STREAM_BUDGET)
                         : (uint8_t)(LIB_STREAM_BUDGET * LIB_STREAM_SLOTS);
  const uint8_t share = LIB_STREAM_BUDGET;   // each slot keeps a whole ration
  // Rotate who is served first, or slot 0 is always fed and the last joiner
  // is always last in a pass that can run out.
  streamRR = (uint8_t)((streamRR + 1) % LIB_STREAM_SLOTS);
  for (uint8_t k = 0; k < LIB_STREAM_SLOTS; k++) {
    uint8_t i = (uint8_t)((streamRR + k) % LIB_STREAM_SLOTS);
    StreamSlot &s = sslot[i];
    if (!s.busy) continue;
    // no early break on an empty pool: reaping and the inbound command
    // reader below must run for EVERY live slot, not just the funded ones.

    // Peer-close detection, done here rather than trusting connected().
    // NetworkClient::connected() switches on errno after recv() returns 0, but
    // a clean FIN leaves errno untouched - so a peer that shut down politely
    // could still read as connected and hold its slot for the full dead timer.
    // recv()==0 IS the close; treat it as one.
    int fd = s.cli.fd();
    if (fd < 0) { streamSlotClose(s, "no socket"); continue; }
    uint8_t peek;
    int pr = recv(fd, &peek, 1, MSG_DONTWAIT | MSG_PEEK);
    if (pr == 0) { streamSlotClose(s, "peer closed"); continue; }
    if (pr < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
      streamSlotClose(s, "socket error"); continue;
    }
    if (!s.cli.connected()) { streamSlotClose(s, "peer gone"); continue; }

    // Inbound seek. One line, so a command split across two packets is
    // assembled rather than misread.
    while (s.cli.available()) {
      int ch = s.cli.read();
      if (ch < 0) break;
      if (ch == '\n') {
        s.cmd[s.cmdLen] = 0;
        if (s.cmd[0] == 'S') {
          uint32_t sz  = s.f.size();
          uint32_t off = (uint32_t)strtoul(s.cmd + 1, nullptr, 10);
          if (off > sz) off = sz;
          s.f.seek(off);
          s.left = sz - off;
          // EVERY seek answers with its own marker - the client counts them.
          // Two commands arriving in one pass used to be coalesced: the
          // second overwrote the buffer while the first's marker was still
          // (partly) unsent, so the client waited for a marker that no
          // longer existed and gave up into a two-second freeze and a full
          // reconnect. Keep whatever UNSENT marker bytes remain and append;
          // unsent file bytes are pre-seek data the client would discard
          // anyway, so those do get dropped.
          uint16_t keep = 0;
          if (s.markerOnly && s.pos < s.len) {
            keep = (uint16_t)(s.len - s.pos);
            memmove(s.buf, s.buf + s.pos, keep);
          }
          memcpy(s.buf + keep, LIB_SEEK_MARK, sizeof(LIB_SEEK_MARK));
          s.len  = (uint16_t)(keep + sizeof(LIB_SEEK_MARK));
          s.pos  = 0;
          s.markerOnly = true;         // buffer holds nothing but markers now
          s.lastMs = millis();
          Serial.printf("[file] in-stream seek to %lu\n", (unsigned long)off);
        }
        // Identity announce, the same line the command channel takes. This
        // path carries the very commands the lock exists to arbitrate, so
        // without an identity here the whole rule is one socket away from
        // being walked around. It never seizes control - see ctlCommand().
        else if (s.cmd[0] == 'I') {
          const char *tb = strchr(s.cmd + 1, '\t');
          size_t n = tb ? (size_t)(tb - (s.cmd + 1)) : strlen(s.cmd + 1);
          if (n >= sizeof(s.id)) n = sizeof(s.id) - 1;
          memcpy(s.id, s.cmd + 1, n);
          s.id[n] = 0;
          s.admin = (tb && tb[1] == '1');
        }
        // Speaker control down the same wire. The HTTP pokes these replace
        // rode a 250 ms fire-and-forget connect against a server busy
        // pumping this very stream - measured on the bench, roughly half of
        // them never landed, which is why pause "took a moment". This
        // socket is already connected; delivery is TCP's problem now.
        //
        // Gated by the same arbiter as every other transport: this copy of
        // the dispatch is the one that made the admin lock decorative.
        else if (s.cmd[0] == 'P' || s.cmd[0] == 'A' || s.cmd[0] == 'M') {
          touchClient();
          const char *who = s.id[0] ? s.id : "?";
          if (!ctlAllowId(who, s.admin)) {
            // The refusal cannot answer HERE - these bytes are picture.
            Serial.printf("[stream] refused %s from %s\n", s.cmd, who);
            ctlPokeId(who, 'L');
          }
          else if (s.cmd[0] == 'P') {
            if (s.cmd[1] == '1') audioPause(); else audioResume();
            Serial.printf("[stream] in-stream %s\n", s.cmd[1]=='1' ? "hold" : "resume");
          }
          else if (s.cmd[0] == 'A') {
            uint32_t sec = (uint32_t)strtoul(s.cmd + 1, nullptr, 10);
            // Same rules as /aseek: MP3 only, in place, never a teardown.
            if (audioBusy && mp3) { audioSeekWantMs = sec * 1000UL; ctlBroadcastSeek(sec * 1000UL); }
          }
          else {
            // Millisecond-exact seek, same rules as 'A'.
            uint32_t ms = (uint32_t)strtoul(s.cmd + 1, nullptr, 10);
            if (audioBusy && mp3) { audioSeekWantMs = ms; ctlBroadcastSeek(ms); }
          }
        }
        s.cmdLen = 0;
      } else if (s.cmdLen < sizeof(s.cmd) - 1) {
        s.cmd[s.cmdLen++] = (char)ch;
      }
    }

    // NOTE: this deliberately does NOT count "the slot is open" as client
    // activity. It did for one revision, to stop the audio watchdog silencing
    // a paused film - and that defeated both failsafes at once. When an Atlas
    // reboots mid-stream its socket is left half-open: no RST arrives, so
    // connected() stays true and the slot stays busy, refreshing lastReqMs on
    // every pass forever. Meanwhile the AP keeps the station entry for a peer
    // that never sent a deauth, so the client count never reaches zero either.
    // The room kept playing a film nobody was watching, which is the exact
    // fault this was all supposed to fix.
    //
    // Liveness is the CLIENT's job to prove. Atlas polls /apos while a reel
    // plays and while it is paused, so genuine silence means genuinely gone.

    // Draw from the shared pool - see the note at streamPool. The music wins.
    for (uint8_t b = 0; b < share && streamPool; b++, streamPool--) {
      if (s.pos >= s.len) {                       // buffer drained, refill it
        if (!s.left) { streamSlotClose(s, "complete"); break; }
        uint32_t want = s.left < LIB_STREAM_CHUNK ? s.left : LIB_STREAM_CHUNK;
        int r = s.f.read(s.buf, want);
        if (r <= 0) { streamSlotClose(s, "read error"); break; }
        s.len  = (uint16_t)r;
        s.pos  = 0;
        s.left -= (uint32_t)r;
        s.markerOnly = false;         // file bytes now; see the seek handler
      }
      int w = sockSendNow(s.cli, s.buf + s.pos, s.len - s.pos);
      if (w < 0)  { streamSlotClose(s, "write error"); break; }
      if (w == 0) break;                          // window shut; try next pass
      s.pos   += (uint16_t)w;
      s.lastMs = millis();
      touchClient();          // bytes ACCEPTED by the peer: that is a live client
    }

    // A peer that has stopped acknowledging but has not closed the socket
    // looks exactly like a slow one for a while, then like this.
    if (s.busy && millis() - s.lastMs > LIB_STREAM_DEAD_MS)
      streamSlotClose(s, "stalled");
  }
}

// Serve a file, optionally from a byte offset. Headers here, body from
// streamPump() - see the note at StreamSlot for why.
//
// This uses ?off= rather than an HTTP Range header on purpose: the built-in
// WebServer hardcodes "Accept-Ranges: none" and has no 206 support, and our
// only real client is Atlas, which does not need Range semantics - it needs
// "start sending from byte N", which is exactly what a keyframe offset is.
// Browsers downloading the whole file are unaffected.
static void handleFile() {
  touchClient();
  if (!server.hasArg("n")) { server.send(400, "text/plain", "missing n"); return; }
  String n = server.arg("n");
  if (!n.startsWith("/")) n = "/" + n;
  if (!libPathOk(n)) { server.send(400, "text/plain", "bad path"); return; }

  File f = SD.open(n.c_str(), FILE_READ);
  if (!f) { server.send(404, "text/plain", "not found"); return; }

  uint32_t sz  = f.size();
  uint32_t off = server.hasArg("off") ? (uint32_t)strtoul(server.arg("off").c_str(), nullptr, 10) : 0;
  // Optional length. Without it a 128-byte header fetch advertises the WHOLE
  // reel, so the slot sits there trying to push megabytes at a client that
  // read its 128 bytes and hung up - three slots burned per reel open, out of
  // a pool of four, and the next reel gets 503 "no stream slots" which Atlas
  // reports as "no header".
  uint32_t len = server.hasArg("len") ? (uint32_t)strtoul(server.arg("len").c_str(), nullptr, 10) : 0;
  // An offset at or past the end is an empty body, NOT the whole file from the
  // start. The old code silently did the latter, which would answer a scrub
  // past the last keyframe by replaying the reel from frame zero.
  if (off > sz) off = sz;
  if (off) f.seek(off);

  uint32_t body = sz - off;
  if (len && len < body) body = len;

  int8_t slot = -1;
  for (uint8_t i = 0; i < LIB_STREAM_SLOTS; i++) if (!sslot[i].busy) { slot = (int8_t)i; break; }
  if (slot < 0) {
    // Recycle the stalest slot rather than refusing. A slot that has moved no
    // bytes the longest is the best guess at an abandoned one, and answering
    // 503 here is indistinguishable from a broken reel at the other end.
    uint32_t oldest = 0xFFFFFFFF;
    for (uint8_t i = 0; i < LIB_STREAM_SLOTS; i++)
      if (sslot[i].lastMs < oldest) { oldest = sslot[i].lastMs; slot = (int8_t)i; }
    Serial.printf("[file] pool full, recycling slot %d\n", (int)slot);
    streamSlotClose(sslot[slot], "evicted");
  }

  server.sendHeader("Cache-Control", "no-store");
  server.setContentLength(body);
  server.send(200, "application/octet-stream", "");   // headers only

  StreamSlot &s = sslot[slot];
  s.cli    = server.client();      // refcounted; outlives the server's own handle
  s.f      = f;
  s.left   = body;
  s.lastMs = millis();
  s.len    = s.pos = 0;
  s.cmdLen = 0;
  s.markerOnly = false;
  s.id[0]  = 0;                    // see ctlAccept(): never inherit an identity
  s.admin  = false;
  s.busy   = true;

  // Arm TCP keepalive. A paused film and a peer whose battery just died look
  // identical from here - both simply stop reading - and since the pause case
  // has to be allowed to last for minutes, the dead case needs its own
  // detector rather than a shorter timer. Keepalive probes an idle connection
  // and tears it down when nobody answers, which is exactly that detector.
  int fd = s.cli.fd(), on = 1, idle = 20, intvl = 5, cnt = 3;
  if (fd >= 0) {
    // Release the socket outright on close instead of leaving it in TIME_WAIT.
    // Atlas reconnects constantly - a command, a poll, a stream re-open - and
    // lwIP's pool of protocol control blocks is small and fixed. Sockets that
    // are finished but not yet forgotten fill it, and then nothing can connect
    // at all, which is how one failed scrub became a permanent "reconnecting".
    struct linger sl; sl.l_onoff = 1; sl.l_linger = 0;
    setsockopt(fd, SOL_SOCKET, SO_LINGER, &sl, sizeof(sl));
    setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on,    sizeof(on));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE,  &idle,  sizeof(idle));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT,   &cnt,   sizeof(cnt));
  }
}

// =========================================================== control lock ==
// Who is allowed to drive the room.
//
// This HAS to live on Library. Two Atlases cannot arbitrate between
// themselves - neither can see the other - and Library is the only thing every
// unit talks to. It is also the thing holding the speaker, which is the
// resource actually being fought over.
//
// The rule, in one sentence: an ADMIN request always wins and takes exclusive
// control; after that, only that admin may drive until it lets go.
//
// That deliberately locks out the unit that STARTED playback, which is the
// whole point - a teacher taking a room back from a student whose reel is
// already running.
static char     ctlOwner[20]  = {0};    // stable per-unit id (MAC tail)
static bool     ctlAdmin      = false;  // is the current owner an admin
static uint32_t ctlLastMs     = 0;

// Without a release rule a room stays locked forever the moment a teacher
// walks out of range. 90 s of silence from the owner hands control back.
#define CTL_IDLE_MS 90000UL

static void ctlRelease(const char *why) {
  if (!ctlOwner[0]) return;
  Serial.printf("[ctl] released (%s), was %s%s\n", why, ctlOwner, ctlAdmin ? " ADMIN" : "");
  elog(false, "lock released (%s)", why);
  ctlOwner[0] = 0;
  ctlAdmin = false;
  // Tell the room it has its dials back. Waiting for each unit to discover
  // this by trying a command means the first press after a release is still
  // refused, which reads as the lock being stuck.
  ctlBroadcast('U');
}

// THE arbiter. Every transport funnels through this one function: HTTP via
// ctlAllow() below, and the raw command lines via ctlCommand()/streamPump(),
// which had no authorization at all and so made the whole lock decorative.
// Returns false if this caller may not drive; the caller answers in whatever
// dialect its transport speaks (403 over HTTP, an 'L' byte on a channel).
static bool ctlAllowId(const char *id, bool isAdmin) {
  if (!id || !id[0]) id = "?";

  if (ctlOwner[0] && (millis() - ctlLastMs > CTL_IDLE_MS)) ctlRelease("idle");

  bool mine = ctlOwner[0] && !strcmp(id, ctlOwner);
  // Note this BEFORE the owner is overwritten: a seizure is an admin arriving
  // over somebody else's ownership, and afterwards there is no way to tell.
  bool seized = isAdmin && !mine && ctlOwner[0];

  // An admin seizing control from someone else is the interesting case, and
  // the one worth logging - it is what a teacher pressing pause looks like.
  if (isAdmin && !mine) {
    if (ctlOwner[0]) Serial.printf("[ctl] ADMIN %s takes control from %s\n", id, ctlOwner);
    else             Serial.printf("[ctl] ADMIN %s takes control\n", id);
    elog(true, "ADMIN %s seizes", id);
  }

  if (ctlOwner[0] && !mine && ctlAdmin && !isAdmin) return false;   // locked out

  snprintf(ctlOwner, sizeof(ctlOwner), "%s", id);
  ctlAdmin  = isAdmin;
  ctlLastMs = millis();
  // The room learns it is locked the INSTANT the admin takes it, not on its
  // next refused press. A unit that only finds out by being told "no" has
  // already let its viewer turn the dial and watch nothing happen. The count
  // is on the record because the first bench tape lost this exact broadcast.
  if (seized) Serial.printf("[ctl] lock told %u unit(s)\n",
                            ctlBroadcastExcept('L', ctlOwner));
  return true;
}

// Call at the top of every control endpoint. Returns false if this caller is
// not allowed to drive, having already sent the 403.
static bool ctlAllow() {
  String id = server.hasArg("id") ? server.arg("id") : String("?");
  bool isAdmin = server.hasArg("a") && server.arg("a") == "1";
  if (!ctlAllowId(id.c_str(), isAdmin)) {
    // Locked out by an admin. Say so explicitly - a unit that simply ignored
    // the dial would read as a fault rather than a decision.
    server.send(403, "text/plain", "locked");
    return false;
  }
  return true;
}

// Hand control back when the room empties. Without this a teacher who walks
// out with the only admin unit leaves every other Atlas locked out until the
// idle timer expires - or forever, if they never touch it again.
static void ctlOwnerTick() {
  if (!ctlOwner[0]) return;
  if (WiFi.softAPgetStationNum() == 0)         { ctlRelease("room empty"); return; }
  else if (millis() - ctlLastMs > CTL_IDLE_MS) { ctlRelease("idle");       return; }
  // The lock is a STATE, so announce it like one. The seizure's one-shot 'L'
  // proved losable on the bench, and a notice that only arrives on refusal
  // means the first thing a student learns from is a dead dial. While the
  // admin's hold lasts (track torn down, pausedPath remembering it), every
  // other unit hears 'L' each 2 s: repeats are a refresh on the far side, and
  // the popup dies ~5 s after the lock does, everywhere, by construction.
  // Only while HELD - during admin-driven playback the picture matters more
  // than the badge, and refusal pokes still answer any actual gesture.
  static uint32_t lockNextMs = 0;
  if (ctlAdmin && audioIsHeld() && (int32_t)(millis() - lockNextMs) >= 0) {
    lockNextMs = millis() + 2000;
    Serial.printf("[ctl] lock renewed -> %u\n", ctlBroadcastExcept('L', ctlOwner));
  }
}

static void handleWho() {
  char b[64];
  snprintf(b, sizeof(b), "%s\t%d", ctlOwner[0] ? ctlOwner : "-", ctlAdmin ? 1 : 0);
  server.send(200, "text/plain", b);
}

static void handlePlay() {
  if (!ctlAllow()) return;
  touchClient();
  if (!server.hasArg("n")) { server.send(400, "text/plain", "missing n"); return; }
  // THE SPEAKER OCCUPANCY RULE, enforced at the SOURCE. The units ask
  // politely, but a shop always grows a stray old unit eventually, and the
  // old ghost heuristic ("busy with one member is abandoned") counted a
  // solo live listener as their own ghost - anyone could walk over them,
  // and a roomful of units doing it to each other was the dueling-restart
  // audio chop. While ANY control channel is alive, a busy speaker belongs
  // to the id that started it; others are refused unless admin. Zero live
  // channels is the true dead-session ghost, free to claim.
  {
    String pid = server.hasArg("id") ? server.arg("id") : String("?");
    bool adm = server.hasArg("a") && server.arg("a") == "1";
    uint8_t live = 0;
    for (uint8_t i = 0; i < LIB_CTL_SLOTS; i++) if (ctlslot[i].busy) live++;
    if (audioBusy && live >= 1 && !adm && strcmp(pid.c_str(), audioOwnerId) != 0) {
      elog(true, "play refused %s", pid.c_str());
      Serial.printf("[play] refused %s - speaker owned by %s\n", pid.c_str(), audioOwnerId);
      server.send(200, "text/plain", "busy");
      return;
    }
    snprintf(audioOwnerId, sizeof(audioOwnerId), "%s", pid.c_str());
  }
  String n = server.arg("n");
  if (!n.startsWith("/")) n = "/" + n;
  bool ok = audioPlay(n.c_str());
  httpStarted = ok;               // arms the idle watchdog for this playback
  // Starting anything cancels an armed pause. Otherwise pausing track A, then
  // starting track B, then resuming would drag track A back at A's offset.
  if (ok) audioForgetPause();
  // A new screening DEPOSES every other player in the room - WHETHER OR NOT
  // its soundtrack exists. Deposition used to ride audio success, so a
  // silent reel's open deposed nobody: the old room's viewers stayed seated
  // in a dead screening and followed the new reel's seek broadcasts into
  // nonsense. The screening is the event; the audio is just its sound.
  // RETRIES of the same screening are not new screenings: the sound-probe
  // pokes a missing mp3 several times, and each N was deposing the very
  // viewers who had just joined - a join/depose churn loop on silent reels.
  {
    static char     lastPlayPath[LIB_PATH_MAX] = {0};
    static uint32_t lastPlayNMs = 0;
    bool samePath = !strcmp(n.c_str(), lastPlayPath);
    if (!samePath || millis() - lastPlayNMs > 30000) {
      ctlBroadcast('N');
      lastPlayNMs = millis();
      snprintf(lastPlayPath, sizeof(lastPlayPath), "%s", n.c_str());
    }
  }
  // A new screening also replaces any registered soundless one.
  soundlessPath[0] = 0;
  server.send(ok ? 200 : 404, "text/plain", ok ? "playing" : "cannot play");
}

static void handleStop()   {
  if (!ctlAllow()) return;
  touchClient();
  audioStop();
  soundlessPath[0] = 0;     // a struck set takes its silent screening too
  // An EXPLICIT stop is the owner striking the set: tell the room the
  // screening is over. (A film reaching its own end broadcasts nothing -
  // every unit's picture ends by itself and this would only kick them
  // during the credits.)
  ctlBroadcast('S');
  server.send(200, "text/plain", "stopped");
}

// Where the soundtrack is, in milliseconds. This is the ONLY thing that can
// answer "are the picture and the sound still together" - Atlas has no idea
// how far the audio has actually got, because the audio never reaches it.
//
// Derived from the decoder's own byte position rather than millis(), so it
// reflects what has really been decoded and not merely how long ago playback
// was asked for. A stalled decoder therefore reports a stalled position,
// which is exactly what a sync check needs to see.
// Published by the audio task under the mutex, read by anyone.
//
// This used to dereference afile/abuf directly from the HTTP handlers. Those
// run on the loop task; the audio task deletes both inside audioStopLocked()
// at end of file, under audioMx, and it is priority 2 on the same core. So the
// old code could pass its null check, be preempted, and come back to a freed
// pointer - a LoadProhibited panic on the base station, triggered by nothing
// more exotic than a track ending while a remote asked where it was.
// (definition moved above audioTask, which reads it for the heal path)

static uint32_t audioPosMs() { return audioBusy ? audioPosPub : 0; }

// Bytes that have been READ but not yet HEARD. afile->getPos() is where the
// card read head has got to, which is a long way ahead of the speaker:
//
//   AudioFileSourceBuffer fill   up to 8192 B  =  683 ms at 96 kbps mono
//   libmad's own frame buffer    ~768 B avg    =   64 ms
//   I2S DMA, 5 x 576 frames                    =   65 ms
//                                                 -------
//                                                 ~810 ms
//
// Reporting the read position made /apos permanently ~0.8 s optimistic - and
// permanently in ONE direction. Atlas's deadband is 120 ms, so the measured
// drift could never land inside it: libSyncCheck nudged the frame clock by
// -4 ms on every single four-second check, forever, and called that locked.
// The A/V sync loop was chasing a constant offset rather than real drift.
// It also meant pause/resume skipped ~0.8 s of audio on every cycle.
#define AUDIO_MAD_INFLIGHT 768   // libmad buffLen is 0x600; about half resident
#define AUDIO_DMA_MS        65   // 5 DMA buffers x 576 frames at 44.1 kHz

// Only ever called with audioMx held.
static void audioPosPublishLocked() {
  if (!audioBusy || !afile) { audioPosPub = 0; return; }
  uint32_t sz = afile->getSize();
  if (!sz || !audioTotalMs || sz <= audioDataOff) { audioPosPub = 0; return; }
  uint32_t dataSz = sz - audioDataOff - audioDataEndTrim;   // tags are not time
  // getFillLevel() returns the buffer's exact byte count, so the largest term
  // here is measured rather than assumed.
  uint32_t behind = (abuf ? abuf->getFillLevel() : 0) + AUDIO_MAD_INFLIGHT;
  uint32_t po = afile->getPos();
  po = (po > behind) ? po - behind : 0;
  po = (po > audioDataOff) ? po - audioDataOff : 0;
  uint32_t ms = (uint32_t)((uint64_t)po * audioTotalMs / dataSz);
  audioPosPub = (ms > AUDIO_DMA_MS) ? ms - AUDIO_DMA_MS : 0;
}

// Pause and resume the soundtrack.
//
// Implemented as stop-and-remember, NOT by holding the decoder. If the
// generator simply stops being pumped, the I2S peripheral carries on clocking
// and replays whatever is still in its DMA buffer - that is a buzz, not
// silence, and it would be the loudest thing in the room during a pause.
// Stopping properly is genuinely silent, and coming back uses the same
// proportional seek /aseek already relies on.
static char     pausedPath[LIB_PATH_MAX] = {0};
static uint32_t pausedMs = 0;
static uint32_t pausedTotalMs = 0;

static void audioForgetPause() { pausedPath[0] = 0; pausedMs = 0; pausedTotalMs = 0; }

// For callers ABOVE the audio globals (the builder hoists function prototypes
// to the top of the sketch; it does no such favor for variables). HELD means
// the modern hold - decoder alive, output stopped - NOT pausedPath, which is
// the legacy teardown bookmark and stays empty on this path; testing it left
// the periodic lock notice silently unsent on the first 7.8.1 bench run.
static bool audioIsHeld() { return audioBusy && audioHeld; }

// Pause by silencing the OUTPUT, not by demolishing the decoder.
//
// The first version stopped playback outright and remembered a millisecond
// mark, then resumed with a proportional re-seek. That worked but it was slow
// and lossy: resuming meant re-opening the file, refilling 8 KB, re-syncing
// libmad to a frame and re-initialising I2S - a very audible delay before the
// sound came back - and the proportional seek landed near, not on, the old
// spot, so every pause/resume cycle nudged the track out of place.
//
// AudioOutputI2S::stop() disables and deletes the I2S channel and sets
// i2sOn = false; begin() rebuilds it from the stored pins and rate and
// preloads zeros. Neither touches the generator, the file source or the
// read-ahead buffer. So holding the output leaves libmad exactly where it was:
// resume is immediate, silent, and lands on the same sample it left.
static bool audioPause() {
  if (!audioBusy || audioHeld) return false;
  xSemaphoreTake(audioMx, portMAX_DELAY);
  audioHeld = true;                 // audioTask stops pumping before we cut I2S
  if (aout) aout->stop();           // channel deleted: real silence, no underrun buzz
  xSemaphoreGive(audioMx);
  ctlBroadcast('H');                  // the room freezes with the speaker
  Serial.printf("[audio] held at %lu ms\n", (unsigned long)audioPosPub);
  elog(false, "hold @%lus", (unsigned long)(audioPosPub / 1000));
  return true;
}

static bool audioResume() {
  if (!audioBusy || !audioHeld) return false;
  xSemaphoreTake(audioMx, portMAX_DELAY);
  // begin() can REFUSE - DMA scraps, a channel wedged half-installed - and
  // ignoring its answer resumed into silence: hold cleared, "resumed"
  // printed, /apos reporting playing, and no sound anywhere. If the first
  // try fails, tear the channel down hard and try once more; if it still
  // refuses, STAY HELD and say so - the handheld's reconciler keeps asking,
  // and every ask gets another honest attempt instead of a green lie.
  bool up = aout && aout->begin();
  if (!up && aout) {
    aout->stop();
    up = aout->begin();
  }
  if (up) {
    aout->SetGain(volume);
    audioHeld = false;
  }
  xSemaphoreGive(audioMx);
  if (up) ctlBroadcast('R');          // the room rolls with the speaker
  Serial.println(up ? F("[audio] resumed")
                    : F("[audio] RESUME FAILED - output would not restart"));
  elog(false, up ? "resume" : "RESUME FAILED");
  return up;
}

static void handleApause() {
  touchClient();
  if (!ctlAllow()) return;   // pause is a control action; an admin owns it too
  bool want = server.hasArg("p") ? (server.arg("p").toInt() != 0) : true;
  bool ok;
  if (want) { ok = audioPause(); }
  else      {
    ok = audioResume();
    // ARM on success, never DISARM on redundancy. Atlas re-sends the resume
    // until /apos confirms it, so the second copy always arrives with the
    // track already running - audioResume() answers false - and writing that
    // false into httpStarted switched the idle watchdog off for the rest of
    // the playback. A film that had been paused once would then play to an
    // empty room after the handheld died, which is the exact fault the
    // watchdog exists to prevent.
    if (ok) httpStarted = true;
  }
  server.send(200, "text/plain", ok ? (want ? "paused" : "playing") : "nothing to do");
}

// posMs <TAB> totalMs <TAB> state     state: 0 stopped, 1 playing, 2 paused
//
// A paused track has to keep reporting where it is. The decoder is gone - that
// is what makes the pause silent - so the position comes from the mark we will
// resume at, and a remote drawing a progress bar does not see the track jump
// to zero the moment it is held.
// Which file the answer is ABOUT. Without this, any playing audio confirmed
// anyone's soundtrack: a leftover track from the previous screening answered
// a fresh film's "did my /play land?" with yes, the film inherited that
// track's length, and when it ended the film concluded its OWN sound had
// "ended naturally" and stayed silent forever - with the whole room's clock
// gone. Identity makes every answer verifiable.
static uint32_t audioPathHash() {
  uint32_t h = 5381;
  for (const char* s = nowPlaying; *s; s++) h = h * 33u + (uint8_t)*s;
  return h;
}

static void handleApos() {
  touchClient();
  // Held playback is still LOADED playback: the decoder, the file and the
  // position are all exactly where they were, so this reports the real spot
  // rather than a remembered one.
  uint8_t  state = !audioBusy ? 0 : (audioHeld ? 2 : 1);
  uint32_t pos   = state ? audioPosMs()  : 0;
  uint32_t tot   = state ? audioTotalMs  : 0;
  char b[64];
  snprintf(b, sizeof(b), "%lu\t%lu\t%u\t%08lx",
           (unsigned long)pos, (unsigned long)tot, (unsigned)state,
           state ? (unsigned long)audioPathHash() : 0UL);
  server.send(200, "text/plain", b);
}

// Jump the soundtrack to a given second. Restarting the decoder at a byte
// offset is the honest way to do this for MP3: frames are self-contained, so
// seeking by proportion lands close enough for a film and costs nothing.
// Move a RUNNING decoder without rebuilding it.
//
// The old path called audioPlayFrom(), which tears the whole chain down and
// stands it back up: close the file, delete the generator, re-open five
// megabytes over SPI, refill 8 KB, re-initialise I2S and re-sync libmad to a
// frame. Measured elsewhere in this system at around three seconds. Doing that
// per scrub click is why the duration bar felt unusable, and it is also what
// made the film's gross-drift re-seek so expensive.
//
// The library already has everything needed to do this properly:
//   AudioFileSourceBuffer::seek()  invalidates its window and forwards down
//   AudioFileSourceSD::seek()      is a plain File::seek
//   AudioGeneratorMP3::desync()    clears libmad's frame pointers so it
//                                  re-syncs at the new offset
// Nothing is freed, nothing is re-opened, I2S never stops. Milliseconds.
//
// MP3 only: WAV has no desync and its generator caches header state, so that
// still takes the rebuild path.
static bool audioSeekFast(uint32_t ms) {
  if (!audioBusy || !mp3 || !abuf || !afile || !audioTotalMs) return false;
  uint32_t sz = afile->getSize();
  if (!sz || sz <= audioDataOff) return false;
  if (ms > audioTotalMs) ms = audioTotalMs;
  // Inside the audio bytes, past the tag - seeking to 0:00 means the first
  // audio frame, not the first byte of the cover art.
  uint32_t off = audioDataOff +
                 (uint32_t)((uint64_t)(sz - audioDataOff) * ms / audioTotalMs);

  xSemaphoreTake(audioMx, portMAX_DELAY);
  bool ok = abuf->seek((int32_t)off, SEEK_SET);
  if (ok) mp3->desync();          // frames are self-contained; libmad re-finds one
  audioPosPublishLocked();
  xSemaphoreGive(audioMx);
  return ok;
}

static void handleAseek() {
  if (!ctlAllow()) return;
  touchClient();
  // ms is the native unit end to end now - "s" survives for older callers,
  // but whole seconds cost up to 999 ms of floor error per seek, and the
  // Atlas nudge then spent half a minute walking out an error the protocol
  // itself had injected.
  uint32_t target;
  if (server.hasArg("ms")) {
    target = (uint32_t)strtoul(server.arg("ms").c_str(), nullptr, 10);
  } else if (server.hasArg("s")) {
    target = (uint32_t)(strtoul(server.arg("s").c_str(), nullptr, 10)) * 1000UL;
  } else { server.send(400, "text/plain", "missing s"); return; }

  // Cheap path first: a loaded decoder just moves. This also covers a HELD
  // track, so scrubbing while paused costs nothing and stays silent.
  if (audioBusy && mp3) {
    audioSeekWantMs = target;          // applied between decode passes
    ctlBroadcastSeek(target);
    server.send(200, "text/plain", "ok");
    return;
  }

  // Scrubbing a PAUSED film is the normal case, not an odd one - holding still
  // is exactly when you hunt for a shot. There is no decoder to seek, so move
  // the mark we will resume from instead. Rejecting this as "not playing" left
  // the sound where the picture no longer was.
  if (!audioBusy && pausedPath[0]) {
    pausedMs = target;
    server.send(200, "text/plain", "ok");
    return;
  }

  if (!audioBusy || !nowPlaying[0]) { server.send(400, "text/plain", "not playing"); return; }
  // WAV cannot be seeked: the fast path is MP3-only (desync), and the rebuild
  // below hands AudioGeneratorWAV a file whose read position is mid-sample -
  // begin() fails and the TRACK DIES. A scrub gesture that silently kills
  // playback is far worse than one that does nothing, and it also un-held a
  // paused track as a side effect. Decline politely instead.
  if (!endsWithNoCase(nowPlaying, ".mp3")) { server.send(200, "text/plain", "ok"); return; }
  char path[LIB_PATH_MAX];
  snprintf(path, sizeof(path), "%s", nowPlaying);
  bool ok = audioPlayFrom(path, target);
  httpStarted = ok;               // audioStopLocked cleared it; re-arm
  server.send(ok ? 200 : 404, "text/plain", ok ? "ok" : "seek failed");
}

static void handleVolume() {
  if (!ctlAllow()) return;
  touchClient();
  if (server.hasArg("v")) {
    int v = server.arg("v").toInt();
    volume = constrain(v, 0, 100) / 100.0f;
    volumeTouched();
    if (aout) aout->SetGain(volume);
  }
  server.send(200, "text/plain", String((int)(volume * 100)));
}

static void handleStatus() {
  touchClient();
  String s = "version\t" LIB_VERSION "\n";
  s += "sd\t";       s += sdReady ? "ok" : "fail";          s += "\n";
  s += "items\t";    s += catCount;                          s += "\n";
  // A soundless screening counts as playing while its owner's picture
  // ticks stay fresh - lobbies, joins and the occupancy rule all read this.
  bool soundless = !audioBusy && soundlessPath[0] &&
                   (millis() - soundlessAtMs) < 8000;
  s += "playing\t";
  s += (audioBusy ? nowPlaying : (soundless ? soundlessPath : "-"));
  s += "\n";
  // Held is a live decoder with the output gated - audioHeld is the truth.
  // pausedPath is a vestige of the old stop-and-remember pause and is never
  // written, so this line read "-" through every real pause.
  s += "paused\t";   s += ((audioBusy && audioHeld) ? nowPlaying : "-"); s += "\n";
  s += "volume\t";   s += (int)(volume * 100);               s += "\n";
  s += "clients\t";  s += WiFi.softAPgetStationNum();        s += "\n";
  uint8_t live = 0;
  for (uint8_t i = 0; i < LIB_STREAM_SLOTS; i++) if (sslot[i].busy) live++;
  s += "streams\t";  s += live;                              s += "\n";
  s += "heap\t";     s += ESP.getFreeHeap();                 s += "\n";
  s += "psram\t";    s += ESP.getFreePsram();                s += "\n";
  s += "card\t";     s += cardKind;     s += "\t";
                     s += cardTotalMb;  s += "\t";
                     s += cardUsedMb;                        s += "\n";
  s += "files\t";    s += cardFiles;                         s += "\n";
  // Live control channels - the room's actual population. The occupancy
  // rule reads this: a "busy" speaker with nobody attached is a GHOST left
  // by a dead session, and a ghost's screening is anyone's to take.
  { uint8_t live = 0;
    for (uint8_t i = 0; i < LIB_CTL_SLOTS; i++) if (ctlslot[i].busy) live++;
    s += "ctl\t"; s += live; s += "\n"; }
  s += "uptime\t";   s += (uint32_t)(millis() / 1000);       s += "\n";
  server.send(200, "text/plain", s);
}

static void handleRescan() { touchClient(); catalogueReport(); server.send(200, "text/plain", String(catCount)); }

// A plain browsable page, so the whole box can be validated from a laptop
// before any Atlas firmware exists.
static void handleRoot() {
  touchClient();
  String h = F("<!doctype html><meta name=viewport content='width=device-width,initial-scale=1'>"
               "<style>body{font:15px system-ui;margin:1.2rem;max-width:44rem}"
               "td{padding:.25rem .6rem;border-bottom:1px solid #ddd}"
               "a{color:#06c;text-decoration:none}h1{font-size:1.2rem}"
               ".k{color:#888;font-size:.85em}</style><h1>Atlas Library ");
  h += LIB_VERSION;
  h += F("</h1>");
  if (!sdReady) h += F("<p style='color:#c00'><b>No SD card.</b> Check wiring and that the card is FAT32.</p>");
  h += F("<p><a href='/status'>status</a> &middot; <a href='/rescan'>rescan card</a> &middot; "
         "<a href='/stop'>stop audio</a></p><table>");
  for (uint16_t i = 0; i < catCount; i++) {
    h += F("<tr><td class=k>"); h += kindName(cat[i].kind);
    h += F("</td><td>");        h += cat[i].title;
    h += F("</td><td class=k>");
    if (cat[i].secs) { h += cat[i].secs / 60; h += ':'; if (cat[i].secs % 60 < 10) h += '0'; h += cat[i].secs % 60; }
    else             { h += cat[i].bytes / 1024; h += F(" KB"); }
    h += F("</td><td>");
    if (cat[i].kind == KIND_DIR) { h += F("<a href='/?path="); h += cat[i].path; h += F("'>open</a>"); }
    else {
      if (cat[i].kind == KIND_AUDIO) { h += F("<a href='/play?n="); h += cat[i].path; h += F("'>play</a> "); }
      h += F("<a href='/file?n="); h += cat[i].path; h += F("'>download</a>");
    }
    h += F("</td></tr>");
  }
  h += F("</table>");

  // No upload form. Files go on the card with a card reader, which is both
  // simpler and the only route that does not put a write endpoint on a network
  // this firmware does not authenticate. See the note at handleFile.
  h += F("<p class=k>To add files, put the microSD in a computer. "
         "Library decodes .mp3 and .wav; a reel must be ATLASRL4 (v4) from "
         "ReelEncRL4, and its soundtrack is the same name with .mp3.</p>");
  server.send(200, "text/html", h);
}

// ================================================================== setup ==

static void catalogueScan();   // defined below; the probe rescans on success

// See the cardFiles note up with the globals.
static void cardCountWalk(File dir, uint8_t depth) {
  if (depth > 4) return;                 // no card we make is deeper
  for (File e = dir.openNextFile(); e; e = dir.openNextFile()) {
    const char *nm = strrchr(e.name(), '/');
    nm = nm ? nm + 1 : e.name();
    if (nm[0] == '.' || !strcasecmp(nm, "System Volume Information")) {
      e.close();
      continue;
    }
    if (e.isDirectory()) cardCountWalk(e, depth + 1);
    else if (cardFiles < 0xFFFF) cardFiles++;
    e.close();
  }
}

// Raw SPI probe. SD.begin() only ever says "failed", which cannot tell a
// dead bus from a bad filesystem. This talks to the card in its native SPI
// protocol and reports what actually comes back:
//
//   CMD0 -> 0x01   card is alive and idle. Bus and power are FINE, so the
//                  problem is the filesystem or the SD library, not wiring.
//   CMD0 -> 0xFF   nothing on MISO at all. No power, wrong pin, MISO not
//                  connected, or the card is not seated.
//   CMD0 -> 0x00   responding but not entering idle - usually a marginal
//                  supply, which is what a 3V3 feed to a 5V module looks like.
static void sdProbe() {
  Serial.println(F("[probe] raw SPI test"));
  SPI.end();
  pinMode(PIN_SD_CS, OUTPUT);
  digitalWrite(PIN_SD_CS, HIGH);
  SPI.begin(PIN_SD_SCK, PIN_SD_MISO, PIN_SD_MOSI, PIN_SD_CS);
  SPI.beginTransaction(SPISettings(400000, MSBFIRST, SPI_MODE0));

  // 80 idle clocks with CS high: the card needs these to wake into SPI mode.
  for (uint8_t i = 0; i < 10; i++) SPI.transfer(0xFF);

  digitalWrite(PIN_SD_CS, LOW);
  const uint8_t cmd0[] = { 0x40, 0, 0, 0, 0, 0x95 };   // GO_IDLE_STATE + valid CRC
  for (uint8_t i = 0; i < 6; i++) SPI.transfer(cmd0[i]);

  uint8_t r = 0xFF;
  for (uint8_t i = 0; i < 16; i++) { r = SPI.transfer(0xFF); if (r != 0xFF) break; }
  digitalWrite(PIN_SD_CS, HIGH);
  SPI.transfer(0xFF);
  SPI.endTransaction();

  Serial.print(F("[probe] CMD0 response 0x")); Serial.println(r, HEX);
  if      (r == 0x01) Serial.println(F("[probe] CARD IS ALIVE - bus and power are good, fault is above SPI"));
  else if (r == 0xFF) Serial.println(F("[probe] NO RESPONSE - check MISO, CS, power, and that the card is seated"));
  else                Serial.println(F("[probe] odd reply - usually a marginal supply"));

  // Leave the bus as the SD library expects to find it.
  sdReady = SD.begin(PIN_SD_CS, SPI, 4000000UL);
  Serial.print(F("[probe] SD.begin at 4 MHz: ")); Serial.println(sdReady ? "OK" : "failed");
  if (sdReady) { Serial.print(F("[probe] card size MB: ")); Serial.println((uint32_t)(SD.cardSize() / (1024ULL * 1024ULL))); catalogueScan(); }
}

static void serialHelp() {
  Serial.println(F("commands: list | ls <dir> | rescan | tone | play <file> | pause | resume"));
  Serial.println(F("          stop | vol <0-100> | status | rm <file>"));
}

void setup() {
  Serial.begin(115200);
  delay(300);
  Serial.println();
  Serial.println(F("=================================="));
  Serial.println(F("Atlas Library " LIB_VERSION));
  elogLoad();
  elog(true, "boot v" LIB_VERSION);
  volumeLoad();               // the knob remembers where it was left
  Serial.println(F("=================================="));
  Serial.printf("PSRAM: %u bytes free\n", ESP.getFreePsram());

  setCpuFrequencyMhz(160);   // 240 is more than this box ever needs
  statusLed.begin();
  statusLed.setBrightness(255);   // mains powered - no reason to dim it
  statusLed.setPixelColor(0, statusLed.Color(40, 20, 0));   // amber while booting
  statusLed.show();

  audioMx = xSemaphoreCreateMutex();

  // ---- SD. Explicit pins: the board variant's SPI defaults are wrong ----
  SPI.begin(PIN_SD_SCK, PIN_SD_MISO, PIN_SD_MOSI, PIN_SD_CS);
  sdReady = SD.begin(PIN_SD_CS, SPI, SD_SPI_HZ);
  if (!sdReady) {
    // One retry at a conservative clock - long jumper leads often will not
    // train at 20 MHz, and that failure looks identical to bad wiring.
    Serial.println(F("[sd] 20 MHz failed, retrying at 4 MHz"));
    sdReady = SD.begin(PIN_SD_CS, SPI, 4000000UL);
  }
  if (sdReady) {
    // Measured once, here. usedBytes() counts free clusters across the whole
    // FAT, which on a 32 GB card is not something to do inside an HTTP
    // handler while eight streams are running - the numbers only change when
    // someone pulls the card anyway.
    cardTotalMb = (uint32_t)(SD.totalBytes() / (1024ULL * 1024ULL));
    cardUsedMb  = (uint32_t)(SD.usedBytes()  / (1024ULL * 1024ULL));
    cardKind    = (SD.cardType() == CARD_MMC)  ? "MMC"  :
                  (SD.cardType() == CARD_SD)   ? "SDSC" :
                  (SD.cardType() == CARD_SDHC) ? "SDHC" : "?";
    Serial.printf("[sd] %s %lu MB, %lu MB used\n", cardKind,
                  (unsigned long)cardTotalMb, (unsigned long)cardUsedMb);
    cardFiles = 0;
    File root = SD.open("/");
    if (root) { cardCountWalk(root, 0); root.close(); }
    Serial.printf("[sd] %u files on the card\n", (unsigned)cardFiles);
    // The games category is retired; sweep away the empty folder so it
    // stops appearing on shelves. rmdir refuses a non-empty one, which is
    // exactly right - if someone put files there, they keep their folder.
    if (SD.exists("/games") && SD.rmdir("/games"))
      Serial.println(F("[sd] removed empty /games (category retired)"));
  } else {
    Serial.println(F("[sd] MOUNT FAILED - check wiring, and that the card is FAT32"));
  }

  catalogueReport();

  // ---- audio out ----
  aout = new AudioOutputI2S();
  aout->SetPinout(PIN_I2S_BCLK, PIN_I2S_LRC, PIN_I2S_DOUT);
  // Writes each mono sample to BOTH slots. This matters: with the amp's SD
  // pin floating the breakout averages (L+R)/2, so feeding only one slot
  // would play about 6 dB quiet.
  aout->SetOutputModeMono(true);
  aout->SetGain(volume);

  // Read-ahead buffer into PSRAM, once. Falls back to internal RAM so a board
  // with no PSRAM still plays rather than silently failing to allocate.
  // 24 KB, about two seconds at 96 kbps. The chapter transport serves the
  // card in flat-out bursts several seconds long, and the old 8 KB (0.7 s)
  // read-ahead drained mid-burst - the decoder tripped on a dry read and
  // "normal playback" glitched with nothing else wrong anywhere.
  abufSpace = (uint8_t *)ps_malloc(24576);
  if (!abufSpace) abufSpace = (uint8_t *)malloc(24576);
  Serial.printf("[audio] decoder %u B internal, read-ahead 24576 B %s\n",
                (unsigned)sizeof(mp3Space), abufSpace ? "reserved" : "FAILED");

  xTaskCreatePinnedToCore(audioTask, "audio", 8192, nullptr, 2, nullptr, 1);

  // ---- WiFi access point ----
#if LIB_ENABLE_WIFI
  WiFi.mode(WIFI_AP);
  // A passphrase under 8 characters is silently ignored by the IDF and the AP
  // comes up OPEN, which is the one outcome we must never ship by accident.
  static_assert(sizeof(AP_PASSWORD) - 1 >= 8,
                "AP_PASSWORD must be at least 8 characters or the AP comes up open");
  bool ap = WiFi.softAP(AP_SSID, AP_PASSWORD, 1, AP_HIDDEN, AP_MAX_CLIENTS);
  // Must be set AFTER softAP(): bringing the interface up resets it to max.
  WiFi.setTxPower(AP_TX_POWER);

  // Reap dead stations quickly.
  //
  // A handheld that is switched off does not say goodbye - there is no deauth,
  // the radio simply stops. The AP keeps that station in its table until its
  // own inactivity timer expires, which by default is minutes, so
  // softAPgetStationNum() cheerfully reports a client that is sitting on the
  // bench with its battery out. That is why turning an Atlas off left the
  // music playing: the rule that says "no Atlas, no sound" could not see that
  // the Atlas had gone.
  //
  // Ten seconds without a single frame from a station is long enough to be
  // certain and short enough to feel immediate. Atlas talks to the base
  // station every four seconds while anything is playing, so a live one can
  // never trip this.
  esp_wifi_set_inactive_time(WIFI_IF_AP, 10);

  // Ask the driver what it ACTUALLY accepted. max_connection is a uint8_t with
  // no documented ceiling in the headers - the limit lives inside the
  // closed-source WiFi library - so the only honest way to know how many
  // handhelds this box supports is to set the number we want and read back
  // what we were given.
  {
    wifi_config_t got = {};
    if (esp_wifi_get_config(WIFI_IF_AP, &got) == ESP_OK) {
      Serial.printf("[wifi] max clients requested %d, driver accepted %d\n",
                    AP_MAX_CLIENTS, got.ap.max_connection);
    }
  }

  // Re-apply the transmit power LAST. Reading the config back showed 19.5 dBm
  // despite setTxPower() being called earlier - something in the AP setup
  // between the two puts it back to maximum, so the only setting that sticks
  // is the one made after everything else is configured.
  WiFi.setTxPower(AP_TX_POWER);
  Serial.printf("[wifi] AP \"%s\" %s, WPA2, %s, up to %d clients, tx %.1f dBm\n",
                AP_SSID, ap ? "up" : "FAILED",
                AP_HIDDEN ? "hidden" : "broadcast", AP_MAX_CLIENTS,
                (float)WiFi.getTxPower() / 4.0f);
  Serial.print(F("[wifi] http://")); Serial.println(WiFi.softAPIP());

  server.on("/",          handleRoot);
  server.on("/browse",    handleBrowse);
  server.on("/file",      handleFile);
  server.on("/ctl",       handleCtl);      // the command channel
  server.on("/play",      handlePlay);
  server.on("/stop",      handleStop);
  server.on("/apos",      handleApos);     // where is the soundtrack?
  server.on("/aseek",     handleAseek);    // put it here
  server.on("/apause",    handleApause);   // hold it / let it go
  server.on("/vol",       handleVolume);
  server.on("/who",       handleWho);      // who holds control
  server.on("/status",    handleStatus);
  server.on("/rescan",    handleRescan);
  server.begin();
  Serial.println(F("[http] server started"));
  ctlServer.begin();
  ctlServer.setNoDelay(true);
  Serial.println(F("[ctl] raw listener on 8081"));

#else
  WiFi.mode(WIFI_OFF);
  Serial.println(F("[wifi] DISABLED - thermal test build"));
#endif
  serialHelp();
}

// A tiny serial console. Bring-up is far easier when the card, the amp and
// the network can each be exercised on their own.
static void pollSerial() {
  static char line[96];
  static uint8_t n = 0;
  while (Serial.available()) {
    char c = Serial.read();
    if (c == '\r') continue;
    if (c == '\n') {
      line[n] = 0;
      if (n) {
        char *cmd = strtok(line, " ");
        char *arg = strtok(nullptr, "");
        if      (!strcasecmp(cmd, "list")) catalogueReport();
        // The card lives inside a sealed box and USB MSC is off, so without a
        // way to look into a subfolder from here the only way to see what is
        // on it is to open the box and pull the card.
        else if (!strcasecmp(cmd, "ls")) {
          browseDir(arg && *arg ? arg : "/");
          Serial.printf("[cat] %s -> %u entr%s\n", catPath, catCount, catCount == 1 ? "y" : "ies");
          for (uint16_t i = 0; i < catCount; i++) {
            Serial.printf("  %-2u %-5s %-28s", i, KIND_NAMES[cat[i].kind], cat[i].title);
            if (cat[i].kind == KIND_DIR) Serial.print(F("  <dir>"));
            else                         Serial.printf("  %8u B", cat[i].bytes);
            if (cat[i].secs) Serial.printf("  %u:%02u", cat[i].secs / 60, cat[i].secs % 60);
            Serial.printf("   %s\n", cat[i].path);
          }
        }
        // Same reason. Full path required, and it names what it removed - a
        // delete that guesses at what you meant is not a delete anyone wants.
        else if (!strcasecmp(cmd, "rm") && arg) {
          if (arg[0] != '/') Serial.println(F("[rm] give the full path, starting with /"));
          else if (!SD.exists(arg)) Serial.printf("[rm] no such file: %s\n", arg);
          else if (SD.remove(arg))  Serial.printf("[rm] removed %s\n", arg);
          else                      Serial.printf("[rm] could not remove %s\n", arg);
        }
        else if (!strcasecmp(cmd, "rescan")) { ensureCategoryFolders(); catalogueReport(); }
        else if (!strcasecmp(cmd, "log"))    elogDump();
        else if (!strcasecmp(cmd, "tone"))   toneRequested = true;
        else if (!strcasecmp(cmd, "stop"))   audioStop();
        else if (!strcasecmp(cmd, "pause"))  { if (!audioPause())  Serial.println(F("[audio] nothing playing")); }
        else if (!strcasecmp(cmd, "resume")) { if (!audioResume()) Serial.println(F("[audio] nothing paused")); }
        else if (!strcasecmp(cmd, "status")) {
          uint8_t live = 0;
          for (uint8_t i = 0; i < LIB_STREAM_SLOTS; i++) if (sslot[i].busy) live++;
          // Die temperature, so the thermal question stops being a matter of
          // opinion. The S3's sensor reads the SILICON, not the case - a chip
          // sitting at 55-65 C in a warm room is entirely normal for a radio
          // that never sleeps, and the shutdown threshold is far above that.
          Serial.printf("sd=%d items=%u playing=%s vol=%d clients=%d streams=%u/%u heap=%u temp=%.1fC\n",
                        sdReady, catCount, audioBusy ? nowPlaying : "-",
                        (int)(volume * 100), WiFi.softAPgetStationNum(),
                        live, LIB_STREAM_SLOTS, ESP.getFreeHeap(), temperatureRead());
        }
        else if (!strcasecmp(cmd, "vol") && arg) {
          volume = constrain(atoi(arg), 0, 100) / 100.0f;
          volumeTouched();
          if (aout) aout->SetGain(volume);
          Serial.printf("vol=%d\n", (int)(volume * 100));
        }
        else if (!strcasecmp(cmd, "play") && arg) {
          char p[LIB_PATH_MAX];
          snprintf(p, sizeof(p), "%s%s", (arg[0] == '/') ? "" : "/", arg);
          audioPlay(p);
        }
        else serialHelp();
      }
      n = 0;
    } else if (n < sizeof(line) - 1) {
      line[n++] = c;
    }
  }
}

// Library must never depend on a client coming back. A restart, a flat
// battery or a crash on the Atlas side all skip its /stop, and the audio task
// is on its own core, so it happily plays to the end of a two-hour album in an
// empty room. Two independent conditions end it, and neither one touches
// playback that was started from the serial console.
static void audioIdleWatch() {
  if (!audioBusy || !httpStarted) return;

  // Gate it. softAPgetStationNum() takes the WiFi API lock, and calling it on
  // every pass meant several hundred lock acquisitions a second competing with
  // the task that moves the stream's packets. Nothing here needs to be checked
  // more than twice a second.
  static uint32_t last = 0;
  if (millis() - last < 500) return;
  last = millis();

  // No Atlas on the network means nothing is listening, so nothing should be
  // playing. The network is closed and hidden, so a station IS an Atlas -
  // there is no other kind of client to account for.
  //
  // Debounced, though: a single zero reading is not proof. A handheld that
  // roams, or drops a beacon, or re-associates after a moment of interference
  // reads as zero for a fraction of a second, and silencing the room every
  // time that happened would be its own fault. Three seconds of continuous
  // zero is a departure; anything shorter is radio.
  static uint32_t emptySince = 0;
  if (WiFi.softAPgetStationNum() == 0) {
    if (!emptySince) emptySince = millis();
    if (millis() - emptySince > 3000) {
      Serial.println(F("[audio] no Atlas on the network - stopping"));
      elog(true, "idle stop (no clients)");
      emptySince = 0;
      audioStop();
      return;
    }
  } else {
    emptySince = 0;
  }

  if (millis() - lastReqMs > LIB_IDLE_STOP_MS) {
    Serial.printf("[audio] no request in %lu s - stopping\n",
                  (unsigned long)(LIB_IDLE_STOP_MS / 1000));
    audioStop();
  }
}

void loop() {
  server.handleClient();
  streamPump();       // bounded body writes; see the note at StreamSlot
  ctlAccept();        // the raw listener outranks the web server
  ctlTick();          // the room's shared clock, two-second cadence
  ctlPump();          // command lines off the control channels
  audioDeadMan();     // silence from the station holds the speaker

  ctlOwnerTick();     // release the room lock when the admin leaves
  volumeSaveTick();   // debounced NVS write for the volume knob
  statusUpdate();     // status light: red = no card, blue = idle, green = playing
  audioIdleWatch();
  pollSerial();
  // 8 ms rather than 2: the server only needs polling fast enough to feel
  // instant, and spinning four times more often just made heat. Skipped while
  // a stream is live, where the pump is the thing that needs the passes.
  bool streaming = false;
  for (uint8_t i = 0; i < LIB_STREAM_SLOTS; i++) if (sslot[i].busy) { streaming = true; break; }
  delay(streaming ? 1 : 8);
}
