const uint32_t POLL_MS = 1000;
#define TFT_BL_PIN 21

// CYD dokunmatik (XPT2046)
#define T_CS   33
#define T_CLK  25
#define T_MOSI 32
#define T_MISO 39
#define T_IRQ  36
// ===========================================

TFT_eSPI tft = TFT_eSPI();
TFT_eSprite titleSpr = TFT_eSprite(&tft);
JPEGDEC jpeg;
SPIClass touchSPI(VSPI);

// WiFi menu durumu
struct WifiNet { String ssid; int rssi; bool enc; };
const int MAX_NETS = 20, NET_VISIBLE = 5;
WifiNet g_nets[MAX_NETS];
int g_netCount = 0, g_netScroll = 0;
String g_ssid, g_pass, g_kbInput;
bool g_enc = true, g_kbShift = false, g_kbSym = false;

uint16_t C_BG, C_GREEN, C_GREY, C_DARK, C_BAR_BG, C_BTN;

// Duzen
const int ART_X = 8, ART_Y = 42, ART_W = 150, ART_H = 150;
const int SPR_X = 168, SPR_Y = 46, SPR_W = 144, SPR_H = 32, SCROLL_GAP = 40;
const int TXT_X = 168;
const int CTRL_Y = 150, CTRL_H = 42, CTRL_W = 44, CTRL_GAP = 6;
const int BAR_X = 16, BAR_Y = 205, BAR_W = 288, BAR_H = 8;
const int TIME_Y = 214;

// Soz alani duzeni
const int LYR_Y = 92, LYR_H = 108, LYR_X = 12, LYR_W = 296;

// Token
String accessToken;
uint32_t tokenExpiresAt = 0;

// Calma durumu
bool havePlayer = false;
bool playing = false;
int volumePercent = 50;
String trackId, title, artist, album, artUrl, lastArtUrl;
uint32_t durationMs = 0, progressMs = 0, fetchedAt = 0;

// UI durumu
int screenState = -1;
int lastSec = -1, lastBarW = -1;
bool lastShownPlaying = false;
int titleW = 0, scrollX = 0;
uint32_t lastScroll = 0, lastUi = 0, lastPoll = 0, pollInterval = POLL_MS;
uint32_t lastLyricUi = 0;

// Ileri sarma (cubuk surukleme)
bool seekActive = false;
uint32_t seekMs = 0;

// Sozler (lrclib.net)
struct LyricLine { uint32_t ms; String text; };
const int MAX_LYRICS = 80;
LyricLine lyrics[MAX_LYRICS];
int lyricCount = 0;
bool lyricsMode = false;
bool lyricsAvailable = false;
bool lyricsSynced = false;
int lastLyricIdx = -1;
String lyricsTrackId;
bool lyricsAttempted = false;

// Playlist tarayici
const int MAX_PL = 50, MAX_TR = 100;
const int LIST_Y0 = 40, LIST_ROW = 32, LIST_ROWS = 5;
String plName[MAX_PL], plId[MAX_PL];
int plCount[MAX_PL];
int plTotal = 0, plScroll = 0;
String trName[MAX_TR], trArtist[MAX_TR];
int trPos[MAX_TR];
int trTotal = 0, trScroll = 0;
String curPlaylistId;
bool plAuthNeeded = false;

bool jpFontLoaded = false;
bool jpFontAvailable = false;
bool sdOk = false;       // SD mounted (album art dosya cache)
bool needRedraw = false; // track degisti, loop() cizecek (TLS serbestken)

// ---------- Metin yardimcilari ----------
String toAscii(const char* s) {
  String o;
  size_t n = strlen(s);
  o.reserve(n);
  for (size_t i = 0; i < n;) {
    uint8_t c = s[i];
    if (c < 0x80) { o += (char)c; i++; continue; }
    int len = (c >= 0xF0) ? 4 : (c >= 0xE0) ? 3 : 2;
    if (len == 2 && i + 1 < n) {
      uint16_t cp = ((c & 0x1F) << 6) | (s[i + 1] & 0x3F);
      char r = '?';
      switch (cp) {
        case 0x11F: r = 'g'; break; case 0x11E: r = 'G'; break;
        case 0x131: r = 'i'; break; case 0x130: r = 'I'; break;
        case 0x15F: r = 's'; break; case 0x15E: r = 'S'; break;
        case 0xF6: case 0xF3: r = 'o'; break; case 0xD6: r = 'O'; break;
        case 0xFC: case 0xFA: r = 'u'; break; case 0xDC: r = 'U'; break;
        case 0xE7: r = 'c'; break; case 0xC7: r = 'C'; break;
        case 0xE9: case 0xE8: case 0xEA: case 0xE4: r = 'e'; break;
        case 0xE1: case 0xE0: case 0xE2: r = 'a'; break;
        case 0xED: case 0xEE: r = 'i'; break; case 0xF1: r = 'n'; break;
      }
      o += r;
    } else {
      o += '?';
    }
    i += len;
  }
  return o;
}

// UTF-8 display: JP font yoksa ASCII'ye cevir (tek fonksiyon - yer tasarrufu)
String dispStr(const String &s) { return toAscii(s.c_str()); }

// Metin CJK (Japonca/Cince) iceriyor mu?
bool hasCJK(const String &s) {
  for (size_t i = 0; i < s.length(); i++) {
    uint8_t c = s[i];
    if (c >= 0xE3 && c <= 0xE9) return true;
  }
  return false;
}

// JP smooth fontu ihtiyaca gore yukle/birak
void applyJPFont() {
  if (!jpFontAvailable) return;
  bool need = hasCJK(title) || hasCJK(artist) || hasCJK(album);
  if (!need) {
    for (int i = 0; i < lyricCount; i++) {
      if (hasCJK(lyrics[i].text)) { need = true; break; }
    }
  }
  if (need == jpFontLoaded) return;
  if (need) {
    if (ESP.getFreeHeap() < 130000) {
      Serial.printf("JP font skip, low heap %u\n", ESP.getFreeHeap());
      return;
    }
    tft.loadFont("jp16", SD);
    jpFontLoaded = true;
  } else {
    tft.unloadFont();
    jpFontLoaded = false;
  }
}

String fitText(String s, int maxW, int font) {
  if (!jpFontLoaded) {
    s = dispStr(s);
    tft.setTextFont(font);
  }
  if (tft.textWidth(s) <= maxW) return s;
  String suffix = "...";
  while (s.length() > 1 && tft.textWidth(s + suffix) > maxW) s.remove(s.length() - 1);
  return s + suffix;
}

String fmtTime(uint32_t ms) {
  uint32_t s = ms / 1000, h = s / 3600, m = (s / 60) % 60, sec = s % 60;
  char buf[16];
  if (h > 0) snprintf(buf, sizeof(buf), "%u:%02u:%02u", h, m, sec);
  else       snprintf(buf, sizeof(buf), "%u:%02u", (unsigned)(s / 60), sec);
  return String(buf);
}

uint32_t currentProgress() {
  if (!playing) return progressMs;
  uint32_t p = progressMs + (millis() - fetchedAt);
  if (durationMs > 0 && p > durationMs) p = durationMs;
  return p;
}

// ---------- Dokunmatik ----------
uint16_t touchADCCh(uint8_t cmd) {
  digitalWrite(T_CS, LOW);
  touchSPI.transfer(cmd);
  uint16_t v = touchSPI.transfer16(0);
  digitalWrite(T_CS, HIGH);
  return v >> 3;
}

bool touchRaw(uint16_t *rx, uint16_t *ry) {
  uint16_t z1 = touchADCCh(0xB0);
  if (z1 < 40) return false;
  uint16_t x1 = touchADCCh(0xD0);
  uint16_t y1 = touchADCCh(0x90);
  uint16_t z2 = touchADCCh(0xC0);
  uint16_t pressure = z1 + 4095 - z2;
  if (pressure < 80) return false;

  uint16_t x2 = touchADCCh(0xD0);
  uint16_t y2 = touchADCCh(0x90);

  if (x1 < 50 || x1 > 4050 || y1 < 50 || y1 > 4050) return false;
  if (abs((int)x1 - (int)x2) > 300 || abs((int)y1 - (int)y2) > 300) return false;

  *rx = (x1 + x2) / 2;
  *ry = (y1 + y2) / 2;
  return true;
}

bool touchToScreen(uint16_t rawX, uint16_t rawY, int *sx, int *sy) {
  int x = map(rawY, 529, 3442, 0, 319);
  int y = map(rawX, 672, 3312, 0, 239);
  if (x < -25 || x > 344 || y < -25 || y > 264) return false;
  if (x < 0) x = 0; if (x > 319) x = 319;
  if (y < 0) y = 0; if (y > 239) y = 239;
  *sx = x;
  *sy = y;
  return true;
}

