1(function () { 2 const slogans = [ 3 'RQST Radio â An Orchestra on the Air.', 4 'Two Signals, One Musical Obsession.', 5 'More Music... More Styles!', 6 'More Music, More Styles, Less Noise.', 7 'RQST Radio â The House of Good Music.', 8 'Welcome to RQST! Thanks for Visiting!', 9 'A Free, Non-Commercial Project.', 10 'The Music of Your Memories.', 11 'Check the Programming Schedule.', 12 'Have a Nice Day!' 13 ]; 14 let sloganIndex = 0; 15 let sloganFadeTimer = 0; 16 const metadataState = { 1: 'unknown', 2: 'unknown' }; 17 const audioState = { 1: 'standby', 2: 'standby' }; 18 let lastWeather = null; 19 let alertTestTimer = 0; 20 let alertTestIndex = 0; 21 let activeAlertTestPreset = null; 22 23 const alertTestPresets = { 24 storm: { 25 icon: 'ð©ï¸', label: 'THUNDERSTORM ALERT', 26 title: 'TEST MODE â Local thunderstorm alert', kind: 'condition' 27 }, 28 heat: { 29 icon: 'âï¸', label: 'HIGH TEMPERATURE ALERT', 30 title: 'TEST MODE â High local temperature alert', kind: 'condition' 31 }, 32 cold: { 33 icon: 'ð¥¶', label: 'LOW TEMPERATURE ALERT', 34 title: 'TEST MODE â Low local temperature alert', kind: 'condition' 35 }, 36 severe: { 37 icon: 'â ï¸', label: 'SEVERE WEATHER ALERT', 38 title: 'TEST MODE â Severe local weather alert', kind: 'condition' 39 }, 40 official: { 41 icon: 'â ï¸', label: 'OFFICIAL WEATHER ALERT', 42 title: 'TEST MODE â Official local weather alert', kind: 'official' 43 } 44 }; 45 const alertTestSequence = ['storm', 'heat', 'cold', 'severe', 'official']; 46 47 const audioLabels = { 48 standby: 'STAND BY', loading: 'CONNECTING', ready: 'STAND BY', online: 'ONLINE', 49 buffering: 'BUFFERING', error: 'ERROR', offline: 'OFFLINE' 50 }; 51 52 const weatherDescriptions = { 53 0: ['âï¸', 'Clear'], 1: ['ð¤ï¸', 'Mostly clear'], 2: ['â ', 'Partly cloudy'], 54 3: ['âï¸', 'Cloudy'], 45: ['ð«ï¸', 'Fog'], 48: ['ð«ï¸', 'Rime fog'], 55 51: ['ð¦ï¸', 'Light drizzle'], 53: ['ð¦ï¸', 'Drizzle'], 55: ['ð§ï¸', 'Heavy drizzle'], 56 56: ['ð§ï¸', 'Freezing drizzle'], 57: ['ð§ï¸', 'Heavy freezing drizzle'], 57 61: ['ð¦ï¸', 'Light rain'], 63: ['ð§ï¸', 'Rain'], 65: ['ð§ï¸', 'Heavy rain'], 58 66: ['ð§ï¸', 'Freezing rain'], 67: ['ð§ï¸', 'Heavy freezing rain'], 59 71: ['ð¨ï¸', 'Light snow'], 73: ['ð¨ï¸', 'Snow'], 75: ['âï¸', 'Heavy snow'], 77: ['ð¨ï¸', 'Snow grains'], 60 80: ['ð¦ï¸', 'Light rain showers'], 81: ['ð§ï¸', 'Rain showers'], 82: ['ð§ï¸', 'Heavy rain showers'], 61 85: ['ð¨ï¸', 'Snow showers'], 86: ['âï¸', 'Heavy snow showers'], 62 95: ['âï¸', 'Thunderstorm'], 96: ['âï¸', 'Thunderstorm with hail'], 99: ['âï¸', 'Severe thunderstorm with hail'] 63 }; 64 65 function cfg() { return window.RQST_CONFIG || {}; } 66 67 function getAlertTestMode() { 68 const params = new URLSearchParams(window.location.search); 69 return String(params.get('alertTest') || '').trim().toLowerCase(); 70 } 71 72 function getAlertTestPreset() { 73 return activeAlertTestPreset; 74 } 75 76 function renderAlertTestPreset(preset) { 77 const line = document.getElementById('local-service-line'); 78 if (line) line.hidden = false; 79 renderLocalAlert(preset); 80 } 81 82 function initAlertTest() { 83 const mode = getAlertTestMode(); 84 window.clearInterval(alertTestTimer); 85 alertTestTimer = 0; 86 activeAlertTestPreset = null; 87 88 if (!mode) return; 89 90 if (mode === 'cycle') { 91 const showNext = () => { 92 const key = alertTestSequence[alertTestIndex % alertTestSequence.length]; 93 alertTestIndex += 1; 94 activeAlertTestPreset = alertTestPresets[key]; 95 renderAlertTestPreset(activeAlertTestPreset); 96 }; 97 showNext(); 98 alertTestTimer = window.setInterval(showNext, 4000); 99 return; 100 } 101 102 const preset = alertTestPresets[mode]; 103 if (preset) { 104 activeAlertTestPreset = preset; 105 renderAlertTestPreset(activeAlertTestPreset); 106 return; 107 } 108 109 console.warn(`[RQST] unknown alertTest value: ${mode}`); 110 } 111 112 function rotateSlogan() { 113 const el = document.getElementById('slogan-rotator'); 114 if (!el) return; 115 116 window.clearTimeout(sloganFadeTimer); 117 el.classList.add('is-fading'); 118 119 sloganFadeTimer = window.setTimeout(() => { 120 sloganIndex = (sloganIndex + 1) % slogans.length; 121 el.textContent = `⦠${slogans[sloganIndex]}`; 122 123 // Dos frames garantizan que el navegador pinte primero el estado invisible 124 // y luego ejecute el fade-in del mensaje nuevo. 125 requestAnimationFrame(() => requestAnimationFrame(() => { 126 el.classList.remove('is-fading'); 127 })); 128 }, 620); 129 } 130 131 function formatLocalDate(now) { 132 return new Intl.DateTimeFormat('en-GB', { 133 weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' 134 }).format(now).replace(/\./g, ''); 135 } 136 137 function updateClocks() { 138 const now = new Date(); 139 const gmtEl = document.getElementById('gmt-time'); 140 const localEl = document.getElementById('local-time'); 141 const footerTimeEl = document.getElementById('footer-local-time'); 142 const footerDateEl = document.getElementById('local-date'); 143 if (gmtEl) gmtEl.textContent = now.toUTCString().slice(17, 22);
144 if (localEl) localEl.textContent = now.toTimeString().substring(0, 5); 145 if (footerTimeEl) footerTimeEl.textContent = `ð ${now.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })}`; 146 if (footerDateEl) footerDateEl.textContent = `ð ${formatLocalDate(now)}`; 147 } 148 149 function initPanels() { 150 const panel = document.getElementById('schedule-panel'); 151 const openButton = document.getElementById('open-schedule'); 152 const closeButton = document.getElementById('close-schedule'); 153 const label = openButton?.querySelector('.schedule-access-label'); 154 155 const setScheduleOpen = (isOpen) => { 156 panel?.classList.toggle('open', isOpen); 157 openButton?.classList.toggle('is-open', isOpen); 158 openButton?.setAttribute('aria-expanded', String(isOpen)); 159 openButton?.setAttribute('aria-label', isOpen ? 'Close program schedule' : 'Open program schedule'); 160 if (label) label.textContent = isOpen ? 'Close Schedule' : 'Program Schedule'; 161 }; 162 163 openButton?.addEventListener('click', () => { 164 setScheduleOpen(!panel?.classList.contains('open')); 165 }); 166 closeButton?.addEventListener('click', () => setScheduleOpen(false)); 167 document.addEventListener('keydown', (event) => { 168 if (event.key === 'Escape' && panel?.classList.contains('open')) setScheduleOpen(false); 169 }); 170 } 171 172 function initCookieBanner() { 173 const banner = document.getElementById('cookie-banner'); 174 const btn = document.getElementById('accept-cookies'); 175 if (!banner || !btn) return; 176 177 const storageKey = 'rqstCookiesAccepted'; 178 let accepted = false; 179 180 // En algunos navegadores localStorage puede estar bloqueado, especialmente 181 // al probar la web desde file://. El aviso debe seguir funcionando aunque 182 // no sea posible recordar la aceptación entre recargas. 183 try { 184 accepted = window.localStorage.getItem(storageKey) === '1'; 185 } catch (error) { 186 console.warn('RQST: local storage is unavailable for the notice.', error); 187 } 188 189 banner.hidden = accepted; 190 191 btn.addEventListener('click', () => { 192 banner.hidden = true; 193 banner.classList.add('is-dismissed');
194 try { 195 window.localStorage.setItem(storageKey, '1'); 196 } catch (error) { 197 console.warn('RQST: the notice acceptance could not be saved.', error); 198 } 199 }); 200 } 201 202 function getWeatherIndicator(current, daily) { 203 const t = cfg().weather?.thresholds || {}; 204 const code = Number(current.weather_code ?? -1); 205 const temperature = Number(current.temperature_2m ?? 0); 206 const precipitation = Number(daily.precipitation_sum?.[0] || 0); 207 const snowfall = Number(daily.snowfall_sum?.[0] || 0); 208 const maxWind = Number(daily.wind_speed_10m_max?.[0] || current.wind_speed_10m || 0); 209 210 // Condiciones locales destacadas. No se presentan como alertas oficiales. 211 if ([95, 96, 99].includes(code)) { 212 return { 213 icon: 'ð©ï¸', label: 'THUNDERSTORM ALERT', 214 title: 'Thunderstorms in the local forecast', kind: 'condition' 215 }; 216 } 217 if (temperature > (t.warmCurrentC ?? 30)) { 218 return { 219 icon: 'âï¸', label: 'HIGH TEMPERATURE ALERT', 220 title: `Local temperature: ${Math.round(temperature)} °C`, kind: 'condition' 221 }; 222 } 223 if (temperature < (t.coldCurrentC ?? 0)) { 224 return { 225 icon: 'ð¥¶', label: 'LOW TEMPERATURE ALERT', 226 title: `Local temperature: ${Math.round(temperature)} °C`, kind: 'condition' 227 }; 228 } 229 if (precipitation >= (t.heavyRainMmDay || 40) || [65, 82].includes(code) || 230 snowfall >= (t.snowCmDay || 1) || [75, 77, 86].includes(code) || 231 maxWind >= (t.extremeWindKmh || 90)) { 232 return { 233 icon: 'â ï¸', label: 'SEVERE WEATHER ALERT', 234 title: 'Severe conditions estimated in the local forecast', kind: 'condition' 235 }; 236 } 237 return null; 238 } 239 240 function renderLocalAlert(alert) { 241 const indicator = document.getElementById('local-alert-indicator'); 242 if (!indicator) return; 243 244 let icon = indicator.querySelector('.local-alert-icon'); 245 let text = indicator.querySelector('.local-alert-text'); 246 if (!icon || !text) { 247 indicator.replaceChildren(); 248 icon = document.createElement('span'); 249 icon.className = 'local-alert-icon'; 250 icon.setAttribute('aria-hidden', 'true'); 251 text = document.createElement('span'); 252 text.className = 'local-alert-text'; 253 indicator.append(icon, text); 254 } 255 256 if (!alert?.icon) { 257 indicator.hidden = true; 258 icon.textContent = ''; 259 text.textContent = ''; 260 indicator.removeAttribute('title'); 261 indicator.setAttribute('aria-label', ''); 262 indicator.removeAttribute('data-alert-kind'); 263 return; 264 } 265 266 const kind = alert.kind || 'condition'; 267 const fallbackLabel = kind === 'official' ? 'OFFICIAL WEATHER ALERT' : 'LOCAL WEATHER ALERT'; 268 const label = String(alert.label || fallbackLabel).trim() || fallbackLabel; 269 icon.textContent = alert.icon; 270 text.textContent = label; 271 indicator.title = String(alert.title || label); 272 indicator.setAttribute('aria-label', label); 273 indicator.dataset.alertKind = kind; 274 indicator.hidden = false; 275 } 276 277 function renderWeather(data, placeLabel) { 278 const el = document.getElementById('local-weather'); 279 if (!el || !data?.current) return; 280 const current = data.current; 281 const daily = data.daily || {}; 282 const indicator = getAlertTestPreset() || getWeatherIndicator(current, daily); 283 const [icon, description] = weatherDescriptions[Number(current.weather_code)] || ['ð¤ï¸', 'Local weather']; 284 const temp = Math.round(Number(current.temperature_2m)); 285 const place = placeLabel ? `${placeLabel}: ` : ''; 286 el.textContent = `${icon} ${place}${temp} °C · ${description}`; 287 el.title = `${place}${temp} °C · ${description}`; 288 renderLocalAlert(indicator); 289 } 290 291 async function fetchWeather(latitude, longitude, placeLabel) { 292 const params = new URLSearchParams({ 293 latitude: String(latitude), longitude: String(longitude), 294 current: 'temperature_2m,apparent_temperature,precipitation,rain,snowfall,weather_code,wind_speed_10m', 295 daily: 'temperature_2m_max,temperature_2m_min,precipitation_sum,snowfall_sum,wind_speed_10m_max', 296 timezone: 'auto', forecast_days: '1' 297 }); 298 const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params.toString()}`, { cache: 'no-store' }); 299 if (!response.ok) throw new Error(`Weather HTTP ${response.status}`); 300 const data = await response.json(); 301 lastWeather = { latitude, longitude, placeLabel, data };
302 renderWeather(data, placeLabel); 303 } 304 305 function setLocalServiceVisible(visible) { 306 const shouldShow = Boolean(visible || getAlertTestPreset()); 307 const line = document.getElementById('local-service-line'); 308 if (line) line.hidden = !shouldShow; 309 if (!shouldShow) { 310 renderLocalAlert(null); 311 return; 312 } 313 const preset = getAlertTestPreset(); 314 if (preset) renderLocalAlert(preset); 315 } 316 317 function resolveWeatherLocation() { 318 if (!navigator.geolocation) return Promise.resolve(null); 319 return new Promise(resolve => { 320 navigator.geolocation.getCurrentPosition( 321 pos => resolve({ latitude: pos.coords.latitude, longitude: pos.coords.longitude, label: '' }), 322 () => resolve(null), 323 { enableHighAccuracy: false, timeout: cfg().weather?.geolocationTimeoutMs || 7000, maximumAge: 1800000 } 324 ); 325 }); 326 } 327 328 async function updateWeather() { 329 if (cfg().weather?.enabled === false) { 330 setLocalServiceVisible(false); 331 return; 332 } 333 let location = null; 334 try { 335 location = lastWeather || await resolveWeatherLocation(); 336 if (!location) { 337 setLocalServiceVisible(false); 338 return; 339 } 340 // La autorización de ubicación basta para mostrar fecha y hora. 341 // El clima se completa en cuanto responde el servicio meteorológico. 342 setLocalServiceVisible(true); 343 await fetchWeather(location.latitude, location.longitude, location.placeLabel || ''); 344 } catch (error) { 345 if (!location && !lastWeather) setLocalServiceVisible(false); 346 console.warn('[RQST] Local weather could not be updated', error); 347 } 348 } 349
350 function renderStatus(signalId) { 351 const el = document.getElementById(`status-${signalId}`); 352 const line = document.getElementById(`status-line-${signalId}`); 353 if (!el || !line) return; 354 355 const rawState = audioState[signalId] || 'standby'; 356 const aState = rawState === 'ready' ? 'standby' : rawState; 357 const mState = metadataState[signalId] || 'unknown'; 358 const offline = aState === 'error' || aState === 'offline'; 359 const degraded = !offline && mState === 'degraded'; 360 const standby = !offline && !degraded && aState === 'standby'; 361 const buffering = !offline && aState === 'buffering'; 362 const active = !offline && !degraded && !standby && !buffering; 363 364 ['online', 'standby', 'offline', 'degraded', 'buffering'].forEach(name => { 365 const enabled = name === 'online' 366 ? active 367 : name === 'standby' 368 ? standby 369 : name === 'offline' 370 ? offline 371 : name === 'degraded' 372 ? degraded 373 : buffering; 374 el.classList.toggle(name, enabled); 375 line.classList.toggle(name, enabled); 376 }); 377 378 const symbol = offline ? 'X' : 'O'; 379 const text = `${audioLabels[aState] || 'STAND BY'}${degraded ? ' · META' : ''}`; 380 el.innerHTML = `<span class="status-dot" aria-hidden="true">${symbol}</span><span class="status-text"></span>`; 381 el.querySelector('.status-text').textContent = text; 382 } 383 384 function setLedValue(el, rawValue) { 385 if (!el) return; 386 const value = String(rawValue || 'No information').trim() || 'No information'; 387 const renderToken = String((Number(el.dataset.rollerToken || 0) + 1)); 388 el.dataset.rollerToken = renderToken; 389 el.replaceChildren(); 390 391 const track = document.createElement('span'); 392 track.className = 'led-scroll-track'; 393 const first = document.createElement('span'); 394 first.className = 'led-scroll-copy'; 395 first.textContent = value; 396 track.appendChild(first); 397 el.appendChild(track); 398 el.setAttribute('aria-label', value); 399 el.title = value; 400 401 const measureAndActivate = () => { 402 if (!el.isConnected || el.dataset.rollerToken !== renderToken) return; 403 track.classList.remove('is-scrolling'); 404 track.querySelectorAll('.led-scroll-copy[aria-hidden="true"]').forEach(node => node.remove()); 405 track.style.removeProperty('--led-roll-distance'); 406 track.style.removeProperty('--led-roll-duration'); 407 408 const available = Math.floor(el.clientWidth || el.getBoundingClientRect().width); 409 const textWidth = Math.ceil(Math.max( 410 first.scrollWidth || 0, 411 first.getBoundingClientRect().width || 0 412 )); 413 if (!available || !textWidth || textWidth <= available + 2) return; 414 415 const second = first.cloneNode(true); 416 second.setAttribute('aria-hidden', 'true'); 417 track.appendChild(second); 418 const gap = 42; 419 const distance = textWidth + gap; 420 const duration = Math.max(9, Math.min(28, distance / 24)); 421 track.style.setProperty('--led-roll-gap', `${gap}px`); 422 track.style.setProperty('--led-roll-distance', `${distance}px`); 423 track.style.setProperty('--led-roll-duration', `${duration.toFixed(2)}s`); 424 // Force a layout pass so restarting the same text also restarts the animation. 425 void track.offsetWidth; 426 track.classList.add('is-scrolling'); 427 }; 428 429 // Two frames allow the compact grid, fonts and mobile layout to settle first. 430 requestAnimationFrame(() => requestAnimationFrame(measureAndActivate)); 431 // Algunos móviles terminan de ajustar el ancho del display un poco después. 432 // Reintentamos para recuperar el roller cuando el layout termina de asentarse. 433 window.setTimeout(measureAndActivate, 120); 434 window.setTimeout(measureAndActivate, 480); 435 if (document.fonts?.ready) document.fonts.ready.then(measureAndActivate).catch(() => {}); 436 } 437 438 function refreshAllRollers() { 439 document.querySelectorAll('.led-value').forEach(el => { 440 const value = el.getAttribute('aria-label') || el.textContent; 441 setLedValue(el, value); 442 }); 443 } 444 445 let resizeTimer = 0; 446 window.addEventListener('resize', () => { 447 clearTimeout(resizeTimer); 448 resizeTimer = setTimeout(refreshAllRollers, 180); 449 }); 450 451 452 if ('ResizeObserver' in window) { 453 const ledResizeObserver = new ResizeObserver(entries => { 454 if (entries.some(entry => entry.target.classList.contains('led-display'))) { 455 clearTimeout(resizeTimer); 456 resizeTimer = setTimeout(refreshAllRollers, 90); 457 } 458 }); 459 document.querySelectorAll('.led-display').forEach(display => ledResizeObserver.observe(display)); 460 } 461 462 window.RQST_UI = { 463 init() { 464 initPanels(); initCookieBanner(); updateClocks(); initAlertTest(); updateWeather(); 465 setInterval(updateClocks, 1000); 466 setInterval(rotateSlogan, 5200); 467 setInterval(updateWeather, cfg().weather?.refreshMs || 900000); 468 renderStatus(1); renderStatus(2); 469 refreshAllRollers(); 470 },
471 setLedValueById(id, value) { setLedValue(document.getElementById(id), value); }, 472 setProgram(signalId, value) { setLedValue(document.getElementById(`led-program-${signalId}`), value); }, 473 setNowPlaying(signalId, meta, degraded = false) { 474 const display = document.getElementById(`roller-${signalId}`); 475 if (!display) return; 476 const values = { 477 program: meta?.program || 'No information', artist: meta?.artist || 'No information', 478 title: meta?.title || 'No information', album: meta?.album || 'No information' 479 }; 480 Object.entries(values).forEach(([field, value]) => { 481 const id = field === 'program' ? `led-program-${signalId}` : `${field}-${signalId}`; 482 setLedValue(document.getElementById(id), value); 483 }); 484 display.classList.toggle('is-degraded', Boolean(degraded)); 485 }, 486 setRoller(signalId, text) { 487 this.setNowPlaying(signalId, { program: text || 'Loading information...', artist: 'â', title: 'â', album: 'â' }, true); 488 }, 489 setStatus(signalId, online) { this.setAudioState(signalId, online ? 'online' : 'error'); }, 490 setAudioState(signalId, state) { audioState[signalId] = state || 'standby'; renderStatus(signalId); }, 491 setMetadataState(signalId, state) { metadataState[signalId] = state || 'unknown'; renderStatus(signalId); }, 492 setPlaying(signalId, playing) { document.getElementById(`player-card-${signalId}`)?.classList.toggle('is-playing', playing); }, 493 setOfficialLocalAlert(alert) { 494 renderLocalAlert(alert ? { ...alert, kind: 'official' } : null); 495 } 496 }; 497})();
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.