<!-- SOLID CITY · part 2860 -->
on () { return list.slice(); }, sync: function () { apply(); }, poll: poll, sid: function () { return sid; }, controlled: function () { return Object.keys(ctl); }, lastOk: function () { return lastOk; } };
})();


===== FILE: public/city/player.js =====
/* SOLID CITY — the King's music player (SolidBigIsland, @solidbigisland on TikTok)
 * Built on the same approach as /newworld radio.js (Soul Print Radio): songs.json
 * catalogue + /SolidBigIsland/mp3/<file> on one HTML5 <audio>, with duck/unduck
 * for voice. Added here: lazy list via /api/city/songs (covers admin-only by
 * default), shuffle/repeat/seek/search/filters/lyrics, next-track-only preload,
 * localStorage resume, Media Session (lock-screen controls), cockpit skin, and hooks
 * for the city (tower billboard, radio-tower boost).
 * 2026-10-09 BACKGROUND PLAY: songs play ONLY through the plain <audio> element (never
 * routed into an AudioContext — iOS suspends AudioContext when the screen locks or the
 * app is switched, which silenced the music). Hiding the page never pauses the music;
 * 'ended' auto-advances while hidden; the next track is warmed in the HTTP cache early.
 * Volume = element.volume; iOS makes that read-only (hardware buttons rule there), so
 * on iPhone the slider/duck/boost are no-ops and the slider shows a hint instead.
 */
window.CityPlayer = (function () {
  'use strict';
  var LS = 'sc.player.v1', MP3 = '/SolidBigIsland/mp3/', THUMB = '/SolidBigIsland/';
  var KIND = { lyric: 'LYRIC SONG', cover: 'COVER', instrumental: 'INSTRUMENTAL' };
  // Shuffle is the DEFAULT (first visit: random first song). The toggle choice is remembered.
  var st = { n: 0, t: 0, vol: 0.8, shuffle: true, repeat: 'all', paused: false, played: [] };
  try { var sv = JSON.parse(localStorage.getItem(LS) || 'null'); if (sv) for (var k in sv) st[k] = sv[k]; } catch (e) {}
  function save() { try { st.t = Math.floor(audio.currentTime || st.t || 0); localStorage.setItem(LS, JSON.stringify(st)); } catch (e) {} }

  var audio = new Audio(); audio.preload = 'auto'; audio.setAttribute('playsinline', ''); audio.setAttribute('webkit-playsinline', '');
  audio.setAttribute('x-webkit-airplay', 'allow'); audio.id = 'sc-music'; audio.hidden = true;
  (document.body || document.documentElement).appendChild(audio);   // a real element in the DOM: iOS treats it as the page's media session
  var pre = new Audio(); pre.preload = 'auto'; pre.muted = true;  // next track only (warms the HTTP cache; never played)
  var boost = 1, duckF = 1, data = null, byN = {}, queue = [], qi = -1, cur = null;
  var filter = 'all', query = '', listeners = [], preloadedFor = null, loading = null, wantPlay = false, seeking = false;

  /* ---------- volume: element.volume (no WebAudio, so it keeps playing in the background) ---------- */
  // iOS: element.volume is read-only (always 1) — the side buttons set the level.
  var FIXED_VOL = (function () { try { var a = new Audio(); a.volume = 0.5; return a.volume === 1; } catch (e) { return false; } })();
  function ensureCtx() {}   // kept as a no-op: callers used it to unlock WebAudio; the songs no longer use WebAudio
  function applyVol() { if (FIXED_VOL) return; audio.volume = Math.max(0, Math.min(1, st.vol * 0.85 * boost * duckF)); }
  // Hidden (locked / switched app): keep playing — just save the place. Back: re-sync the UI and,
  // if the system paused us (call, Siri) and the King hadn't paused, try to pick up again.
  document.addEventListener('visibilitychange', function () {
    if (document.hidden) { save(); return; }
    renderNow(); syncSeek(); emit();
    if (cur && audio.src && audio.paused && wantPlay && !st.paused && !audio.ended) { var p = audio.play(); if (p && p.catch) p.catch(function () {}); }
  });
  addEventListener('pagehide', save);
  addEventListener('pageshow', function (e) { if (e.persisted) { renderNow(); syncSeek(); } });

  /* ---------- catalogue (lazy) ---------- */
  function load() {
    if (loading) return loading;
    loading = fetch('/api/city/songs', { credentials: 'same-origin' }).then(function (r) { return r.json(); }).then(function (j) {
      data = j; byN = {}; (j.songs || []).forEach(function (s) { byN[s.n] = s; });
      setQueue(visible()); renderList(); renderFilters();
      cur = byN[st.n] || null; if (!cur) { st.t = 0; cur = queue[0] || null; }
      qi = cur ? queue.indexOf(cur) : -1; renderNow(); emit();
      return j;
    }).catch(function () { loading = null; el.mTitle.textContent = 'Music unavailable — retry'; });
    return loading;
  }
  function visible() {
    var q = query.trim().toLowerCase();
    return (data ? data.songs : []).filter(function (s) { return (filter === 'all' || s.kind === filter) && (!q || s.title.toLowerCase().indexOf(q) >= 0 || String(s.n) === q); });
  }
  function shuffled(a) { a = a.slice(); for (var i = a.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)), t = a[i]; a[i] = a[j]; a[j] = t; } return a; }
  //