// ---------- Spotify komut (skip / play / pause / seek) ----------
bool refreshAccessToken();

bool spotifyCommand(const char *path) {
  if (WiFi.status() != WL_CONNECTED) return false;
  if (accessToken.isEmpty() || (int32_t)(millis() - tokenExpiresAt) > 0) {
    if (!refreshAccessToken()) return false;
  }
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(5000);
  String url = String("https://api.spotify.com/v1/me/player/") + path;
  http.begin(client, url);
  http.addHeader("Authorization", "Bearer " + accessToken);
  http.addHeader("Content-Type", "application/json");
  const char *method = (strstr(path, "next") || strstr(path, "previous")) ? "POST" : "PUT";
  const char *body = "{}";
  int code = http.sendRequest(method, (uint8_t *)body, strlen(body));
  http.end();
  Serial.printf("CMD %s -> %d\n", path, code);
  return code >= 200 && code < 300;
}

bool spotifySetVolume(int pct) {
  if (pct < 0) pct = 0;
  if (pct > 100) pct = 100;
  char path[48];
  snprintf(path, sizeof(path), "volume?volume_percent=%d", pct);
  if (spotifyCommand(path)) {
    volumePercent = pct;
    return true;
  }
  return false;
}

// ---------- Playlist API ----------
bool spotifyGet(const String &path, String *out, int *codeOut) {
  if (WiFi.status() != WL_CONNECTED) return false;
  if (accessToken.isEmpty() || (int32_t)(millis() - tokenExpiresAt) > 0) {
    if (!refreshAccessToken()) return false;
  }
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(8000);
  http.begin(client, "https://api.spotify.com/v1/" + path);
  http.addHeader("Authorization", "Bearer " + accessToken);
  int code = http.GET();
  if (codeOut) *codeOut = code;
  if (code == 200 && out) *out = http.getString();
  http.end();
  return code == 200;
}

bool fetchPlaylists() {
  plTotal = 0;
  plScroll = 0;
  plAuthNeeded = false;
  String body;
  int code = 0;
  if (!spotifyGet("me/playlists?limit=50", &body, &code)) {
    if (code == 403) plAuthNeeded = true;
    return false;
  }

  JsonDocument filter;
  filter["items"][0]["id"] = true;
  filter["items"][0]["name"] = true;
  filter["items"][0]["tracks"]["total"] = true;
  filter["items"][0]["items"]["total"] = true;
  JsonDocument doc;
  if (deserializeJson(doc, body, DeserializationOption::Filter(filter))) return false;

  for (JsonVariant v : doc["items"].as<JsonArray>()) {
    if (plTotal >= MAX_PL) break;
    String id = v["id"] | "";
    if (id.isEmpty()) continue;
    plId[plTotal] = id;
    plName[plTotal] = v["name"] | "?";
    int cnt = v["items"]["total"] | 0;
    if (cnt == 0) cnt = v["tracks"]["total"] | 0;
    plCount[plTotal] = cnt;
    plTotal++;
  }
  return plTotal > 0;
}

bool fetchPlaylistTracks(const String &id) {
  trTotal = 0;
  trScroll = 0;
  String body;
  int code = 0;
  String path = "playlists/" + id + "/items?limit=100";
  if (!spotifyGet(path, &body, &code)) {
    return false;
  }

  JsonDocument filter;
  filter["items"][0]["item"]["name"] = true;
  filter["items"][0]["item"]["artists"][0]["name"] = true;
  filter["items"][0]["track"]["name"] = true;
  filter["items"][0]["track"]["artists"][0]["name"] = true;
  JsonDocument doc;
  if (deserializeJson(doc, body, DeserializationOption::Filter(filter))) return false;

  int pos = 0;
  for (JsonVariant v : doc["items"].as<JsonArray>()) {
    JsonObject tr = v["item"];
    if (tr.isNull()) tr = v["track"];
    if (tr.isNull()) { pos++; continue; }
    String name = tr["name"] | "";
    if (name.isEmpty()) { pos++; continue; }
    if (trTotal >= MAX_TR) break;
    String art;
    for (JsonVariant a : tr["artists"].as<JsonArray>()) {
      if (art.length()) art += ", ";
      art += a["name"] | "";
    }
    trName[trTotal] = name;
    trArtist[trTotal] = art;
    trPos[trTotal] = pos;
    trTotal++;
    pos++;
  }
  return trTotal > 0;
}

bool spotifyPlayAt(const String &playlistId, int pos) {
  if (WiFi.status() != WL_CONNECTED) return false;
  if (accessToken.isEmpty() || (int32_t)(millis() - tokenExpiresAt) > 0) {
    if (!refreshAccessToken()) return false;
  }
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(6000);
  http.begin(client, "https://api.spotify.com/v1/me/player/play");
  http.addHeader("Authorization", "Bearer " + accessToken);
  http.addHeader("Content-Type", "application/json");
  String body = "{\"context_uri\":\"spotify:playlist:" + playlistId +
                "\",\"offset\":{\"position\":" + String(pos) + "}}";
  int code = http.sendRequest("PUT", (uint8_t *)body.c_str(), body.length());
  http.end();
  Serial.printf("PLAY %d -> %d\n", pos, code);
  return code >= 200 && code < 300;
}

// ---------- Sozler ----------
String urlEncode(const String &s) {
  String o;
  o.reserve(s.length() * 3);
  for (unsigned i = 0; i < s.length(); i++) {
    char c = s[i];
    if (isalnum((unsigned char)c) || c == '-' || c == '_' || c == '.' || c == '~') o += c;
    else if (c == ' ') o += "%20";
    else {
      char buf[4];
      snprintf(buf, sizeof(buf), "%%%02X", (uint8_t)c);
      o += buf;
    }
  }
  return o;
}

void clearLyrics() {
  lyricCount = 0;
  lyricsAvailable = false;
  lyricsSynced = false;
  lyricsTrackId = "";
  lyricsAttempted = false;
  lastLyricIdx = -1;
}

bool parseLRC(const String &lrc) {
  lyricCount = 0;
  // LRC var mi kontrol
  bool hasTags = lrc.indexOf('[') >= 0 && lrc.indexOf("]:") >= 0 || lrc.indexOf(':') > 0 && lrc.indexOf('[') == 0;

  if (!hasTags || lrc.indexOf("[0") < 0) {
    // plain lyrics - satir satir, zaman yok
    lyricsSynced = false;
    int pos = 0;
    while (pos < (int)lrc.length() && lyricCount < MAX_LYRICS) {
      int nl = lrc.indexOf('\n', pos);
      if (nl < 0) nl = lrc.length();
      String text = lrc.substring(pos, nl);
      text.trim();
      // UTF-8 koru
      if (text.length() > 0) {
        lyrics[lyricCount].ms = 0; // zaman yok
        lyrics[lyricCount].text = text;
        lyricCount++;
      }
      pos = nl + 1;
    }
    return lyricCount > 0;
  }

  // LRC parse
  lyricsSynced = true;
  int pos = 0;
  while (pos < (int)lrc.length() && lyricCount < MAX_LYRICS) {
    int bracket = lrc.indexOf('[', pos);
    if (bracket < 0) break;
    int close = lrc.indexOf(']', bracket);
    if (close < 0) break;
    String tag = lrc.substring(bracket + 1, close);
    int c1 = tag.indexOf(':');
    if (c1 < 0) { pos = close + 1; continue; }
    // sadece [mm:ss...] etiketleri (metadata degil)
    if (c1 > 2 || !isDigit(tag[0])) { pos = close + 1; continue; }
    int c2 = tag.indexOf('.', c1);
    uint32_t min = tag.substring(0, c1).toInt();
    uint32_t sec, msPart = 0;
    if (c2 < 0) {
      sec = tag.substring(c1 + 1).toInt();
    } else {
      sec = tag.substring(c1 + 1, c2).toInt();
      String frac = tag.substring(c2 + 1);
      if (frac.length() == 2) msPart = frac.toInt() * 10;
      else if (frac.length() >= 3) msPart = frac.substring(0, 3).toInt();
    }
    int lineEnd = lrc.indexOf('\n', close);
    if (lineEnd < 0) lineEnd = lrc.length();
    String text = lrc.substring(close + 1, lineEnd);
    text.trim();
    if (text.length() > 0) {
      lyrics[lyricCount].ms = min * 60000 + sec * 1000 + msPart;
      lyrics[lyricCount].text = text;
      lyricCount++;
    }
    pos = lineEnd + 1;
  }
  return lyricCount > 0;
}

