PageSourceSearch

https://wcyt.org/playlist-widget.js?v=2

js wcyt.org collected 2026-10-02 23:33:02 UTC 57,472 bytes, 1,266 lines download raw bytes

1/**
2 * WCYT Playlist Widget
3 * Polls the Icecast status endpoint and renders a KEXP-style
4 * now-playing + recent-plays display with integrated audio player.
5 *
6 * To swap in a proxy if CORS fails, change METADATA_URL to your
7 * proxy endpoint (e.g. "https://wcyt.org/nowplaying.php").
8 */
9
10(function () {
11  'use strict';
12
13  // ── Config ────────────────────────────────────────────────────────────────
14  const METADATA_URL  = 'https://securestreams2.autopo.st:1069/status-json.xsl';
15  const POLL_MS       = 10_000;
16  const MAX_HISTORY   = 50;
17  const FALLBACK_ART  = '/images/squarespace/WCYT-removebg-preview.png';
18  const FALLBACK_ART_2 = '/images/shows/2.0 Logo.png';
19
20  // DJ show logos are Drive-hosted (see dj-backend.gs) and can take a little
21  // while to become fetchable right after upload. Retry a few times with
22  // backoff and a cache-busting param before giving up and falling back —
23  // exposed on window since inline onerror= attributes run in global scope.
24  window.wcytHandleArtError = function (img, fallbackSrc) {
25    const tries = (img._artRetries || 0) + 1;
26    img._artRetries = tries;
27    const base = img._artBase || img.src.split('?')[0];
28    img._artBase = base;
29    const isDriveUrl = /\/\/(drive\.google\.com|lh3\.googleusercontent\.com)\//.test(base);
30    if (isDriveUrl && tries <= 6) {
31      const delay = Math.min(1000 * 2 ** (tries - 1), 15000); // 1s,2s,4s,8s,15s,15s
32      setTimeout(() => {
33        img.src = base + (base.includes('?') ? '&' : '?') + '_r=' + Date.now();
34      }, delay);
35    } else {
36      img.src = fallbackSrc;
37      img.classList.add('wcyt-art-fallback');
38    }
39  };
40  const SHOW_URL      = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vRvbq5nlJGzIblU91RLbcNBwChU9jE28xlwM537tunzMWb3hWyHmnuojMZAjKqNdSP8mmoDXdzp4U0a/pub?output=csv';
41  const SHOW_TTL_MS   = 45 * 60 * 1000; // auto-clear after 45 minutes
42
43  // ── Now-playing JSONs (written by bsi_watcher.py at the station) ──────────
44  // Each has { now, comingUp, recent } — the watcher maintains the rolling
45  // recently-played list itself because Simian's own sidebar truncates and
46  // runs ~30 min behind by midday.
47  // Raw GitHub URL bypasses the GitHub Pages CDN — updates within seconds of a push
48  const RAW_BASE = 'https://raw.githubusercontent.com/dunnand/WCYT-NowPlaying/main';
49  const BSI_FILES = [
50    '/point_recent.json', // station 0 — The Point
51    '/wcyt2_recent.json', // station 1 — 2.0
52  ];
53
54  // ── DJ Panel ──────────────────────────────────────────────────────
55  // Backed by a Google Apps Script web app (see dj-backend.gs in this repo).
56  // Paste the deployed /exec URL here. Read-only — no keys needed.
57  const DJPANEL_URL = 'https://script.google.com/macros/s/AKfycbwV99T1Owt69gFQYtdOmqGUJjtMME1y0bUBjJ9XHN1CjzPXMhfPeI8xNFZOUUI6hsB7/exec';
58
59  // ── Last.fm config ────────────────────────────────────────────────────────
60  // Register free API keys at https://www.last.fm/api/account/create
61  const LASTFM_KEY    = 'cdd7c1932d8d41d9ec8918ef01b617c9';
62  const LASTFM_SECRET = 'b91a01a7583f831133adff6cf4831390';
63  const LASTFM_API    = 'https://ws.audioscrobbler.com/2.0/';
64
65  const STATIONS = [
66    {
67      id:         'wcyt',
68      label:      'WCYT THE POINT 91FM',
69      short:      'The Point',
70      name:       'The Point 91 FM',
71      tagline:    'Where Music is the Point',
72      stream:     'https://securestreams2.autopo.st:1069/WCYT.mp3',
73      mount:      'WCYT.mp3',
74    },
75    {
76      id:         '2pt0',
77      label:      '2.0 NEXT LEVEL OF RADIO',
78      short:      '2.0',
79      name:       '2.0 – The Next Level of Radio',
80      tagline:    'The Next Level of Radio',
81      stream:     'https://securestreams2.autopo.st:1069/wcythd2.mp3',
82      mount:      'wcythd2.mp3',
83    },
84  ];
85
86  // Titles/artists that should never appear in the playlist display.
87  const BLOCKED_TERMS = [
88    'liner',
89    'legal id',
90    'btyb',
91    'sponsor',
92  ];
93
94  // ── State ─────────────────────────────────────────────────────────────────
95  let currentSong    = null;
96  let songHistory    = [];
97  let currentSong2   = null;   // now-playing for station 2
98  let songHistory2   = [];     // recent plays for station 2
99  let bsiRecent1     = [];     // recently played from BSI file, station 0
100  let bsiRecent2     = [];     // recently played from BSI file, station 1
101  const ART_CACHE_KEY = 'wcyt-art-cache-v2';
102  const ART_CACHE_MAX = 5000;
103  let artCache = (() => {
104    try { return JSON.parse(localStorage.getItem(ART_CACHE_KEY) || '{}'); } catch { return {}; }
105  })();
106  function saveArtCache() {
107    try {
108      const keys = Object.keys(artCache);
109      if (keys.length > ART_CACHE_MAX) {
110        const trimmed = {};
111        keys.slice(-ART_CACHE_MAX).forEach(k => { trimmed[k] = artCache[k]; });
112        artCache = trimmed;
113      }
114      localStorage.setItem(ART_CACHE_KEY, JSON.stringify(artCache));
115    } catch {}
116  }
117  let currentShowWCYT = null;  // { name: string, expiresAt: Date } | null  (The Point)
118  let currentShow2    = null;  // { name: string, expiresAt: Date } | null  (2.0)
119  let heroEl            = null;
120  let compactEl         = null;
121  let fullEl            = null;
122  let stickyEl          = null;
123  let stickyVisible     = false;
124  let pendingAutoResume = false;
125  let corsWarned        = false;
126  let pollTimer         = null;
127  let tickTimer         = null;
128  let activeStation     = 0;   // index into STATIONS
129
130  // ── DJ Panel state ────────────────────────────────────────────────
131  let djPanel = { wcyt: null, '2pt0': null };
132
133  async function fetchDJPanel() {
134    if (!DJPANEL_URL) return;
135    try {
136      const res  = await fetch(DJPANEL_URL, { cache: 'no-store' });
137      const data = await res.json();
138      const rec  = data.record || {};
139      djPanel['wcyt'] = rec['wcyt']  || null;
140      djPanel['2pt0'] = rec['2pt0'] || null;
141      render();
142    } catch (err) {
143      console.warn('[WCYTPlaylist] DJ panel fetch error:', err);
144    }
145  }
146
147  const DJ_EXPIRE_MS = 30 * 60 * 1000; // 30 minutes
148
149  function getDJPanel(stationIdx) {
150    const key = stationIdx === 0 ? 'wcyt' : '2pt0';
151    const p   = djPanel[key];
152    if (!p || !p.active) return null;
153    if (p.updatedAt && Date.now() - new Date(p.updatedAt).getTime() > DJ_EXPIRE_MS) return null;
154    return p;
155  }
156
157  // ── Last.fm state ─────────────────────────────────────────────────────────
158  let lastfmSession  = null;   // { key, name } | null
159  let lfmCurrent     = null;   // { artist, title, startedAt } | null
160  let lfmListenMs    = 0;      // ms of current song actually heard
161  let lfmListenTick  = null;   // timestamp audio started for current accumulation
162
163  // ── Audio player state ────────────────────────────────────────────────────
164  let audio          = null;
165  let audioState     = 'stopped';
166
167  function getAudio() {
168    if (!audio) {
169      audio = new Audio();
170      audio.preload = 'none';
171      audio.addEventListener('waiting',  () => setAudioState('buffering'));
172      audio.addEventListener('playing',  () => setAudioState('playing'));
173      audio.addEventListener('pause',    () => setAudioState('stopped'));
174      audio.addEventListener('ended',    () => setAudioState('stopped'));
175      audio.addEventListener('error',    () => setAudioState('stopped'));
176    }
177    return audio;
178  }
179
180  function setAudioState(state) {
181    // Track listen time for scrobbling
182    if (state === 'playing' && audioState !== 'playing') {
183      lfmListenTick = Date.now();
184    } else if (state !== 'playing' && audioState === 'playing') {
185      if (lfmListenTick) { lfmListenMs += Date.now() - lfmListenTick; lfmListenTick = null; }
186    }
187    audioState = state;
188    updatePlayerButtons();
189    updateBarsAnimation();
190    renderSticky();
191  }
192
193  function savePlayerState() {
194    try {
195      sessionStorage.setItem('wcyt-player', JSON.stringify({
196        station: activeStation,
197        playing: audioState === 'playing' || audioState === 'buffering',
198        showWCYT: currentShowWCYT ? { name: currentShowWCYT.name, expiresAt: currentShowWCYT.expiresAt.toISOString(), imageUrl: currentShowWCYT.imageUrl || null } : null,
199        show2:    currentShow2    ? { name: currentShow2.name,    expiresAt: currentShow2.expiresAt.toISOString(),    imageUrl: currentShow2.imageUrl    || null } : null,
200      }));
201    } catch {}
202  }
203
204  function togglePlay() {
205    const a = getAudio();
206    if (audioState === 'playing' || audioState === 'buffering') {
207      a.pause();
208      a.src = '';
209    } else {
210      a.src = STATIONS[activeStation].stream;
211      a.play().catch(() => setAudioState('stopped'));
212      setAudioState('buffering');
213    }
214    savePlayerState();
215  }
216
217  function switchStation(idx) {
218    if (idx === activeStation) return;
219    activeStation = idx;
220    const a = getAudio();
221    const wasPlaying = audioState === 'playing' || audioState === 'buffering';
222    a.pause();
223    a.src = '';
224    setAudioState('stopped');
225    if (wasPlaying) {
226      a.src = STATIONS[activeStation].stream;
227      a.play().catch(() => setAudioState('stopped'));
228      setAudioState('buffering');
229    }
230    savePlayerState();
231    render();
232  }
233
234  function updatePlayerButtons() {
235    document.querySelectorAll('[data-wcyt-playbtn]').forEach(btn => {
236      btn.setAttribute('data-wcyt-playbtn', audioState);
237      btn.setAttribute('aria-label', audioState === 'playing' ? 'Pause' : 'Play');
238      btn.innerHTML = btnIcon(audioState);
239    });
240  }
241
242  function updateBarsAnimation() {
243    document.querySelectorAll('.wcyt-bars span').forEach(bar => {
244      bar.style.animationPlayState = 'running';
245    });
246  }
247
248  const PAUSE_SVG = '<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor"><rect x="0" y="0" width="3.5" height="16" rx="1.5"/><rect x="8.5" y="0" width="3.5" height="16" rx="1.5"/></svg>';
249  const PLAY_SVG  = '<span class="wcyt-btn-icon">&#9654;</span>';
250
251  function btnIcon(state) {
252    if (state === 'buffering') return '<span class="wcyt-btn-spinner"></span>';
253    if (state === 'playing')   return PAUSE_SVG;
254    return PLAY_SVG;
255  }
256
257  function playBtnHTML(size) {
258    const cls = size === 'lg' ? 'wcyt-play-btn wcyt-play-btn--lg' : 'wcyt-play-btn';
259    return `<button
260      class="${cls}"
261      data-wcyt-playbtn="${audioState}"
262      aria-label="${audioState === 'playing' ? 'Pause' : 'Play'}"
263      onclick="WCYTPlaylist.togglePlay()"
264    >${btnIcon(audioState)}</button>`;
265  }
266
267  // ── Last.fm MD5 + API helpers ─────────────────────────────────────────────
268
269  function lfmMd5(input) {
270    function safeAdd(x, y) {
271      const lsw = (x & 0xffff) + (y & 0xffff);
272      return (((x >> 16) + (y >> 16) + (lsw >> 16)) << 16) | (lsw & 0xffff);
273    }
274    const rol = (n, s) => (n << s) | (n >>> (32 - s));
275    const cmn = (q, a, b, x, s, t) => safeAdd(rol(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b);
276    const ff  = (a,b,c,d,x,s,t) => cmn((b & c) | (~b & d), a, b, x, s, t);
277    const gg  = (a,b,c,d,x,s,t) => cmn((b & d) | (c & ~d), a, b, x, s, t);
278    const hh  = (a,b,c,d,x,s,t) => cmn(b ^ c ^ d, a, b, x, s, t);
279    const ii  = (a,b,c,d,x,s,t) => cmn(c ^ (b | ~d), a, b, x, s, t);
280    const str = unescape(encodeURIComponent(input));
281    const M   = [];
282    for (let i = 0; i < str.length * 8; i += 8) M[i >> 5] = (M[i >> 5] || 0) | (str.charCodeAt(i / 8) & 0xff) << (i % 32);
283    M[str.length * 8 >> 5] |= 0x80 << (str.length * 8 % 32);
284    M[(((str.length * 8 + 64) >>> 9) << 4) + 14] = str.length * 8;
285    let [a, b, c, d] = [1732584193, -271733879, -1732584194, 271733878];
286    for (let i = 0, n = M.length; i < n; i += 16) {
287      const [oa, ob, oc, od] = [a, b, c, d];
288      const w = [...M.slice(i, i + 16), ...Array(16).fill(0)].slice(0, 16);
289      a=ff(a,b,c,d,w[0],7,-680876936);d=ff(d,a,b,c,w[1],12,-389564586);c=ff(c,d,a,b,w[2],17,606105819);b=ff(b,c,d,a,w[3],22,-1044525330);
290      a=ff(a,b,c,d,w[4],7,-176418897);d=ff(d,a,b,c,w[5],12,1200080426);c=ff(c,d,a,b,w[6],17,-1473231341);b=ff(b,c,d,a,w[7],22,-45705983);
291      a=ff(a,b,c,d,w[8],7,1770035416);d=ff(d,a,b,c,w[9],12,-1958414417);c=ff(c,d,a,b,w[10],17,-42063);b=ff(b,c,d,a,w[11],22,-1990404162);
292      a=ff(a,b,c,d,w[12],7,1804603682);d=ff(d,a,b,c,w[13],12,-40341101);c=ff(c,d,a,b,w[14],17,-1502002290);b=ff(b,c,d,a,w[15],22,1236535329);
293      a=gg(a,b,c,d,w[1],5,-165796510);d=gg(d,a,b,c,w[6],9,-1069501632);c=gg(c,d,a,b,w[11],14,643717713);b=gg(b,c,d,a,w[0],20,-373897302);
294      a=gg(a,b,c,d,w[5],5,-701558691);d=gg(d,a,b,c,w[10],9,38016083);c=gg(c,d,a,b,w[15],14,-660478335);b=gg(b,c,d,a,w[4],20,-405537848);
295      a=gg(a,b,c,d,w[9],5,568446438);d=gg(d,a,b,c,w[14],9,-1019803690);c=gg(c,d,a,b,w[3],14,-187363961);b=gg(b,c,d,a,w[8],20,1163531501);
296      a=gg(a,b,c,d,w[13],5,-1444681467);d=gg(d,a,b,c,w[2],9,-51403784);c=gg(c,d,a,b,w[7],14,1735328473);b=gg(b,c,d,a,w[12],20,-1926607734);
297      a=hh(a,b,c,d,w[5],4,-378558);d=hh(d,a,b,c,w[8],11,-2022574463);c=hh(c,d,a,b,w[11],16,1839030562);b=hh(b,c,d,a,w[14],23,-35309556);
298      a=hh(a,b,c,d,w[1],4,-1530992060);d=hh(d,a,b,c,w[4],11,1272893353);c=hh(c,d,a,b,w[7],16,-155497632);b=hh(b,c,d,a,w[10],23,-1094730640);
299      a=hh(a,b,c,d,w[13],4,681279174);d=hh(d,a,b,c,w[0],11,-358537222);c=hh(c,d,a,b,w[3],16,-722521979);b=hh(b,c,d,a,w[6],23,76029189);
300      a=hh(a,b,c,d,w[9],4,-640364487);d=hh(d,a,b,c,w[12],11,-421815835);c=hh(c,d,a,b,w[15],16,530742520);b=hh(b,c,d,a,w[2],23,-995338651);
301      a=ii(a,b,c,d,w[0],6,-198630844);d=ii(d,a,b,c,w[7],10,1126891415);c=ii(c,d,a,b,w[14],15,-1416354905);b=ii(b,c,d,a,w[5],21,-57434055);
302      a=ii(a,b,c,d,w[12],6,1700485571);d=ii(d,a,b,c,w[3],10,-1894986606);c=ii(c,d,a,b,w[10],15,-1051523);b=ii(b,c,d,a,w[1],21,-2054922799);
303      a=ii(a,b,c,d,w[8],6,1873313359);d=ii(d,a,b,c,w[15],10,-30611744);c=ii(c,d,a,b,w[6],15,-1560198380);b=ii(b,c,d,a,w[13],21,1309151649);
304      a=ii(a,b,c,d,w[4],6,-145523070);d=ii(d,a,b,c,w[11],10,-1120210379);c=ii(c,d,a,b,w[2],15,718787259);b=ii(b,c,d,a,w[9],21,-343485551);
305      a=safeAdd(a,oa); b=safeAdd(b,ob); c=safeAdd(c,oc); d=safeAdd(d,od);
306    }
307    let hex = '';
308    [a, b, c, d].forEach(n => { for (let i = 0; i < 4; i++) hex += ((n >> (i * 8)) & 0xff).toString(16).padStart(2, '0'); });
309    return hex;
310  }
311
312  function lfmSign(params) {
313    return lfmMd5(
314      Object.keys(params).sort()
315        .filter(k => k !== 'format')
316        .map(k => k + params[k])
317        .join('') + LASTFM_SECRET
318    );
319  }
320
321  async function lfmPost(params) {
322    if (!LASTFM_KEY || !LASTFM_SECRET) return null;
323    params.api_key = LASTFM_KEY;
324    params.api_sig  = lfmSign(params);
325    params.format   = 'json';
326    try {
327      const res = await fetch(LASTFM_API, { method: 'POST', body: new URLSearchParams(params) });
328      return res.json();
329    } catch { return null; }
330  }
331
332  function lfmLoad() {
333    try {
334      const s = JSON.parse(localStorage.getItem('wcyt-lastfm') || 'null');
335      if (s?.key) lastfmSession = s;
336    } catch {}
337  }
338
339  function lfmConnect() {
340    if (!LASTFM_KEY) return;
341    if (lastfmSession) {
342      lastfmSession = null;
343      localStorage.removeItem('wcyt-lastfm');
344      renderSticky();
345      return;
346    }
347    const cb = encodeURIComponent(location.href.split('?')[0] + '?lfmcb=1');
348    const popup = window.open(
349      `https://www.last.fm/api/auth/?api_key=${LASTFM_KEY}&cb=${cb}`,
350      'lfm-auth', 'width=800,height=600,left=200,top=100'
351    );
352    window.addEventListener('message', function h(e) {
353      if (e.data?.type !== 'wcyt-lastfm') return;
354      window.removeEventListener('message', h);
355      lastfmSession = e.data.session;
356      localStorage.setItem('wcyt-lastfm', JSON.stringify(lastfmSession));
357      renderSticky();
358    });
359  }
360
361  async function lfmHandleCallback() {
362    const p     = new URLSearchParams(location.search);
363    const token = p.get('token');
364    if (!p.get('lfmcb') || !token || !window.opener) return;
365    const data = await lfmPost({ method: 'auth.getSession', token });
366    if (data?.session) {
367      window.opener.postMessage(
368        { type: 'wcyt-lastfm', session: { key: data.session.key, name: data.session.name } },
369        '*'
370      );
371    }
372    window.close();
373  }
374
375  async function lfmNowPlaying(artist, title) {
376    if (!lastfmSession || !artist) return;
377    await lfmPost({ method: 'track.updateNowPlaying', sk: lastfmSession.key, artist, track: title });
378  }
379
380  async function lfmScrobbleTrack(artist, title, startedAt) {
381    if (!lastfmSession || !artist) return;
382    await lfmPost({
383      method:    'track.scrobble',
384      sk:        lastfmSession.key,
385      artist,
386      track:     title,
387      timestamp: String(Math.floor(startedAt / 1000)),
388    });
389  }
390
391  function lfmOnSongStart(artist, title) {
392    if (!lastfmSession || !LASTFM_KEY) return;
393    lfmCurrent    = { artist, title, startedAt: Date.now() };
394    lfmListenMs   = 0;
395    lfmListenTick = (audioState === 'playing') ? Date.now() : null;
396    lfmNowPlaying(artist, title);
397  }
398
399  function lfmOnSongEnd() {
400    if (!lfmCurrent || !lastfmSession || !LASTFM_KEY) return;
401    const extra = lfmListenTick ? Date.now() - lfmListenTick : 0;
402    if (lfmListenMs + extra >= 30_000) {
403      lfmScrobbleTrack(lfmCurrent.artist, lfmCurrent.title, lfmCurrent.startedAt);
404    }
405    lfmCurrent = null;
406  }
407
408  // ── Helpers ───────────────────────────────────────────────────────────────
409
410  function parseTitle(raw) {
411    if (!raw || !raw.trim()) return { artist: '', title: 'On Air' };
412    const idx = raw.indexOf(' - ');
413    if (idx === -1) return { artist: '', title: raw.trim() };
414    return {
415      artist: raw.slice(0, idx).trim(),
416      title:  raw.slice(idx + 3).trim(),
417    };
418  }
419
420  function formatTime(date) {
421    return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
422  }
423
424  function relativeTime(date) {
425    const secs = Math.floor((Date.now() - date) / 1000);
426    if (secs < 60)  return 'just started';
427    if (secs < 120) return '1 min ago';
428    return Math.floor(secs / 60) + ' min ago';
429  }
430
431  function artCacheKey(artist, title) {
432    return (artist + '|' + title).toLowerCase();
433  }
434
435  function isBlocked(raw, parsed) {
436    const haystack = [
437      (raw   ?? '').toLowerCase(),
438      (parsed.artist ?? '').toLowerCase(),
439      (parsed.title  ?? '').toLowerCase(),
440    ].join(' ');
441    return BLOCKED_TERMS.some(term => haystack.includes(term));
442  }
443
444  // ── Manual art overrides ──────────────────────────────────────────────────
445  // For artists whose music isn't on iTunes (e.g. Bandcamp-only releases).
446  // Key: normalized artist name (lowercase, no punctuation). Value: image URL.
447  const ART_OVERRIDES = {
448    'cindylee': 'https://f4.bcbits.com/img/a1091823768_10.jpg',
449  };
450
451  // Song-level overrides exported from the art review tool.
452  // Key: "artist|songtitle" (lowercased). Value: image URL.
453  // Loaded once from /images/art_overrides.json on startup.
454  let SONG_ART_OVERRIDES = {};
455  async function loadSongOverrides() {
456    try {
457      const res = await fetch('/images/art_overrides.json', { cache: 'no-cache' });
458      if (!res.ok) return;
459      const data = await res.json();
460      SONG_ART_OVERRIDES = data.overrides || {};
461      // If the current song loaded before overrides were ready, re-fetch its art now
462      if (currentSong && !currentSong.artUrl) {
463        const url = artOverride(currentSong.artist, currentSong.title, currentSong.albumLine || '');
464        if (url) { currentSong = { ...currentSong, artUrl: url }; render(); }
465      }
466      if (currentSong2 && !currentSong2.artUrl) {
467        const url = artOverride(currentSong2.artist, currentSong2.title, currentSong2.albumLine || '');
468        if (url) { currentSong2 = { ...currentSong2, artUrl: url }; render(); }
469      }
470    } catch { /* file not present yet — fine */ }
471  }
472
473  function artOverride(artist, title, albumLine = '') {
474    const songKey = (artist + '|' + (title || '')).toLowerCase();
475    if (SONG_ART_OVERRIDES[songKey]) return SONG_ART_OVERRIDES[songKey];
476    // WAV files at the station are sometimes tagged with the album name as the
477    // song title, so art_overrides.json ends up keyed by album, not song title.
478    // Fall back to an album-name lookup using the BSI albumLine (e.g. "Green • 1988").
479    if (albumLine) {
480      // Try full "Album • Year" key first — disambiguates same-name albums (e.g. Weezer)
481      const fullKey = (artist + '|' + albumLine).toLowerCase();
482      if (SONG_ART_OVERRIDES[fullKey]) {
483        SONG_ART_OVERRIDES[songKey] = SONG_ART_OVERRIDES[fullKey];
484        return SONG_ART_OVERRIDES[fullKey];
485      }
486      const m = albumLine.match(/^(.+?)\s*[•·]\s*\d{4}\s*$/);
487      const album = m ? m[1].trim() : albumLine.trim();
488      if (album) {
489        const albumKey = (artist + '|' + album).toLowerCase();
490        if (SONG_ART_OVERRIDES[albumKey]) {
491          SONG_ART_OVERRIDES[songKey] = SONG_ART_OVERRIDES[albumKey]; // learn song-title key
492          return SONG_ART_OVERRIDES[albumKey];
493        }
494      }
495    }
496    const key = normArtist(artist);
497    return ART_OVERRIDES[key] ?? null;
498  }
499
500  // ── Album art ─────────────────────────────────────────────────────────────
501  // Art comes ONLY from the curated overrides file (approved via the art
502  // review tool) — there is no live iTunes/MusicBrainz lookup on the site,
503  // so nothing unreviewed can ever appear. The explicit-cover blocklist
504  // lives in display.htm (the only page that needs a second gate).
505
506  // Strip accents/diacritics so "Björk" → "Bjork" for search/matching
507  function stripDiacritics(s) {
508    return (s ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '');
509  }
510
511  function normArtist(s) {
512    return stripDiacritics(s ?? '').toLowerCase()
513      .replace(/\s*&\s*/g, 'and')
514      .replace(/[^a-z0-9]/g, '');
515  }
516
517  async function fetchArt(artist, title, albumLine = '') {
518    const key = artCacheKey(artist, title);
519    if (artCache[key]) return artCache[key];
520    const url = artOverride(artist, title, albumLine) || null;
521    if (url) { artCache[key] = url; saveArtCache(); }
522    return url;
523  }
524
525  // ── Current show (Google Sheet) ───────────────────────────────────────────
526
527  function parseCSVRow(row) {
528    const out = [];
529    let cur = '', inQ = false;
530    for (const ch of row) {
531      if (ch === '"')       { inQ = !inQ; }
532      else if (ch === ',' && !inQ) { out.push(cur); cur = ''; }
533      else                  { cur += ch; }
534    }
535    out.push(cur);
536    return out;
537  }
538
539  function isStation2(stationVal) {
540    const v = (stationVal ?? '').trim().toLowerCase();
541    return v === '2' || v === '2.0' || v.includes('next level') || v.includes('2.0');
542  }
543
544  function applyShowEntry(tsRaw, stationVal, showName, imageUrl) {
545    const CLEAR_WORDS = ['clear', 'end', 'done', 'off'];
546    const isClear     = !showName || CLEAR_WORDS.includes(showName.toLowerCase());
547    const submittedAt = new Date(tsRaw);
548    const expiresAt   = new Date(submittedAt.getTime() + SHOW_TTL_MS);
549    const entry       = (!isClear && Date.now() < expiresAt.getTime())
550      ? { name: showName, expiresAt, imageUrl: imageUrl || null }
551      : null;
552
553    if (isStation2(stationVal)) {
554      currentShow2 = entry;
555    } else {
556      currentShowWCYT = entry;
557    }
558  }
559
560  async function fetchCurrentShow() {
561    try {
562      // Append timestamp to bust Google's server-side CSV cache
563      const res  = await fetch(SHOW_URL + '&_t=' + Date.now(), { cache: 'no-store' });
564      const text = await res.text();
565      const lines = text.trim().split('\n').filter(Boolean);
566
567      if (lines.length < 2) { currentShowWCYT = null; currentShow2 = null; render(); return; }
568
569      // Columns: timestamp(0), email(1), station(2), show/dj name(3)
570      const headers     = parseCSVRow(lines[0]).map(h => h.trim().toLowerCase());
571      const stationCol  = headers.findIndex(h => h.includes('station'));
572      const resolvedStn = stationCol !== -1 ? stationCol : 2;
573
574      // Find show column — prefer 'show' or 'dj' keyword first to avoid
575      // colliding with 'station name' or other columns that contain 'name'
576      const byShowDj = headers.findIndex((h, i) => i !== resolvedStn && (h.includes('show') || h.includes('dj')));
577      const byName   = headers.findIndex((h, i) => i !== resolvedStn && h.includes('name'));
578      const resolvedShw = byShowDj !== -1 ? byShowDj
579                        : byName   !== -1 ? byName
580                        : resolvedStn + 1;
581      const imageCol = headers.findIndex(h => h.includes('image') || h.includes('photo') || h.includes('pic'));
582
583      console.log('[WCYTPlaylist] Show sheet headers:', headers);
584      console.log('[WCYTPlaylist] stationCol:', resolvedStn, '| showCol:', resolvedShw);
585
586      // Reset both stations, then replay all non-expired entries (last entry per station wins)
587      currentShowWCYT = null;
588      currentShow2    = null;
589
590      for (let i = 1; i < lines.length; i++) {
591        const cols      = parseCSVRow(lines[i]);
592        const tsRaw     = (cols[0]              ?? '').trim();
593        const stationV  = (cols[resolvedStn]    ?? '').trim();
594        const showName  = (cols[resolvedShw]    ?? '').trim();
595        const imageUrl  = imageCol !== -1 ? (cols[imageCol] ?? '').trim() : '';
596        applyShowEntry(tsRaw, stationV, showName, imageUrl);
597      }
598
599      render();
600    } catch (err) { console.warn('[WCYTPlaylist] fetchCurrentShow error:', err); }
601  }
602
603  // ── Fetch album/year from BSI output file ────────────────────────────────
604
605  async function fetchBSIData(url) {
606    try {
607      const res  = await fetch(RAW_BASE + url + '?t=' + Date.now(), { cache: 'no-store' });
608      const data = await res.json();
609      const now  = data.now || {};
610      const recent = (data.recent || [])
611        .filter(s => !isBlocked(s.artist + ' - ' + s.title, { artist: s.artist, title: s.title }))
612        .slice(0, 20)
613        .map(s => ({ time: s.time, artist: s.artist, title: s.title, albumLine: s.album || '' }));
614      return { albumLine: now.album || '', bsiTitle: now.title || '', recent };
615    } catch { return { albumLine: '', bsiTitle: '', recent: [] }; }
616  }
617
618  // ── Fetch stream metadata ─────────────────────────────────────────────────
619
620  async function fetchNowPlaying() {
621    try {
622      const res = await fetch(METADATA_URL, { cache: 'no-store' });
623      const buf = await res.arrayBuffer();
624      let text;
625      try { text = new TextDecoder('utf-8', { fatal: true }).decode(buf); }
626      catch { text = new TextDecoder('latin1').decode(buf); }
627
628      let data;
629      try { data = JSON.parse(text); }
630      catch (parseErr) {
631        console.warn('[WCYTPlaylist] JSON parse failed. Raw response (first 500 chars):', text?.slice(0, 500));
632        return; // skip poll, keep current display
633      }
634
635      let sources = data?.icestats?.source ?? [];
636      if (!Array.isArray(sources)) sources = [sources];
637
638      const findSource = mount =>
639        sources.find(s => (s.listenurl ?? '').toLowerCase().includes(mount.toLowerCase()));
640
641      // Fetch BSI data (album/year + recently played) for both stations
642      const [bsi1, bsi2] = await Promise.all([
643        fetchBSIData(BSI_FILES[0]),
644        fetchBSIData(BSI_FILES[1]),
645      ]);
646      // Station 1 — WCYT (drives history + full playlist page)
647      const rawTitle1 = findSource(STATIONS[0].mount)?.title ?? null;
648      console.log('[WCYTPlaylist] raw title WCYT:', rawTitle1);
649      const parsed1tmp  = parseTitle(rawTitle1);
650      const bsiMatch1   = bsi1.bsiTitle && bsi1.bsiTitle.trim().toLowerCase() === parsed1tmp.title.trim().toLowerCase();
651      const album1      = bsiMatch1 ? bsi1.albumLine : '';
652
653      // Station 2 — track current song + history
654      const raw2    = findSource(STATIONS[1].mount)?.title ?? null;
655      const parsed2 = parseTitle(raw2);
656
657      // Filter out the currently-playing song from BSI recent (BSI sometimes writes
658      // the current song into recently-played before it's actually finished)
659      const nowKey1 = artCacheKey(parsed1tmp.artist, parsed1tmp.title);
660      const nowKey2 = artCacheKey(parsed2.artist, parsed2.title);
661      bsiRecent1 = bsi1.recent.filter(s => artCacheKey(s.artist, s.title) !== nowKey1);
662      bsiRecent2 = bsi2.recent.filter(s => artCacheKey(s.artist, s.title) !== nowKey2);
663      // Prefetch art for recent items into cache so next render has them
664      [...bsiRecent1, ...bsiRecent2].forEach(s => fetchArt(s.artist, s.title, s.albumLine || ''));
665      // Always re-render so recently played reflects the latest BSI data even
666      // when the current song hasn't changed (render() is not called otherwise)
667      render();
668
669      handleNewTitle(rawTitle1, album1);
670      const bsiMatch2 = bsi2.bsiTitle && bsi2.bsiTitle.trim().toLowerCase() === parsed2.title.trim().toLowerCase();
671      const album2    = bsiMatch2 ? bsi2.albumLine : '';
672      if (!isBlocked(raw2, parsed2) && (parsed2.artist || parsed2.title !== 'On Air')) {
673        const key2 = artCacheKey(parsed2.artist, parsed2.title);
674        const cur2  = currentSong2 ? artCacheKey(currentSong2.artist, currentSong2.title) : null;
675        if (key2 !== cur2) {
676          if (currentSong2) {
677            if (activeStation === 1) lfmOnSongEnd();
678            const outKey2 = artCacheKey(currentSong2.artist, currentSong2.title);
679            if (!songHistory2.some(h => artCacheKey(h.artist, h.title) === outKey2)) {
680              songHistory2.unshift({ ...currentSong2, endedAt: new Date() });
681              if (songHistory2.length > MAX_HISTORY) songHistory2.pop();
682            }
683          }
684          const artUrl = parsed2.artist ? await fetchArt(parsed2.artist, parsed2.title, album2) : null;
685          currentSong2 = { ...parsed2, startedAt: new Date(), artUrl, albumLine: album2 };
686          if (activeStation === 1) lfmOnSongStart(parsed2.artist, parsed2.title);
687          if (activeStation === 1) render();
688        } else if (currentSong2 && album2 && currentSong2.albumLine !== album2) {
689          currentSong2.albumLine = album2;
690          if (!currentSong2.artUrl) {
691            fetchArt(currentSong2.artist, currentSong2.title, album2).then(url => {
692              if (url) { currentSong2 = { ...currentSong2, artUrl: url }; if (activeStation === 1) render(); }
693            });
694          } else if (activeStation === 1) render();
695        }
696      } else {
697        currentSong2 = null;
698        if (activeStation === 1) render();
699      }
700    } catch (err) {
701      if (!corsWarned) {
702        corsWarned = true;
703        console.warn(
704          '[WCYTPlaylist] Could not fetch metadata.\n' +
705          'If this is a CORS error, set up a proxy and update METADATA_URL in playlist-widget.js.\n' +
706          'Error:', err.message
707        );
708      }
709      setErrorState();
710    }
711  }
712
713  async function handleNewTitle(raw, albumLine = '') {
714    const parsed = parseTitle(raw);
715
716    if (isBlocked(raw, parsed)) return;
717
718    if (!currentSong) {
719      const artUrl = parsed.artist ? await fetchArt(parsed.artist, parsed.title, albumLine) : null;
720      currentSong = { ...parsed, startedAt: new Date(), artUrl, albumLine };
721      if (activeStation === 0) lfmOnSongStart(parsed.artist, parsed.title);
722      render();
723      return;
724    }
725
726    const currentKey = artCacheKey(currentSong.artist, currentSong.title);
727    const newKey     = artCacheKey(parsed.artist, parsed.title);
728    if (newKey === currentKey) {
729      // Same song — update album line if BSI just refreshed
730      if (albumLine && currentSong.albumLine !== albumLine) {
731        currentSong.albumLine = albumLine;
732        if (!currentSong.artUrl) {
733          fetchArt(currentSong.artist, currentSong.title, albumLine).then(url => {
734            if (url) { currentSong = { ...currentSong, artUrl: url }; if (activeStation === 0) render(); }
735          });
736        } else if (activeStation === 0) render();
737      }
738      return;
739    }
740
741    if (activeStation === 0) lfmOnSongEnd();
742    const outKey = artCacheKey(currentSong.artist, currentSong.title);
743    if (!songHistory.some(h => artCacheKey(h.artist, h.title) === outKey)) {
744      songHistory.unshift({ ...currentSong, endedAt: new Date() });
745      if (songHistory.length > MAX_HISTORY) songHistory.pop();
746    }
747
748    // Clear immediately, render blank while art loads
749    currentSong = { ...parsed, startedAt: new Date(), artUrl: null, albumLine: '' };
750    if (activeStation === 0) render();
751
752    const artUrl = parsed.artist ? await fetchArt(parsed.artist, parsed.title, albumLine) : null;
753    currentSong = { ...parsed, startedAt: new Date(), artUrl, albumLine };
754    if (activeStation === 0) lfmOnSongStart(parsed.artist, parsed.title);
755    render();
756  }
757
758  // Resolve art URL for any song object — checks live artUrl, then localStorage
759  // cache, then overrides (using albumLine if available). Used at render time so
760  // history items self-heal once overrides are loaded, without a re-fetch.
761  function resolvedArt(song) {
762    if (!song) return null;
763    if (song.artUrl) return song.artUrl;
764    const key = artCacheKey(song.artist, song.title);
765    if (artCache[key]) return artCache[key];
766    const url = artOverride(song.artist, song.title, song.albumLine || '');
767    if (url) { artCache[key] = url; saveArtCache(); }
768    return url || null;
769  }
770
771  // ── Shared elements ───────────────────────────────────────────────────────
772
773  function backdropDiv(artUrl) {
774    if (!artUrl) return '';
775    return `<div class="wcyt-backdrop" style="background-image:url('${esc(artUrl)}')"></div>`;
776  }
777
778  function artImg(artUrl, size, cssClass) {
779    const src        = artUrl || FALLBACK_ART;
780    const isFallback = !artUrl;
781    return `<img
782      class="${cssClass}${isFallback ? ' wcyt-art-fallback' : ''}"
783      src="${esc(src)}"
784      width="${size}" height="${size}"
785      alt="Album art"
786      loading="lazy"
787      onerror="wcytHandleArtError(this, '${FALLBACK_ART}')"
788    >`;
789  }
790
791  // ── Render: Hero ──────────────────────────────────────────────────────────
792
793  function stationSwitcher() {
794    return `
795      <div class="wcyt-station-switcher" role="group" aria-label="Select station">
796        ${STATIONS.map((s, i) => `
797          <button
798            class="wcyt-station-btn${i === activeStation ? ' wcyt-station-btn--active' : ''}"
799            onclick="WCYTPlaylist.switchStation(${i})"
800            aria-pressed="${i === activeStation}"
801          >${i === activeStation ? `<span class="wcyt-bars" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span></span>` : ''}${esc(s.label)}</button>
802        `).join('')}
803      </div>
804    `;
805  }
806
807  function renderHero() {
808    if (!heroEl) return;
809
810    const song       = currentSong;
811    const history    = songHistory.slice(0, 10);
812    const history2   = songHistory2.slice(0, 10);
813    const station    = STATIONS[activeStation];
814    const isWCYT     = activeStation === 0;
815    const currentShow = isWCYT ? currentShowWCYT : currentShow2;
816    const djP        = getDJPanel(activeStation);  // real-time DJ panel data (takes priority)
817
818    // DJ panel state
819    const showArt  = djP?.imageUrl || currentShow?.imageUrl || null;
820    const showName = djP?.showName || currentShow?.name     || null;
821    const songObj  = isWCYT ? song : currentSong2;
822
823    // Manual override clears when a new song starts after it was set.
824    // Show image stays up the whole time the DJ is active, and only gives
825    // way to song art once the override expires (new song detected).
826    const manualSetAt     = djP?.manualSetAt ? new Date(djP.manualSetAt) : null;
827    const manualStale     = manualSetAt && songObj?.startedAt > manualSetAt;
828    const overridesActive = !manualStale;
829    const showDJImage     = djP && !djP.onBreak && !manualStale;
830
831    const dispArt    = (showDJImage && djP?.imageUrl)
832                     ? djP.imageUrl
833                     : (songObj?.artUrl || null);
834    const dispArtist = (djP?.manualArtist && overridesActive) ? djP.manualArtist
835                     : (songObj?.artist || null);
836    const dispTitle  = (djP?.manualTitle  && overridesActive) ? djP.manualTitle
837                     : (songObj?.title   || null);
838
839    heroEl.innerHTML = `
840      <div class="wcyt-hero">
841        ${dispArt ? backdropDiv(dispArt) : ''}
842        <div class="wcyt-hero-inner">
843
844          ${stationSwitcher()}
845
846          ${showName ? `
847            <div class="wcyt-hero-show">
848              <span class="wcyt-hero-show-dot"></span>
849              <span class="wcyt-hero-show-label">NOW ON AIR</span>
850              <span class="wcyt-hero-show-name">${esc(showName)}</span>
851            </div>
852          ` : ''}
853
854          ${isWCYT ? `
855            ${(song || djP) ? `
856              ${artImg(dispArt, 220, 'wcyt-hero-art')}
857              <div class="wcyt-hero-artist">${esc(dispArtist || 'WCYT')}</div>
858              <div class="wcyt-hero-title">${esc(dispTitle || 'On Air')}</div>
859              ${(songObj?.albumLine && !djP?.manualTitle) ? `<div class="wcyt-hero-album">${esc(songObj.albumLine)}</div>` : ''}
860              <div class="wcyt-hero-controls">
861                ${(!djP?.onBreak && !djP?.manualTitle && song) ? `
862                  <span class="wcyt-age wcyt-hero-age" data-started="${song.startedAt.toISOString()}">
863                    ${relativeTime(song.startedAt)}
864                  </span>
865                ` : ''}
866                ${playBtnHTML('lg')}
867                <a href="${activeStation === 1 ? '/playlist?s=2' : '/playlist'}" class="wcyt-hero-playlist-link">Full playlist &rarr;</a>
868              </div>
869            ` : `<div class="wcyt-hero-loading">Loading&hellip;</div>`}
870
871            ${(history.length || bsiRecent1.length) ? `
872              <div class="wcyt-hero-recent">
873                <div class="wcyt-hero-recent-label">RECENTLY PLAYED</div>
874                <ul class="wcyt-hero-recent-list">
875                  ${(() => { const seen = new Set(history.map(s => artCacheKey(s.artist, s.title))); const merged = [...history, ...bsiRecent1.filter(s => !seen.has(artCacheKey(s.artist, s.title)))].slice(0, 10); return merged; })().map(s => `
876                    <li>
877                      ${artImg(resolvedArt(s), 96, 'wcyt-hero-recent-art')}
878                      <span class="wcyt-hero-recent-track">
879                        <span class="wcyt-hero-recent-artist">${esc(s.artist)}</span>
880                        <span class="wcyt-hero-recent-sep">&middot;</span>
881                        <span class="wcyt-hero-recent-title">${esc(s.title)}</span>
882                      </span>
883                      <span class="wcyt-hero-recent-time">${esc(s.startedAt ? formatTime(s.startedAt) : s.time)}</span>
884                    </li>
885                  `).join('')}
886                </ul>
887              </div>
888            ` : ''}
889          ` : `
890            <div class="wcyt-hero-s2">
891              ${(currentSong2 || djP) ? `
892                ${artImg(dispArt || FALLBACK_ART_2, 180, 'wcyt-hero-art')}
893                <div class="wcyt-hero-artist">${esc(dispArtist || '2.0')}</div>
894                <div class="wcyt-hero-title">${esc(dispTitle || 'On Air')}</div>
895              ` : `<img src="${FALLBACK_ART_2}" class="wcyt-hero-art" width="180" height="180" alt="2.0 Next Level of Radio">`}
896                <div class="wcyt-hero-controls">
897                <span class="wcyt-age wcyt-hero-age"
898                  ${currentSong2 && !djP?.manualTitle ? `data-started="${currentSong2.startedAt.toISOString()}"` : ''}>
899                  ${(currentSong2 && !djP?.manualTitle) ? relativeTime(currentSong2.startedAt) : 'Live now'}
900                </span>
901                ${playBtnHTML('lg')}
902                <a href="/playlist?s=2" class="wcyt-hero-playlist-link">Full playlist &rarr;</a>
903              </div>
904            </div>
905
906            ${(history2.length || bsiRecent2.length) ? `
907              <div class="wcyt-hero-recent">
908                <div class="wcyt-hero-recent-label">RECENTLY PLAYED</div>
909                <ul class="wcyt-hero-recent-list">
910                  ${(() => { const seen = new Set(history2.map(s => artCacheKey(s.artist, s.title))); const merged = [...history2, ...bsiRecent2.filter(s => !seen.has(artCacheKey(s.artist, s.title)))].slice(0, 10); return merged; })().map(s => `
911                    <li>
912                      ${artImg(resolvedArt(s), 96, 'wcyt-hero-recent-art')}
913                      <span class="wcyt-hero-recent-track">
914                        <span class="wcyt-hero-recent-artist">${esc(s.artist)}</span>
915                        <span class="wcyt-hero-recent-sep">&middot;</span>
916                        <span class="wcyt-hero-recent-title">${esc(s.title)}</span>
917                      </span>
918                      <span class="wcyt-hero-recent-time">${esc(s.startedAt ? formatTime(s.startedAt) : s.time)}</span>
919                    </li>
920                  `).join('')}
921                </ul>
922              </div>
923            ` : ''}
924          `}
925
926        </div>
927
928        <a class="wcyt-hero-scroll" href="#wcyt-page-content" aria-label="Scroll down">
929          <svg width="24" height="24" viewBox="0 0 24 24" fill="none"
930               stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
931            <polyline points="6 9 12 15 18 9"/>
932          </svg>
933        </a>
934      </div>
935    `;
936
937    updateBarsAnimation();
938  }
939
940  // ── Render: Compact ───────────────────────────────────────────────────────
941
942  function renderCompact() {
943    if (!compactEl) return;
944
945    const song    = currentSong;
946    const history = songHistory.slice(0, 5);
947    const compactShowArt = currentShowWCYT?.imageUrl || null;
948
949    compactEl.innerHTML = `
950      <div class="wcyt-compact">
951        <div class="wcyt-compact-now">
952          ${song ? backdropDiv(song.artUrl) : ''}
953          <div class="wcyt-compact-header">
954            <span class="wcyt-label">NOW PLAYING</span>
955            <span class="wcyt-bars" aria-hidden="true">
956              <span></span><span></span><span></span><span></span><span></span>
957            </span>
958          </div>
959          ${song ? `
960            <div class="wcyt-compact-song">
961              ${artImg(song.artUrl, 140, 'wcyt-compact-art')}
962              <div class="wcyt-compact-song-text">
963                <div class="wcyt-artist">${esc(song.artist || 'WCYT')}</div>
964                <div class="wcyt-title">${esc(song.title)}</div>
965                <div class="wcyt-age" data-started="${song.startedAt.toISOString()}">
966                  ${relativeTime(song.startedAt)}
967                </div>
968              </div>
969              ${playBtnHTML()}
970            </div>
971          ` : `<div class="wcyt-loading">Loading&hellip;</div>`}
972        </div>
973        ${history.length ? `
974          <div class="wcyt-compact-history">
975            <div class="wcyt-section-label">RECENT PLAYS</div>
976            <ul class="wcyt-history-list">
977              ${history.map(s => `
978                <li>
979                  ${artImg(resolvedArt(s), 36, 'wcyt-history-art')}
980                  <span class="wcyt-history-time">${formatTime(s.startedAt)}</span>
981                  <span class="wcyt-history-track">
982                    <span class="wcyt-history-artist">${esc(s.artist || 'WCYT')}</span>
983                    ${s.artist ? ' &middot; ' : ''}
984                    <span class="wcyt-history-title">${esc(s.title)}</span>
985                  </span>
986                </li>
987              `).join('')}
988            </ul>
989          </div>
990        ` : ''}
991        <div class="wcyt-compact-footer">
992          <a href="${activeStation === 1 ? '/playlist?s=2' : '/playlist'}" class="wcyt-full-link">Full playlist &rarr;</a>
993        </div>
994      </div>
995    `;
996
997    updateBarsAnimation();
998  }
999
1000  // ── Render: Full ──────────────────────────────────────────────────────────
1001
1002  function renderFull() {
1003    if (!fullEl) return;
1004
1005    const song    = activeStation === 1 ? currentSong2  : currentSong;
1006    const history = activeStation === 1 ? songHistory2  : songHistory;
1007    const fullShow    = activeStation === 1 ? currentShow2 : currentShowWCYT;
1008    const fullShowArt = fullShow?.imageUrl || null;
1009
1010    fullEl.innerHTML = `
1011      <div class="wcyt-full">
1012        <div class="wcyt-full-now-card">
1013          ${song ? backdropDiv(song.artUrl) : ''}
1014          <div class="wcyt-full-card-header">
1015            <span class="wcyt-label">NOW PLAYING</span>
1016            <span class="wcyt-bars wcyt-bars--lg" aria-hidden="true">
1017              <span></span><span></span><span></span><span></span><span></span>
1018            </span>
1019          </div>
1020          ${song ? `
1021            <div class="wcyt-full-song">
1022              ${artImg(song.artUrl, 340, 'wcyt-full-art')}
1023              <div class="wcyt-full-song-text">
1024                <div class="wcyt-full-artist">${esc(song.artist || 'WCYT')}</div>
1025                <div class="wcyt-full-title">${esc(song.title)}</div>
1026                ${song.albumLine ? `<div class="wcyt-full-album">${esc(song.albumLine)}</div>` : ''}
1027                <div class="wcyt-full-meta">
1028                  <span class="wcyt-age" data-started="${song.startedAt.toISOString()}">
1029                    Started ${relativeTime(song.startedAt)}
1030                  </span>
1031                  ${playBtnHTML()}
1032                </div>
1033              </div>
1034            </div>
1035          ` : `<div class="wcyt-loading">Loading&hellip;</div>`}
1036        </div>
1037
1038      </div>
1039    `;
1040
1041    updateBarsAnimation();
1042  }
1043
1044  // ── Error state ───────────────────────────────────────────────────────────
1045
1046  function setErrorState() {
1047    const msg = `
1048      <div class="wcyt-error">
1049        <span>&#9888;</span> Metadata unavailable &mdash;
1050        <a href="${STATIONS[activeStation].stream}" target="_blank" rel="noopener">listen live</a>
1051      </div>
1052    `;
1053    if (heroEl    && !currentSong) heroEl.innerHTML    = msg;
1054    if (compactEl && !currentSong) compactEl.innerHTML = msg;
1055    if (fullEl    && !currentSong) fullEl.innerHTML    = msg;
1056  }
1057
1058  // ── Relative time ticker ──────────────────────────────────────────────────
1059
1060  function tickAges() {
1061    document.querySelectorAll('.wcyt-age[data-started]').forEach(el => {
1062      const d = new Date(el.dataset.started);
1063      const isHero = el.classList.contains('wcyt-hero-age');
1064      const isFull = el.closest('.wcyt-full-song');
1065      el.textContent = (isHero || isFull)
1066        ? 'Started ' + relativeTime(d)
1067        : relativeTime(d);
1068    });
1069  }
1070
1071  // ── Sticky player ─────────────────────────────────────────────────────────
1072
1073  const STICKY_CSS = `
1074    #wcyt-sticky{position:fixed;bottom:0;left:0;right:0;z-index:9999;
1075      background:#111;border-top:1px solid rgba(255,255,255,.12);
1076      display:flex;align-items:center;padding:0 12px 0 10px;height:68px;gap:10px;
1077      transform:translateY(100%);transition:transform .35s cubic-bezier(.4,0,.2,1);
1078      box-shadow:0 -4px 32px rgba(0,0,0,.6);}
1079    #wcyt-sticky.wcyt-sticky--show{transform:translateY(0);}
1080    #wcyt-sticky-art{width:46px;height:46px;border-radius:5px;object-fit:cover;
1081      background:#333;flex-shrink:0;}
1082    #wcyt-sticky-info{flex:1;min-width:0;overflow:hidden;}
1083    #wcyt-sticky-station{font-size:10px;font-weight:700;letter-spacing:.1em;
1084      text-transform:uppercase;color:#c8102e;margin-bottom:2px;}
1085    #wcyt-sticky-title{font-size:13px;font-weight:600;color:#fff;
1086      white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
1087    #wcyt-sticky-artist{font-size:12px;color:#999;
1088      white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
1089    #wcyt-sticky-show{font-size:10px;color:#c8102e;font-weight:600;
1090      white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
1091      letter-spacing:.04em;margin-top:1px;}
1092    #wcyt-sticky-controls{display:flex;align-items:center;gap:8px;flex-shrink:0;}
1093    .wcyt-sticky-stn{display:flex;gap:4px;}
1094    .wcyt-sticky-stn-btn{font-size:10px;font-weight:700;letter-spacing:.05em;
1095      padding:3px 8px;border-radius:12px;border:1px solid #444;
1096      background:transparent;color:#aaa;cursor:pointer;white-space:nowrap;}
1097    .wcyt-sticky-stn-btn--on{border-color:#c8102e;color:#fff;background:rgba(200,16,46,.18);}
1098    .wcyt-sticky-play{width:38px;height:38px;border-radius:50%;background:#c8102e;
1099      border:none;color:#fff;font-size:13px;
1099cursor:pointer;
1100      display:flex;align-items:center;justify-content:center;flex-shrink:0;}
1101    .wcyt-sticky-play:hover{background:#a50d25;}
1102    .wcyt-sticky-close{background:none;border:none;color:#555;font-size:18px;
1103      cursor:pointer;padding:4px;line-height:1;flex-shrink:0;}
1104    .wcyt-sticky-close:hover{color:#fff;}
1105    .wcyt-sticky-lfm{background:none;border:none;cursor:pointer;padding:4px;
1106      color:#555;font-size:10px;font-weight:700;letter-spacing:.06em;
1107      line-height:1;flex-shrink:0;border-radius:4px;transition:color .2s;}
1108    .wcyt-sticky-lfm:hover{color:#d51007;}
1109    .wcyt-sticky-lfm--on{color:#d51007 !important;}
1110  `;
1111
1112  function initStickyPlayer() {
1113    const style = document.createElement('style');
1114    style.textContent = STICKY_CSS;
1115    document.head.appendChild(style);
1116
1117    stickyEl = document.createElement('div');
1118    stickyEl.id = 'wcyt-sticky';
1119    document.body.appendChild(stickyEl);
1120
1121    // Restore session state
1122    try {
1123      const saved = JSON.parse(sessionStorage.getItem('wcyt-player') || 'null');
1124      if (saved) {
1125        activeStation     = saved.station ?? 0;
1126        pendingAutoResume = !!saved.playing;
1127        if (saved.showWCYT) {
1128          const exp = new Date(saved.showWCYT.expiresAt);
1129          if (Date.now() < exp.getTime()) currentShowWCYT = { name: saved.showWCYT.name, expiresAt: exp, imageUrl: saved.showWCYT.imageUrl || null };
1130        }
1131        if (saved.show2) {
1132          const exp = new Date(saved.show2.expiresAt);
1133          if (Date.now() < exp.getTime()) currentShow2 = { name: saved.show2.name, expiresAt: exp, imageUrl: saved.show2.imageUrl || null };
1134        }
1135      }
1136    } catch {}
1137
1138    window.addEventListener('beforeunload', savePlayerState);
1139  }
1140
1141  function renderSticky() {
1142    if (!stickyEl) return;
1143    const song        = activeStation === 0 ? currentSong : currentSong2;
1144    const station     = STATIONS[activeStation];
1145    const isPlaying   = audioState === 'playing' || audioState === 'buffering';
1146    const currentShow = activeStation === 0 ? currentShowWCYT : currentShow2;
1147
1148    if ((song || isPlaying) && !stickyVisible) {
1149      stickyVisible = true;
1150      stickyEl.classList.add('wcyt-sticky--show');
1151    }
1152
1153    stickyEl.innerHTML = `
1154      <img id="wcyt-sticky-art"
1155        src="${esc(song?.artUrl || FALLBACK_ART)}"
1156        onerror="this.src='${FALLBACK_ART}'"
1157        alt="Album art">
1158      <div id="wcyt-sticky-info">
1159        <div id="wcyt-sticky-station">${esc(station.short)}</div>
1160        <div id="wcyt-sticky-title">${esc(song?.title || 'Live Radio')}</div>
1161        <div id="wcyt-sticky-artist">${esc(song?.artist || station.name)}</div>
1162        ${currentShow ? `<div id="wcyt-sticky-show">ON AIR: ${esc(currentShow.name)}</div>` : ''}
1163      </div>
1164      <div id="wcyt-sticky-controls">
1165        <div class="wcyt-sticky-stn">
1166          ${STATIONS.map((s, i) => `
1167            <button class="wcyt-sticky-stn-btn${i === activeStation ? ' wcyt-sticky-stn-btn--on' : ''}"
1168              onclick="WCYTPlaylist.switchStation(${i})">${esc(s.short)}</button>
1169          `).join('')}
1170        </div>
1171        <button class="wcyt-sticky-play" onclick="WCYTPlaylist.togglePlay()"
1172          aria-label="${isPlaying ? 'Pause' : 'Play'}">
1173          ${audioState === 'buffering'
1174            ? '<span class="wcyt-btn-spinner"></span>'
1175            : isPlaying ? PAUSE_SVG : '&#9654;'}
1176        </button>
1177        <button class="wcyt-sticky-lfm${lastfmSession ? ' wcyt-sticky-lfm--on' : ''}"
1178          onclick="WCYTPlaylist.lfmConnect()"
1179          title="${lastfmSession ? 'Scrobbling as ' + lastfmSession.name + ' · click to disconnect' : 'Connect Last.fm to scrobble'}">LastFM</button>
1180        <button class="wcyt-sticky-close" onclick="WCYTPlaylist.closeSticky()" aria-label="Close">&#x2715;</button>
1181      </div>
1182    `;
1183  }
1184
1185  // ── Combined render ───────────────────────────────────────────────────────
1186
1187  function render() {
1188    renderHero();
1189    renderCompact();
1190    renderFull();
1191    renderSticky();
1192  }
1193
1194  // ── Escape HTML ───────────────────────────────────────────────────────────
1195
1196  function esc(str) {
1197    return String(str)
1198      .replace(/&/g, '&amp;')
1199      .replace(/</g, '&lt;')
1200      .replace(/>/g, '&gt;')
1201      .replace(/"/g, '&quot;');
1202  }
1203
1204  // ── Public API ────────────────────────────────────────────────────────────
1205
1206  window.WCYTPlaylist = {
1207    /**
1208     * @param {HTMLElement|null} hero    - full-screen hero container (homepage)
1209     * @param {HTMLElement|null} compact - compact widget container
1210     * @param {HTMLElement|null} full    - full playlist page container
1211     */
1212    init(hero, compact, full, initialStation) {
1213      heroEl    = hero;
1214      compactEl = compact;
1215      fullEl    = full;
1216      if (initialStation === 0 || initialStation === 1) activeStation = initialStation;
1217      lfmLoad();
1218      lfmHandleCallback();
1219      initStickyPlayer();
1220      loadSongOverrides().then(() => fetchNowPlaying()).then(() => {
1221        if (pendingAutoResume) {
1222          pendingAutoResume = false;
1223          const a = getAudio();
1224          a.src = STATIONS[activeStation].stream;
1225          a.play().catch(() => setAudioState('stopped'));
1226          setAudioState('buffering');
1227        }
1228      });
1229      fetchCurrentShow();
1230      fetchDJPanel();
1231      pollTimer = setInterval(() => { fetchNowPlaying(); fetchCurrentShow(); }, POLL_MS);
1232      setInterval(fetchDJPanel, 60_000); // DJ panel — 60s keeps well inside Apps Script's daily quota
1233      setInterval(loadSongOverrides, 10 * 60_000); // refresh art overrides every 10 min
1234      tickTimer = setInterval(tickAges, 30_000);
1235    },
1236    togglePlay()       { togglePlay(); },
1237    switchStation(idx) { switchStation(idx); },
1238    lfmConnect()       { lfmConnect(); },
1239    closeSticky() {
1240      stickyVisible = false;
1241      if (stickyEl) stickyEl.classList.remove('wcyt-sticky--show');
1242      const a = getAudio();
1243      a.pause(); a.src = '';
1244      setAudioState('stopped');
1245      savePlayerState();
1246    },
1247    fetchArt(artist, title) { return fetchArt(artist, title); },
1248    getRecentPlays(stationIdx) {
1249      const history   = stationIdx === 1 ? songHistory2 : songHistory;
1250      const bsiRecent = stationIdx === 1 ? bsiRecent2   : bsiRecent1;
1251      const seen = new Set(history.map(s => artCacheKey(s.artist, s.title)));
1252      const merged = [
1253        ...history,
1254        ...bsiRecent.filter(s => !seen.has(artCacheKey(s.artist, s.title))),
1255      ];
1256      return merged.map(s => ({
1257        artist: s.artist,
1258        title:  s.title,
1259        time:   s.startedAt ? formatTime(s.startedAt) : (s.time || ''),
1260        artUrl: resolvedArt(s),
1261      }));
1262    },
1263    _getState()        { return { currentSong, songHistory, songHistory2, bsiRecent1, bsiRecent2, artCache, audioState, activeStation, currentShowWCYT, currentShow2 }; },
1264    _mockSong(artist, title) { handleNewTitle(`${artist} - ${title}`); },
1265  };
1266})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.