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">▶</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 →</a> 868 </div> 869 ` : `<div class="wcyt-hero-loading">Loading…</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">·</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 →</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">·</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…</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 ? ' · ' : ''} 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 →</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…</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>⚠</span> Metadata unavailable — 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 : '▶'} 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">✕</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, '&') 1199 .replace(/</g, '<') 1200 .replace(/>/g, '>') 1201 .replace(/"/g, '"'); 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.