bool fetchLyrics() {
  clearLyrics();
  lyricsAttempted = true;
  if (title.isEmpty() || artist.isEmpty()) return false;

  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(6000);

  // Once get dene (daha guvenilir - dogru track)
  String url = "https://lrclib.net/api/get?track_name=" + urlEncode(title) +
               "&artist_name=" + urlEncode(artist) +
               "&duration=" + String(durationMs / 1000);
  Serial.println(url);
  http.begin(client, url);
  int code = http.GET();
  String body = (code == 200) ? http.getString() : "";
  http.end();

  if (!body.isEmpty() && body.startsWith("{")) {
    JsonDocument doc;
    if (!deserializeJson(doc, body)) {
      String lrc = doc["syncedLyrics"] | "";
      if (lrc.isEmpty()) lrc = doc["plainLyrics"] | "";
      if (!lrc.isEmpty() && parseLRC(lrc)) {
        lyricsTrackId = trackId;
        lyricsAvailable = true;
        Serial.printf("Lyrics (get): %d lines synced=%d\n", lyricCount, lyricsSynced);
        return true;
      }
    }
  }

  // search fallback
  url = "https://lrclib.net/api/search?q=" + urlEncode(title + " " + artist);
  Serial.println(url);
  http.begin(client, url);
  code = http.GET();
  body = (code == 200) ? http.getString() : "";
  http.end();
  if (body.isEmpty()) { Serial.println("Lyrics: empty"); return false; }

  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, body);
  if (err) { Serial.printf("Lyrics: json err %s\n", err.c_str()); return false; }

  if (doc.is<JsonArray>()) {
    for (JsonVariant v : doc.as<JsonArray>()) {
      String lrc = v["syncedLyrics"] | "";
      if (!lrc.isEmpty() && parseLRC(lrc)) {
        lyricsTrackId = trackId;
        lyricsAvailable = true;
        Serial.printf("Lyrics: %d lines synced=1\n", lyricCount);
        return true;
      }
    }
    for (JsonVariant v : doc.as<JsonArray>()) {
      String lrc = v["plainLyrics"] | "";
      if (!lrc.isEmpty() && parseLRC(lrc)) {
        lyricsTrackId = trackId;
        lyricsAvailable = true;
        Serial.printf("Lyrics: %d lines synced=0\n", lyricCount);
        return true;
      }
    }
    Serial.println("Lyrics: no match");
  } else {
    Serial.println("Lyrics: not array");
  }
  return false;
}

void drawLyricsView() {
  applyJPFont();
  tft.fillScreen(C_BG);
  drawHeader();
  drawStatus();

  // Baslik kucuk goster
  tft.setTextFont(4);
  tft.setTextDatum(TL_DATUM);
  tft.setTextColor(TFT_WHITE, C_BG);
  tft.drawString(fitText(title, 260, 4), 12, 38);
  tft.setTextColor(C_GREEN, C_BG);
  tft.setTextFont(2);
  tft.drawString(fitText(artist, 260, 2), 12, 68);

  // Kapat butonu (sag ust)
  tft.fillRoundRect(280, 36, 30, 30, 6, C_BTN);
  tft.setTextFont(4);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(TFT_WHITE, C_BTN);
  tft.drawString("X", 295, 53);

  // Soz yoksa mesaj
  if (!lyricsAvailable || lyricCount == 0) {
    tft.setTextColor(C_GREY, C_BG);
    tft.setTextFont(4);
    tft.setTextDatum(MC_DATUM);
    tft.drawString("Soz bulunamadi", 160, 140);
  }

  // Kontroller
  drawCtrlBtns();
  tft.fillRoundRect(BAR_X, BAR_Y, BAR_W, BAR_H, 4, C_BAR_BG);

  lastLyricIdx = -1;
  screenState = 2; // lyrics mode
}

int findCurrentLyric(uint32_t ms) {
  int idx = -1;
  for (int i = 0; i < lyricCount; i++) {
    if (lyrics[i].ms <= ms) idx = i;
    else break;
  }
  return idx;
}

void updateLyricsUI() {
  if (screenState != 2 || !lyricsAvailable || lyricCount == 0) return;

  const int LYR_Y = 92, LYR_H = 108, LYR_X = 12, LYR_W = 296;
  const int LINE_H = 18, VISIBLE = 6;

  int idx;
  if (lyricsSynced) {
    uint32_t p = currentProgress();
    idx = findCurrentLyric(p);
  } else {
    // plain: zamanla kaydir
    static uint32_t lastShift = 0;
    static int plainPos = 0;
    if (millis() - lastShift > 4000) {
      lastShift = millis();
      plainPos++;
      if (plainPos >= lyricCount) plainPos = 0;
    }
    idx = plainPos;
  }
  if (idx == lastLyricIdx) return;
  lastLyricIdx = idx;

  tft.fillRect(LYR_X, LYR_Y, LYR_W, LYR_H, C_BG);
  if (idx < 0) return;

  int start = idx - VISIBLE / 2;
  if (start < 0) start = 0;
  int end = start + VISIBLE;
  if (end > lyricCount) { end = lyricCount; start = max(0, end - VISIBLE); }

  tft.setTextFont(2);
  tft.setTextDatum(TL_DATUM);
  for (int i = start; i < end; i++) {
    int y = LYR_Y + (i - start) * LINE_H;
    if (i == idx) {
      tft.setTextColor(C_GREEN, C_BG);
      tft.setTextFont(4);
      tft.drawString(fitText(lyrics[i].text, LYR_W, 4), LYR_X, y);
      tft.setTextFont(2);
    } else {
      tft.setTextColor(C_GREY, C_BG);
      tft.drawString(fitText(lyrics[i].text, LYR_W, 2), LYR_X, y);
    }
  }
}

void toggleLyricsMode() {
  if (lyricsMode) {
    lyricsMode = false;
    lastArtUrl = "";
    drawTrackScreen();
  } else {
    lyricsMode = true;
    if (!lyricsAttempted && havePlayer && !trackId.isEmpty()) {
      fetchLyrics();
    }
    drawLyricsView();
    updateLyricsUI();
  }
}

// ---------- Playlist tarayici UI ----------
void drawListHeader(const char *title) {
  tft.fillRect(0, 0, 320, 34, C_DARK);
  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(4);
  tft.setTextColor(C_GREEN, C_DARK);
  tft.drawString(title, 12, 5);
  tft.fillRoundRect(282, 6, 30, 22, 4, C_BTN);
  tft.setTextFont(2);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(TFT_WHITE, C_BTN);
  tft.drawString("<", 297, 16);
}

void drawBrowserBack() {
  tft.fillRoundRect(8, 202, 304, 30, 3, C_BTN);
  tft.setTextFont(2);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(TFT_WHITE, C_BTN);
  tft.drawString("Geri", 160, 217);
}

void showBrowserMsg(const char *m) {
  tft.setTextFont(2);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(TFT_ORANGE, C_BG);
  tft.drawString(m, 160, 110);
  if (plAuthNeeded) {
    tft.setTextColor(C_GREY, C_BG);
    tft.drawString("Yeni REFRESH_TOKEN gerekli", 160, 134);
  }
}

void drawListView(const char *title, bool playlists) {
  tft.fillScreen(C_BG);
  drawListHeader(title);

  int total = playlists ? plTotal : trTotal;
  int scroll = playlists ? plScroll : trScroll;

  for (int i = 0; i < LIST_ROWS; i++) {
    int idx = i + scroll;
    if (idx >= total) break;
    int y = LIST_Y0 + i * LIST_ROW;
    tft.fillRoundRect(8, y, 274, LIST_ROW - 2, 3, C_BTN);
    tft.setTextFont(2);
    tft.setTextDatum(ML_DATUM);
    tft.setTextColor(TFT_WHITE, C_BTN);
    String s;
    if (playlists) {
      s = plName[idx];
      if (plCount[idx] > 0) s += " (" + String(plCount[idx]) + ")";
    } else {
      s = trName[idx];
      if (!trArtist[idx].isEmpty()) s += " - " + trArtist[idx];
    }
    if (s.length() > 37) s = s.substring(0, 36) + "..";
    tft.drawString(s, 16, y + LIST_ROW / 2 - 1);
  }

  if (total > LIST_ROWS) {
    tft.fillTriangle(300, 44, 291, 60, 309, 60, scroll > 0 ? C_GREEN : C_BAR_BG);
    tft.fillTriangle(300, 190, 291, 174, 309, 174,
                     (scroll + LIST_ROWS < total) ? C_GREEN : C_BAR_BG);
  }

  drawBrowserBack();
  screenState = playlists ? 3 : 4;
}

