PageSourceSearch

https://rqstradio.pages.dev/js/ui.js?v=71.1-a17

js rqstradio.pages.dev collected 2026-10-03 05:54:36 UTC 20,073 bytes, 497 lines download raw bytes

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.