void openBrowser() {
  lyricsMode = false;
  tft.fillScreen(C_BG);
  drawListHeader("Playlistler");
  tft.setTextFont(2);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(C_GREY, C_BG);
  tft.drawString("Yukleniyor...", 160, 115);

  if (WiFi.status() != WL_CONNECTED) {
    plAuthNeeded = false;
    tft.fillScreen(C_BG);
    drawListHeader("Playlistler");
    showBrowserMsg("WiFi yok");
    drawBrowserBack();
    screenState = 3;
    return;
  }

  if (fetchPlaylists() && plTotal > 0) {
    drawListView("Playlistler", true);
    return;
  }

  tft.fillScreen(C_BG);
  drawListHeader("Playlistler");
  showBrowserMsg(plAuthNeeded ? "Playlist yetkisi gerekli" : "Playlist bulunamadi");
  drawBrowserBack();
  screenState = 3;
}

void closeBrowser() {
  lastArtUrl = "";
  lastPoll = 0;
  if (havePlayer) drawTrackScreen();
  else drawIdle();
  updateProgressUI(true);
}

void handleBrowserTouch(int sx, int sy) {
  bool playlists = (screenState == 3);
  int total = playlists ? plTotal : trTotal;
  int &scroll = playlists ? plScroll : trScroll;

  if (sy < 34 || sy >= 200) {
    if (playlists) closeBrowser();
    else drawListView("Playlistler", true);
    return;
  }

  if (sx >= 286 && total > LIST_ROWS) {
    if (sy < 110) { if (scroll > 0) scroll--; }
    else { if (scroll + LIST_ROWS < total) scroll++; }
    drawListView(playlists ? "Playlistler" : "Sarkilar", playlists);
    return;
  }

  if (sy >= LIST_Y0 && sy < LIST_Y0 + LIST_ROWS * LIST_ROW) {
    int idx = (sy - LIST_Y0) / LIST_ROW + scroll;
    if (idx >= total) return;
    if (playlists) {
      curPlaylistId = plId[idx];
      tft.fillScreen(C_BG);
      drawListHeader("Sarkilar");
      tft.setTextFont(2);
      tft.setTextDatum(MC_DATUM);
      tft.setTextColor(C_GREY, C_BG);
      tft.drawString("Yukleniyor...", 160, 115);
      if (fetchPlaylistTracks(curPlaylistId)) {
        drawListView("Sarkilar", false);
      } else {
        tft.fillScreen(C_BG);
        drawListHeader("Sarkilar");
        showBrowserMsg("Liste alinamadi");
        drawBrowserBack();
        screenState = 4;
      }
    } else {
      tft.fillScreen(C_BG);
      drawListHeader("Sarkilar");
      tft.setTextFont(4);
      tft.setTextDatum(MC_DATUM);
      tft.setTextColor(C_GREEN, C_BG);
      tft.drawString("Caliniyor...", 160, 120);
      spotifyPlayAt(curPlaylistId, trPos[idx]);
      delay(350);
      closeBrowser();
    }
  }
}

// ---------- Cizim ----------
void drawHeader() {
  tft.fillRect(0, 0, 320, 34, C_DARK);
  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(4);
  tft.setTextColor(C_GREEN, C_DARK);
  tft.drawString("Spotify", 12, 5);
  tft.fillCircle(104, 17, 5, C_GREEN);
  // Playlist secici butonu
  tft.fillRoundRect(92, 5, 68, 23, 4, C_BTN);
  tft.setTextFont(2);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(TFT_WHITE, C_BTN);
  tft.drawString("LISTE", 126, 16);
  // Ses seviyesi butonlari
  tft.fillRoundRect(162, 5, 16, 23, 4, C_BTN);
  tft.setTextFont(2);
  tft.setTextColor(TFT_WHITE, C_BTN);
  tft.drawString("-", 170, 16);
  tft.fillRoundRect(180, 5, 16, 23, 4, C_BTN);
  tft.drawString("+", 188, 16);
  tft.setTextFont(1);
  tft.setTextDatum(TL_DATUM);
  tft.setTextColor(TFT_WHITE, C_DARK);
  tft.drawString(String(volumePercent) + "%", 198, 11);
}

void drawStatus() {
  tft.setTextFont(2);
  tft.setTextDatum(TR_DATUM);
  tft.setTextPadding(110);
  tft.setTextColor(playing ? C_GREEN : TFT_ORANGE, C_DARK);
  tft.drawString(playing ? "CALIYOR" : "DURAKLAT", 308, 9);
  tft.setTextPadding(0);
  lastShownPlaying = playing;
}

void drawCtrlBtns() {
  const char *labels[3] = {"|<", "> ", ">|"};
  tft.setTextFont(4);
  tft.setTextDatum(MC_DATUM);
  for (int i = 0; i < 3; i++) {
    int x = TXT_X + i * (CTRL_W + CTRL_GAP);
    uint16_t bg = (i == 1) ? C_GREEN : C_BTN;
    uint16_t fg = (i == 1) ? TFT_BLACK : TFT_WHITE;
    tft.fillRoundRect(x, CTRL_Y, CTRL_W, CTRL_H, 6, bg);
    const char *lab = labels[i];
    if (i == 1) lab = playing ? "||" : "> ";
    tft.setTextColor(fg, bg);
    tft.drawString(lab, x + CTRL_W / 2, CTRL_Y + CTRL_H / 2 + 2);
  }
}

void drawTitleFrame() {
  String d = jpFontLoaded ? title : dispStr(title);
  if (jpFontLoaded) {
    tft.setViewport(SPR_X, SPR_Y, SPR_W, SPR_H, false);
    tft.fillRect(SPR_X, SPR_Y, SPR_W, SPR_H, C_BG);
    tft.setTextDatum(TL_DATUM);
    tft.setTextColor(TFT_WHITE, C_BG);
    tft.setTextWrap(false, false);
    tft.drawString(d, SPR_X - scrollX, SPR_Y + 2);
    if (titleW > SPR_W) tft.drawString(d, SPR_X - scrollX + titleW + SCROLL_GAP, SPR_Y + 2);
    tft.setTextWrap(true, false);
    tft.resetViewport();
    return;
  }
  titleSpr.fillSprite(C_BG);
  titleSpr.setTextColor(TFT_WHITE, C_BG);
  titleSpr.setTextDatum(TL_DATUM);
  titleSpr.drawString(d, -scrollX, 2);
  if (titleW > SPR_W) titleSpr.drawString(d, -scrollX + titleW + SCROLL_GAP, 2);
  titleSpr.pushSprite(SPR_X, SPR_Y);
}

void drawIdle() {
  tft.fillScreen(C_BG);
  drawHeader();
  playing = false;
  drawStatus();
  tft.setTextFont(4);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(C_GREY, C_BG);
  tft.drawString("Hicbir sey calmiyor", 160, 130);
  screenState = 0;
}

// Album kapagi JPEG callback
int jpegDraw(JPEGDRAW *pDraw) {
  tft.pushImage(pDraw->x + ART_X, pDraw->y + ART_Y,
                pDraw->iWidth, pDraw->iHeight, pDraw->pPixels);
  return 1;
}

// JPEGDEC dosya callback'leri (SD uzerinden streaming decode)
int32_t artRead(JPEGFILE *pf, uint8_t *buf, int32_t len) {
  File *f = (File *)pf->fHandle;
  if (!f || len <= 0) return 0;
  int32_t n = (int32_t)f->read(buf, (size_t)len);
  if (n < 0) n = 0;
  pf->iPos += n;
  return n;
}

int32_t artSeek(JPEGFILE *pf, int32_t pos) {
  File *f = (File *)pf->fHandle;
  if (pos < 0) pos = 0;
  else if (pf->iSize > 0 && pos >= pf->iSize) pos = pf->iSize - 1;
  if (f) f->seek((uint32_t)pos);
  pf->iPos = pos;
  return pos;
}

void artClose(void *h) {
  File *f = (File *)h;
  if (f) f->close();
}

bool showAlbumArt(const String &url) {
  tft.fillRect(ART_X, ART_Y, ART_W, ART_H, C_BAR_BG);
  if (url.isEmpty() || !sdOk) return false;

  int off = 0;
  bool wfail = false;
  {
    WiFiClientSecure client;
    client.setInsecure();
    HTTPClient http;
    http.setTimeout(12000);
    http.useHTTP10(true);
    http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
    http.begin(client, url);
    int code = http.GET();
    if (code != 200) { http.end(); return false; }

    int len = http.getSize();
    if (len > 400000) { http.end(); return false; }
    if (len <= 0) len = -1;

    if (SD.exists("/art.tmp")) SD.remove("/art.tmp");
    File f = SD.open("/art.tmp", FILE_WRITE);
    if (!f) { http.end(); return false; }

    WiFiClient *stream = http.getStreamPtr();
    uint8_t tmp[512];
    uint32_t t0 = millis();
    while (millis() - t0 < 12000) {
      size_t avail = stream->available();
      if (avail) {
        if (len > 0 && off + (int)avail > len) avail = len - off;
        if (avail == 0) break;
        size_t want = avail > sizeof(tmp) ? sizeof(tmp) : avail;
        int n = stream->read(tmp, want);
        if (n <= 0) break;
        if (f.write(tmp, n) != (size_t)n) { wfail = true; break; }
        off += n;
        if (len > 0 && off >= len) break;
      } else {
        if (off > 1000 && !http.connected()) break;
        if (!http.connected() && off == 0) break;
        delay(1);
      }
    }
    http.end();
    f.close();
  }

  if (wfail || off < 100) { SD.remove("/art.tmp"); return false; }

  File f = SD.open("/art.tmp", FILE_READ);
  if (!f) return false;
  uint8_t magic[2] = {0, 0};
  size_t mread = f.read(magic, 2);
  f.seek(0);
  if (mread < 2 || magic[0] != 0xFF || magic[1] != 0xD8) {
    f.close();
    SD.remove("/art.tmp");
    return false;
  }

  bool ok = false;
  int fsize = f.size();
  if (jpeg.open((void *)&f, fsize, artClose, artRead, artSeek, jpegDraw) == 1) {
    jpeg.setPixelType(RGB565_BIG_ENDIAN);
    tft.setSwapBytes(false);
    int w = jpeg.getWidth(), h = jpeg.getHeight();
    int sc = JPEG_SCALE_HALF;
    if (w <= ART_W && h <= ART_H) sc = 0;
    else if (w > ART_W * 3 || h > ART_H * 3) sc = JPEG_SCALE_QUARTER;
    ok = jpeg.decode(0, 0, sc) == 1;
    jpeg.close();
  } else {
    f.close();
  }
  SD.remove("/art.tmp");
  return ok;
}

void drawTrackScreen() {
  applyJPFont();
  tft.fillScreen(C_BG);
  drawHeader();
  drawStatus();

  if (artUrl != lastArtUrl) {
    if (showAlbumArt(artUrl) || artUrl.isEmpty()) lastArtUrl = artUrl;
  }

  if (jpFontLoaded) {
    titleW = tft.textWidth(title);
  } else {
    titleSpr.setTextFont(4);
    titleW = titleSpr.textWidth(dispStr(title));
  }
  scrollX = 0;
  lastScroll = millis();
  drawTitleFrame();

  tft.setTextDatum(TL_DATUM);
  tft.setTextColor(C_GREEN, C_BG);
  tft.setTextFont(4);
  tft.drawString(fitText(artist, SPR_W, 4), TXT_X, 90);
  tft.setTextColor(C_GREY, C_BG);
  tft.setTextFont(2);
  tft.drawString(fitText(album, SPR_W, 2), TXT_X, 124);

  drawCtrlBtns();
  tft.fillRoundRect(BAR_X, BAR_Y, BAR_W, BAR_H, 4, C_BAR_BG);

  screenState = 1;
  lastSec = -1;
  lastBarW = -1;
}

// Cubuk + zaman yazilari (p: gosterilecek konum)
void drawProgressFrame(uint32_t p, bool force) {
  int sec = p / 1000;
  if (force || sec != lastSec) {
    lastSec = sec;
    tft.setTextDatum(TL_DATUM);
    tft.setTextFont(4);
    tft.setTextPadding(110);
    tft.setTextColor(TFT_WHITE, C_BG);
    tft.drawString(fmtTime(p), BAR_X, TIME_Y);

    tft.setTextDatum(TR_DATUM);
    tft.setTextColor(C_GREY, C_BG);
    tft.drawString(fmtTime(durationMs), BAR_X + BAR_W, TIME_Y);

    if (screenState == 1) {
      uint32_t remain = durationMs > p ? durationMs - p : 0;
      tft.setTextDatum(TC_DATUM);
      tft.setTextFont(2);
      tft.setTextPadding(70);
      tft.drawString("-" + fmtTime(remain), 160, TIME_Y + 6);
    }
    tft.setTextPadding(0);
  }

  int w = durationMs ? (int)((uint64_t)p * BAR_W / durationMs) : 0;
  if (w > BAR_W) w = BAR_W;
  if (force || w != lastBarW) {
    tft.fillRoundRect(BAR_X, BAR_Y, BAR_W, BAR_H, 4, C_BAR_BG);
    if (w > 0) tft.fillRoundRect(BAR_X, BAR_Y, max(w, 6), BAR_H, 4, C_GREEN);
    lastBarW = w;
  }
}

void updateProgressUI(bool force) {
  if (seekActive) return;
  if (screenState != 1 && screenState != 2) return;

  drawProgressFrame(currentProgress(), force);

  if (lastShownPlaying != playing) {
    drawStatus();
    drawCtrlBtns();
  }
}

// ---------- Ileri sarma (cubuga dokun/surukle) ----------
uint32_t seekXToMs(int px) {
  if (px < BAR_X) px = BAR_X;
  if (px > BAR_X + BAR_W) px = BAR_X + BAR_W;
  return (uint32_t)((uint64_t)(px - BAR_X) * durationMs / BAR_W);
}

void handleSeekTouch(int sx) {
  if (durationMs == 0) return;
  seekActive = true;
  uint32_t startMs = currentProgress();
  uint32_t t0 = millis();
  seekMs = seekXToMs(sx);
  drawProgressFrame(seekMs, true);

  // Parmak kalkana kadar takip et. Tek tukenen okumalar (temas gurultusu)
  // dokunmayi bitirmesin: 3 ardisik bos okuma gerekir.
  uint16_t rx, ry;
  int misses = 0;
  while (true) {
    if (touchRaw(&rx, &ry)) {
      misses = 0;
      int tx, ty;
      if (touchToScreen(rx, ry, &tx, &ty)) {
        seekMs = seekXToMs(tx);
        drawProgressFrame(seekMs, true);
      }
    } else if (++misses >= 3) {
      break;
    }
    delay(15);
  }

  seekActive = false;

  // Cok kisa/gurultulu dokunuslari yoksay (yanlislikla sarkiyi atlatmasin)
  if (millis() - t0 < 120) {
    updateProgressUI(true);
    return;
  }

  char path[40];
  snprintf(path, sizeof(path), "seek?position_ms=%u", (unsigned)seekMs);
  if (spotifyCommand(path)) {
    progressMs = seekMs;
    fetchedAt = millis();
    lastPoll = millis();
  }
  lastLyricIdx = -1;
  updateProgressUI(true);
  delay(20);
}

// ---------- Touch butonlari ----------
void doCtrl(int idx) {
  if (idx == 0)      spotifyCommand("previous");
  else if (idx == 1) spotifyCommand(playing ? "pause" : "play");
  else               spotifyCommand("next");
}

bool inCtrlZone(int sx, int sy) {
  return sy >= CTRL_Y - 15 && sy < BAR_Y - 10 &&
         sx >= TXT_X - 10 && sx < TXT_X + 3 * CTRL_W + 2 * CTRL_GAP + 10;
}

void checkTouch() {
  static uint32_t lastCheck = 0;
  uint32_t now = millis();
  if (now - lastCheck < 25) return;
  lastCheck = now;

  uint16_t rx, ry;
  if (!touchRaw(&rx, &ry)) return;

  int sx, sy;
  if (!touchToScreen(rx, ry, &sx, &sy)) return;

  if (screenState == 3 || screenState == 4) {
    handleBrowserTouch(sx, sy);
    delay(200);
    while (touchRaw(&rx, &ry)) delay(30);
    return;
  }
  if (screenState == 0) {
    if (sy < 34 && sx >= 92 && sx <= 160) {
      openBrowser();
      delay(250);
      while (touchRaw(&rx, &ry)) delay(30);
    }
    if (sy < 34 && sx >= 162 && sx <= 178) {
      if (spotifySetVolume(volumePercent - 10)) { drawHeader(); drawStatus(); }
      delay(200);
      while (touchRaw(&rx, &ry)) delay(30);
    }
    if (sy < 34 && sx >= 180 && sx <= 196) {
      if (spotifySetVolume(volumePercent + 10)) { drawHeader(); drawStatus(); }
      delay(200);
      while (touchRaw(&rx, &ry)) delay(30);
    }
    return;
  }
  if (screenState != 1 && screenState != 2) return;

  // 0) Ust bardaki LISTE butonu: playlist/sarki sec
  if (sy < 34 && sx >= 92 && sx <= 160) {
    openBrowser();
    delay(250);
    while (touchRaw(&rx, &ry)) delay(30);
    return;
  }
  // Ses seviyesi butonlari
  if (sy < 34 && sx >= 162 && sx <= 178) {
    if (spotifySetVolume(volumePercent - 10)) { drawHeader(); drawStatus(); }
    delay(200);
    while (touchRaw(&rx, &ry)) delay(30);
    return;
  }
  if (sy < 34 && sx >= 180 && sx <= 196) {
    if (spotifySetVolume(volumePercent + 10)) { drawHeader(); drawStatus(); }
    delay(200);
    while (touchRaw(&rx, &ry)) delay(30);
    return;
  }

  // 1) Zaman cubugu: surukleyerek ileri/geri sar
  if (sy >= BAR_Y - 8 && sy <= BAR_Y + BAR_H + 16 &&
      sx >= BAR_X - 10 && sx <= BAR_X + BAR_W + 10) {
    if (durationMs > 0) {
      handleSeekTouch(sx);
      // parmagin tamamen kalkmasini bekle (tek temas = tek seek)
      uint32_t rel = millis();
      while (touchRaw(&rx, &ry) && millis() - rel < 1200) { rel = millis(); delay(20); }
      delay(120);
      return;
    }
  }

  // 2) Kontrol butonlari
  if (inCtrlZone(sx, sy)) {
    int idx = (sx - TXT_X) / (CTRL_W + CTRL_GAP);
    if (idx < 0) idx = 0;
    if (idx > 2) idx = 2;
    doCtrl(idx);
    lastPoll = 0;
    delay(250);
    while (touchRaw(&rx, &ry)) delay(30);
    return;
  }

  // 3) Soz modu: ust alana dokun -> normal mod
  if (screenState == 2) {
    if (sy < CTRL_Y - 15) {
      toggleLyricsMode();
      delay(250);
      while (touchRaw(&rx, &ry)) delay(30);
    }
    return;
  }

  // 4) Normal mod: kapak/baslik alanina dokun -> soz modu
  if (sy >= ART_Y - 5 && sy < CTRL_Y - 15 && sx >= ART_X - 5 && sx < TXT_X + 150) {
    toggleLyricsMode();
    delay(250);
    while (touchRaw(&rx, &ry)) delay(30);
  }
}

// ---------- Spotify ----------
bool refreshAccessToken() {
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.begin(client, "https://accounts.spotify.com/api/token");
  http.addHeader("Content-Type", "application/x-www-form-urlencoded");
  http.addHeader("Authorization", "Basic " + base64::encode(String(CLIENT_ID) + ":" + CLIENT_SECRET));
  int code = http.POST("grant_type=refresh_token&refresh_token=" + String(REFRESH_TOKEN));
  if (code != 200) {
    Serial.printf("Token hatasi: %d\n", code);
    http.end();
    return false;
  }
  JsonDocument doc;
  deserializeJson(doc, http.getString());
  http.end();
  accessToken = doc["access_token"].as<String>();
  uint32_t exp = doc["expires_in"] | 3600;
  tokenExpiresAt = millis() + (exp - 60) * 1000UL;
  return accessToken.length() > 0;
}

void pollSpotify() {
  if (WiFi.status() != WL_CONNECTED) return;
  if (accessToken.isEmpty() || (int32_t)(millis() - tokenExpiresAt) > 0) {
    if (!refreshAccessToken()) return;
  }

  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(8000);
  http.begin(client, "https://api.spotify.com/v1/me/player/currently-playing?additional_types=episode");
  http.addHeader("Authorization", "Bearer " + accessToken);

  uint32_t t0 = millis();
  int code = http.GET();
  pollInterval = POLL_MS;

  if (code == 200) {
    String body = http.getString();
    uint32_t t1 = millis();

    JsonDocument filter;
    filter["is_playing"] = true;
    filter["progress_ms"] = true;
    filter["item"]["id"] = true;
    filter["item"]["name"] = true;
    filter["item"]["duration_ms"] = true;
    filter["item"]["artists"][0]["name"] = true;
    filter["item"]["album"]["name"] = true;
    filter["item"]["album"]["images"] = true;
    filter["item"]["images"] = true;
    filter["item"]["show"]["name"] = true;
    filter["item"]["show"]["images"] = true;
    filter["device"]["volume_percent"] = true;

    JsonDocument doc;
    DeserializationError err = deserializeJson(doc, body, DeserializationOption::Filter(filter));
    if (!err) {
      playing = doc["is_playing"] | false;
      progressMs = doc["progress_ms"] | 0;
      fetchedAt = (t0 + t1) / 2;
      JsonObject dev = doc["device"];
      if (!dev.isNull()) {
        int v = dev["volume_percent"] | -1;
        if (v >= 0) volumePercent = v;
      }

      JsonObject item = doc["item"];
      String newId, newTitle, newArtist, newAlbum, newArt;
      if (item.isNull()) {
        newId = "none"; newTitle = "Reklam / Bilinmiyor";
        durationMs = 0;
      } else {
        newId = item["id"] | "";
        newTitle = item["name"] | "?";
        durationMs = item["duration_ms"] | 0;
        JsonArray arts = item["artists"];
        for (JsonVariant a : arts) {
          if (newArtist.length()) newArtist += ", ";
          newArtist += a["name"] | "";
        }
        if (newArtist.isEmpty()) newArtist = item["show"]["name"] | "";
        newAlbum = item["album"]["name"] | "";

        JsonArray images = item["album"]["images"];
        if (images.size() == 0) images = item["images"];
        if (images.size() == 0) images = item["show"]["images"];
        String bestUrl;
        int bestScore = 9999;
        for (JsonVariant iv : images) {
          String u = iv["url"] | "";
          int w = iv["width"] | 0;
          if (u.isEmpty()) continue;
          int score;
          if (w == 300) score = 0;
          else if (w == 64) score = 1;
          else if (w >= 200 && w <= 400) score = 2;
          else if (w >= 600 && w <= 700) score = 3;
          else score = 4;
          if (score < bestScore) { bestScore = score; bestUrl = u; }
        }
        newArt = bestUrl;
      }

      bool idChanged = newId != trackId;
      bool changed = !havePlayer || idChanged ||
                     (!lyricsMode && screenState != 1 && screenState != 3 && screenState != 4);
      havePlayer = true;
      if (changed) {
        trackId = newId; title = newTitle; artist = newArtist; album = newAlbum;
        artUrl = newArt;
        if (idChanged) {
          clearLyrics();
          lastArtUrl = "";
        }
        needRedraw = true;
      } else {
        updateProgressUI(true);
      }
    } else {
      Serial.println("JSON hatasi");
    }
  } else if (code == 204) {
    havePlayer = false;
    if (screenState != 0) { trackId = ""; drawIdle(); }
  } else if (code == 401) {
    accessToken = "";
    pollInterval = 500;
  } else if (code == 429) {
    pollInterval = 30000;
  } else {
    Serial.printf("Spotify HTTP %d\n", code);
  }
  http.end();
}

// ---------- WiFi menu ----------
bool waitPress(int *sx, int *sy, uint32_t timeoutMs) {
  uint32_t start = millis();
  while (millis() - start < timeoutMs) {
    uint16_t rx, ry;
    if (touchRaw(&rx, &ry) && touchToScreen(rx, ry, sx, sy)) return true;
    delay(3);
  }
  return false;
}

void waitRelease() {
  uint16_t rx, ry;
  while (touchRaw(&rx, &ry)) delay(10);
  delay(20);
}

void loadWifi(String *ssid, String *pass) {
  Preferences p;
  if (!p.begin("wificfg", true)) { ssid->clear(); pass->clear(); return; }
  *ssid = p.getString("ssid", "");
  *pass = p.getString("pass", "");
  p.end();
}

void saveWifi(const String &ssid, const String &pass) {
  Preferences p;
  p.begin("wificfg", false);
  p.putString("ssid", ssid);
  p.putString("pass", pass);
  p.end();
  Serial.println("WiFi saved");
}

void drawBars(int x, int y, int rssi) {
  int lvl;
  if (rssi > -55) lvl = 4; else if (rssi > -66) lvl = 3;
  else if (rssi > -77) lvl = 2; else if (rssi > -88) lvl = 1; else lvl = 0;
  for (int i = 0; i < 4; i++) {
    int h = 3 + i * 3;
    uint16_t c = i < lvl ? C_GREEN : C_BAR_BG;
    tft.fillRect(x + i * 5, y + (12 - h), 4, h, c);
  }
}

void drawLock(int x, int y) {
  tft.drawRect(x, y + 4, 8, 6, C_GREY);
  tft.drawLine(x + 1, y + 4, x + 1, y + 2, C_GREY);
  tft.drawLine(x + 1, y + 2, x + 6, y + 2, C_GREY);
  tft.drawLine(x + 6, y + 2, x + 6, y + 4, C_GREY);
}

void drawNetList() {
  tft.fillScreen(C_BG);
  tft.setTextFont(4);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(TFT_WHITE, C_BG);
  tft.drawString("WiFi Ag Sec", 160, 16);

  if (g_netScroll > g_netCount - NET_VISIBLE) g_netScroll = max(0, g_netCount - NET_VISIBLE);
  if (g_netScroll < 0) g_netScroll = 0;

  for (int i = 0; i < NET_VISIBLE && i + g_netScroll < g_netCount; i++) {
    int y = 34 + i * 32;
    WifiNet &n = g_nets[i + g_netScroll];
    tft.fillRoundRect(8, y, 284, 28, 3, C_BTN);
    if (n.enc) drawLock(14, y + 8);
    tft.setTextFont(2);
    tft.setTextDatum(ML_DATUM);
    tft.setTextColor(TFT_WHITE, C_BTN);
    String s = n.ssid;
    if (s.length() > 22) s = s.substring(0, 21) + "..";
    tft.drawString(s, n.enc ? 30 : 18, y + 14);
    drawBars(260, y + 8, n.rssi);
  }

  if (g_netCount > NET_VISIBLE) {
    tft.fillTriangle(304, 40, 298, 54, 310, 54, g_netScroll > 0 ? C_GREEN : C_BAR_BG);
    tft.fillTriangle(304, 188, 298, 174, 310, 174,
                     (g_netScroll + NET_VISIBLE < g_netCount) ? C_GREEN : C_BAR_BG);
  }

  tft.fillRoundRect(8, 200, 304, 34, 3, C_BTN);
  tft.setTextFont(2);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(TFT_WHITE, C_BTN);
  tft.drawString("Yenile", 160, 217);
}

bool wifiScan() {
  Serial.println("WiFi scan...");
  WiFi.disconnect();
  delay(100);
  int n = WiFi.scanNetworks();
  g_netCount = 0;
  for (int i = 0; i < n && g_netCount < MAX_NETS; i++) {
    String s = WiFi.SSID(i);
    if (s.length() == 0) continue;
    bool enc = WiFi.encryptionType(i) != WIFI_AUTH_OPEN;
    int rssi = WiFi.RSSI(i);
    int found = -1;
    for (int j = 0; j < g_netCount; j++)
      if (g_nets[j].ssid == s) { found = j; break; }
    if (found >= 0) {
      if (rssi > g_nets[found].rssi) { g_nets[found].rssi = rssi; g_nets[found].enc = enc; }
      continue;
    }
    g_nets[g_netCount].ssid = s;
    g_nets[g_netCount].rssi = rssi;
    g_nets[g_netCount].enc = enc;
    g_netCount++;
  }
  for (int i = 1; i < g_netCount; i++) {
    WifiNet k = g_nets[i];
    int j = i - 1;
    while (j >= 0 && g_nets[j].rssi < k.rssi) { g_nets[j + 1] = g_nets[j]; j--; }
    g_nets[j + 1] = k;
  }
  WiFi.scanDelete();
  g_netScroll = 0;
  Serial.printf("Found %d nets\n", g_netCount);
  return g_netCount > 0;
}

const char* KB_L0 = "1234567890";
const char* KB_L1 = "qwertyuiop";
const char* KB_L2 = "asdfghjkl";
const char* KB_S0 = "!@#$%^&*()";
const char* KB_S1 = "-_+={}[]\\|";
const char* KB_S2 = "`~;:'\",.<>";
const char* KB_L3 = "zxcvbnm";

void kbAdd(char c) { if (g_kbInput.length() < 64) g_kbInput += c; }

void drawKey(int x, int y, int w, int h, const char* label, uint16_t bg, uint16_t fg = TFT_WHITE) {
  tft.fillRoundRect(x, y, w, h, 3, bg);
  if (label[0]) {
    tft.setTextFont(2);
    tft.setTextDatum(MC_DATUM);
    tft.setTextColor(fg, bg);
    tft.drawString(label, x + w / 2, y + h / 2);
  }
}

void drawKeyboard() {
  tft.fillScreen(C_BG);
  tft.fillRoundRect(4, 4, 200, 28, 3, C_DARK);
  tft.setTextFont(2);
  tft.setTextDatum(TL_DATUM);
  tft.setTextColor(TFT_WHITE, C_DARK);
  String disp;
  unsigned maxd = 22;
  unsigned startd = g_kbInput.length() > maxd ? g_kbInput.length() - maxd : 0;
  for (unsigned i = startd; i < g_kbInput.length(); i++) disp += '*';
  disp += '_';
  tft.drawString(disp, 10, 10);
  tft.setTextDatum(TR_DATUM);
  tft.setTextColor(C_GREY, C_BG);
  String s = g_ssid;
  if (s.length() > 16) s = s.substring(0, 15) + "..";
  tft.drawString(s, 314, 10);

  const int y0 = 36, y1 = 70, y2 = 104, y3 = 138, kh = 30, kw = 30, xs = 32, x0 = 1;
  const char **rows = g_kbSym ? (const char**)(const char*[]){KB_S0, KB_S1, KB_S2}
                              : (const char**)(const char*[]){KB_L0, KB_L1, KB_L2};
  int ys[3] = {y0, y1, y2};
  for (int r = 0; r < 3; r++) {
    for (int c = 0; c < 9; c++) {
      char ch = rows[r][c];
      char lb[2] = {ch, 0};
      if (g_kbShift && !g_kbSym && ch >= 'a' && ch <= 'z') lb[0] = ch - 32;
      drawKey(x0 + c * xs, ys[r], kw, kh, lb, C_BTN);
    }
    drawKey(x0 + 9 * xs, ys[r], kw, kh, "<", C_DARK);
  }

  if (!g_kbSym) {
    drawKey(x0, y3, kw, kh, g_kbShift ? "A" : "^", C_DARK);
    for (int c = 0; c < 7; c++) {
      char ch = KB_L3[c];
      char lb[2] = {ch, 0};
      if (g_kbShift) lb[0] = ch - 32;
      drawKey(x0 + (c + 1) * xs, y3, kw, kh, lb, C_BTN);
    }
    drawKey(x0 + 8 * xs, y3, kw, kh, ",", C_BTN);
    drawKey(x0 + 9 * xs, y3, kw, kh, ".", C_BTN);
  } else {
    tft.fillRoundRect(x0, y3, 10 * xs - 2, kh, 3, C_BG);
  }

  drawKey(4, 172, 48, 30, g_kbSym ? "ABC" : "123", C_BTN);
  drawKey(56, 172, 140, 30, "", C_DARK);
  drawKey(200, 172, 48, 30, "X", C_BTN);
  drawKey(252, 172, 64, 30, "OK", C_GREEN, TFT_BLACK);

  tft.setTextFont(2);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(C_GREY, C_BG);
  tft.drawString("Iptal", 224, 222);
}

// returns: 0 none, 2 backspace, 3 ok, 4 cancel, 5 page, 6 shift (char added otherwise)
int kbHit(int sx, int sy) {
  const int kw = 30, xs = 32, x0 = 1;
  const int ys[4] = {36, 70, 104, 138};
  for (int r = 0; r < 3; r++) {
    if (sy >= ys[r] && sy < ys[r] + 30) {
      if (sx < x0 || sx >= x0 + 10 * xs) return 0;
      int c = (sx - x0) / xs;
      if (c == 9) return 2;
      const char *row = g_kbSym ? (r == 0 ? KB_S0 : r == 1 ? KB_S1 : KB_S2)
                                : (r == 0 ? KB_L0 : r == 1 ? KB_L1 : KB_L2);
      char ch = row[c];
      if (g_kbShift && !g_kbSym && ch >= 'a' && ch <= 'z') ch -= 32;
      kbAdd(ch);
      return 1;
    }
  }
  if (sy >= 138 && sy < 168) {
    if (g_kbSym || sx < x0 || sx >= x0 + 10 * xs) return 0;
    int c = (sx - x0) / xs;
    if (c == 0) { g_kbShift = !g_kbShift; return 6; }
    if (c >= 1 && c <= 7) { char ch = KB_L3[c - 1]; if (g_kbShift) ch -= 32; kbAdd(ch); return 1; }
    if (c == 8) { kbAdd(','); return 1; }
    if (c == 9) { kbAdd('.'); return 1; }
  }
  if (sy >= 172 && sy < 204) {
    if (sx >= 4 && sx < 52) { g_kbSym = !g_kbSym; g_kbShift = false; return 5; }
    if (sx >= 56 && sx < 196) { kbAdd(' '); return 1; }
    if (sx >= 200 && sx < 248) return 4;
    if (sx >= 252 && sx < 316) return 3;
  }
  return 0;
}

bool wifiKeyboard(const String &ssid, bool enc) {
  g_ssid = ssid;
  g_enc = enc;
  g_kbInput = "";
  g_kbShift = false;
  g_kbSym = false;
  if (!enc) { g_pass = ""; return true; }
  while (true) {
    drawKeyboard();
    int sx, sy;
    if (!waitPress(&sx, &sy, 60000)) continue;
    int act = kbHit(sx, sy);
    waitRelease();
    if (act == 3) {
      if (g_kbInput.length() == 0) continue;
      g_pass = g_kbInput;
      return true;
    }
    if (act == 4) return false;
    if (act != 0) drawKeyboard();
  }
}

bool tryConnect(const String &ssid, const String &pass, uint32_t timeoutMs, bool showUi) {
  Serial.printf("WiFi connect: '%s'\n", ssid.c_str());
  if (showUi) {
    tft.fillScreen(C_BG);
    tft.setTextFont(4);
    tft.setTextDatum(MC_DATUM);
    tft.setTextColor(TFT_WHITE, C_BG);
    tft.drawString("Baglaniyor:", 160, 90);
    tft.setTextColor(C_GREEN, C_BG);
    String s = ssid;
    if (s.length() > 20) s = s.substring(0, 19) + "..";
    tft.drawString(s, 160, 116);
    tft.setTextColor(C_GREY, C_BG);
    tft.setTextFont(2);
    tft.drawString("(Dokun: ag sec)", 160, 150);
  }
  WiFi.disconnect();
  WiFi.begin(ssid.c_str(), pass.c_str());
  uint32_t start = millis();
  int sx, sy;
  while (WiFi.status() != WL_CONNECTED && millis() - start < timeoutMs) {
    if (showUi && waitPress(&sx, &sy, 10)) { waitRelease(); return false; }
    delay(50);
  }
  bool ok = WiFi.status() == WL_CONNECTED;
  if (ok) Serial.printf("WiFi ok: %s\n", WiFi.localIP().toString().c_str());
  else Serial.println("WiFi fail");
  return ok;
}

void wifiMenu() {
  bool needScan = true;
  while (WiFi.status() != WL_CONNECTED) {
    if (needScan) {
      tft.fillScreen(C_BG);
      tft.setTextFont(4);
      tft.setTextDatum(MC_DATUM);
      tft.setTextColor(TFT_WHITE, C_BG);
      tft.drawString("Taraniyor...", 160, 120);
      wifiScan();
      drawNetList();
      needScan = false;
    }
    int sx, sy;
    if (!waitPress(&sx, &sy, 100)) continue;
    if (sx >= 294 && g_netCount > NET_VISIBLE) {
      if (sy < 34 + 78) g_netScroll--; else g_netScroll++;
      if (g_netScroll > g_netCount - NET_VISIBLE) g_netScroll = max(0, g_netCount - NET_VISIBLE);
      if (g_netScroll < 0) g_netScroll = 0;
      drawNetList();
      waitRelease();
      continue;
    }
    if (sx >= 8 && sx < 292 && sy >= 34 && sy < 34 + NET_VISIBLE * 32) {
      int idx = (sy - 34) / 32 + g_netScroll;
      if (idx < g_netCount) {
        waitRelease();
        if (wifiKeyboard(g_nets[idx].ssid, g_nets[idx].enc)) {
          if (tryConnect(g_ssid, g_pass, 20000, true)) {
            saveWifi(g_ssid, g_pass);
            return;
          }
          tft.fillScreen(C_BG);
          tft.setTextFont(4);
          tft.setTextDatum(MC_DATUM);
          tft.setTextColor(TFT_RED, C_BG);
          tft.drawString("Baglanamadi", 160, 120);
          delay(1500);
        }
        drawNetList();
      }
      continue;
    }
    if (sy >= 200 && sy < 236 && sx >= 8 && sx < 312) {
      needScan = true;
      waitRelease();
      continue;
    }
    waitRelease();
  }
}

// ---------- WiFi ----------
void connectWiFi() {
  WiFi.mode(WIFI_STA);
  String s, p;
  loadWifi(&s, &p);
  if (s.length() > 0) {
    if (tryConnect(s, p, 20000, true)) return;
  } else if (WIFI_SSID[0]) {
    if (tryConnect(WIFI_SSID, WIFI_PASS, 20000, true)) {
      saveWifi(WIFI_SSID, WIFI_PASS);
      return;
    }
  }
  wifiMenu();
}

void setup() {
  Serial.begin(115200);
  pinMode(TFT_BL_PIN, OUTPUT);
  digitalWrite(TFT_BL_PIN, HIGH);

  tft.init();
  tft.setRotation(1);
  tft.invertDisplay(false);

  C_BG     = tft.color565(16, 16, 18);
  C_GREEN  = tft.color565(29, 185, 84);
  C_GREY   = tft.color565(150, 150, 150);
  C_DARK   = tft.color565(30, 30, 33);
  C_BAR_BG = tft.color565(55, 55, 58);
  C_BTN    = tft.color565(45, 45, 48);
  tft.fillScreen(C_BG);

  titleSpr.setColorDepth(16);
  titleSpr.createSprite(SPR_W, SPR_H);
  titleSpr.setTextFont(4);

  touchSPI.begin(T_CLK, T_MISO, T_MOSI, T_CS);
  pinMode(T_IRQ, INPUT_PULLUP);
  pinMode(T_CS, OUTPUT);
  digitalWrite(T_CS, HIGH);

  sdSPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
  if (SD.begin(SD_CS, sdSPI)) {
    sdOk = true;
    Serial.printf("SD ok: %llu MB\n", SD.cardSize() / (1024 * 1024));
    if (SD.exists("/jp16.vlw")) {
      File ff = SD.open("/jp16.vlw", FILE_READ);
      if (ff && ff.size() > 100000) {
        jpFontAvailable = true;
        Serial.printf("jp16.vlw found (%u bytes)\n", (unsigned)ff.size());
      }
      if (ff) ff.close();
    }
  } else {
    Serial.println("SD not found");
  }
  Serial.printf("Free heap at boot: %u\n", ESP.getFreeHeap());

  connectWiFi();
  drawIdle();
  pollSpotify();
  lastPoll = millis();
}

void loop() {
  uint32_t now = millis();

  if (WiFi.status() != WL_CONNECTED) {
    WiFi.reconnect();
    delay(500);
    return;
  }

  checkTouch();

  if (lyricsMode && havePlayer && !trackId.isEmpty() && !lyricsAttempted) {
    if (fetchLyrics()) {
      drawLyricsView();
    } else {
      drawLyricsView();
    }
  }

  bool ended = havePlayer && playing && durationMs > 0 && currentProgress() >= durationMs;
  if (now - lastPoll >= pollInterval || (ended && now - lastPoll >= 1000)) {
    pollSpotify();
    lastPoll = millis();
  }

  if (needRedraw) {
    needRedraw = false;
    if (screenState == 2) drawLyricsView();
    else if (screenState == 1) drawTrackScreen();
    else if (screenState == 0) {
      if (havePlayer && !trackId.isEmpty()) drawTrackScreen();
      else drawIdle();
    }
    if (screenState == 1 || screenState == 2) updateProgressUI(true);
  }

  if (now - lastUi >= 100) {
    lastUi = now;
    updateProgressUI(false);
  }

  // Sozler: 50 ms'de bir kayan senkron guncelleme
  if (screenState == 2 && now - lastLyricUi >= 50) {
    lastLyricUi = now;
    updateLyricsUI();
  }

  if (screenState == 1 && titleW > SPR_W && now - lastScroll >= (scrollX == 0 ? 1500UL : 35UL)) {
    lastScroll = now;
    scrollX += 2;
    if (scrollX >= titleW + SCROLL_GAP) scrollX = 0;
    drawTitleFrame();
  }
}
