PageSourceSearch

https://radiobomsucessoweb.com.br/app/assets/js/app.js?v=tkj8io

js radiobomsucessoweb.com.br collected 2026-10-02 01:18:51 UTC 22,685 bytes, 654 lines download raw bytes

1/* WebApp da rádio. Vanilla, sem dependências.
2   O <audio> vive no shell e nunca é recriado: a navegação troca só o
3   conteúdo, então o som não para ao mudar de página. */
4(function () {
5  'use strict';
6
7  var CONFIG = JSON.parse(document.getElementById('appConfig').textContent);
8  var ROUTES = CONFIG.routes;
9  var TEXT = CONFIG.strings;
10  var HOME = 'home';
11
12  var audio = document.getElementById('audio');
13  var content = document.getElementById('content');
14  var appTitle = document.getElementById('appTitle');
15
16  /* Player */
17  function play() {
18    if (!audio.src) audio.src = CONFIG.stream;
19    var started = audio.play();
20    if (started && started.catch) started.catch(function () {});
21  }
22
23  function setPlaying(on) {
24    document.body.classList.toggle('is-playing', on);
25    document.querySelectorAll('.js-play').forEach(function (button) {
26      button.setAttribute('aria-label', on ? TEXT.pause : TEXT.play);
27    });
28    if ('mediaSession' in navigator) navigator.mediaSession.playbackState = on ? 'playing' : 'paused';
29  }
30
31  audio.addEventListener('play', function () { setPlaying(true); });
32  audio.addEventListener('playing', function () { setPlaying(true); });
33  audio.addEventListener('pause', function () { setPlaying(false); });
34  audio.addEventListener('ended', function () { setPlaying(false); });
35
36  document.addEventListener('click', function (event) {
37    if (!event.target.closest('.js-play')) return;
38    if (audio.paused) { play(); } else { audio.pause(); }
39  });
40
41  /* Tocando agora: no webapp a arte e sempre a logo da radio, na tela e na
42     tela de bloqueio do sistema. Sem capa de musica em lugar nenhum. */
43  var mediaAtual = null;
44
45  function updateMediaSession(song) {
46    if (!('mediaSession' in navigator)) return;
47
48    // Refazer a metadata a cada volta do relogio faz a notificacao piscar no
49    // Android, entao so muda quando a musica muda
50    if ((song || '') === mediaAtual) return;
51
52    mediaAtual = (song || '');
53
54    try {
55      navigator.mediaSession.metadata = new MediaMetadata({
56        title: CONFIG.name,
57        artist: song || '',
58        artwork: [{ src: CONFIG.logo, sizes: '512x512', type: 'image/png' }]
59      });
60      navigator.mediaSession.setActionHandler('play', play);
61      navigator.mediaSession.setActionHandler('pause', function () { audio.pause(); });
62    } catch (error) {}
63  }
64
65  // Sem esperar a primeira musica: a logo ja identifica quem esta tocando
66  updateMediaSession('');
67
68  function setNowPlaying(song) {
69    ['nowPlaying', 'miniNowPlaying'].forEach(function (id) {
70      var element = document.getElementById(id);
71      if (element) element.textContent = song;
72    });
73
74    updateMediaSession(song);
75  }
76
77  // A API devolve o caractere de substituicao onde o acento se perdeu na origem,
78  // as vezes escapado como texto: melhor a letra faltando do que o codigo cru
79  function cleanSong(text) {
80    return text.replace(/\\u[0-9a-fA-F]{4}/g, '').replace(/�/g, '').replace(/\s{2,}/g, ' ').trim();
81  }
82
83  function fetchNowPlaying() {
84    fetch(CONFIG.coverApi, { cache: 'no-store' })
85      .then(function (response) { return response.json(); })
86      .then(function (data) {
87        if (!data || data.success === false) return;
88        var song = cleanSong((data.artist && data.track) ? (data.artist + ' - ' + data.track) : (data.title || ''));
89        if (song) setNowPlaying(song);
90      })
91      .catch(function () {});
92  }
93
94  fetchNowPlaying();
95  setInterval(fetchNowPlaying, 30000);
96
97  /* Toast */
98  var toast = document.getElementById('toast');
99  var toastTimer = null;
100
101  function showToast(message) {
102    toast.textContent = message;
103    toast.classList.add('show');
104    clearTimeout(toastTimer);
105    toastTimer = setTimeout(function () { toast.classList.remove('show'); }, 2800);
106  }
107
108  /* Confirmação de envio: ocupa a tela inteira, o toast é discreto demais aqui */
109  var feedback = document.getElementById('feedback');
110
111  function showFeedback(title, text, done) {
112    document.getElementById('feedbackTitle').textContent = title;
113    document.getElementById('feedbackText').textContent = text;
114    feedback.hidden = false;
115
116    requestAnimationFrame(function () { feedback.classList.add('show'); });
117
118    setTimeout(function () {
119      feedback.classList.remove('show');
120      setTimeout(function () { feedback.hidden = true; }, 240);
121      if (done) done();
122    }, 2200);
123  }
124
125  /* Compartilhar */
126  document.addEventListener('click', function (event) {
127    if (!event.target.closest('.js-share')) return;
128    event.preventDefault();
129
130    var data = { title: CONFIG.name, text: TEXT.shareText, url: location.origin + '/app/' };
131
132    if (navigator.share) {
133      navigator.share(data).catch(function () {});
134    } else if (navigator.clipboard) {
135      navigator.clipboard.writeText(data.url).then(function () { showToast(TEXT.linkCopied); }).catch(function () {});
136    }
137  });
138
139  /* Menu */
140  var menuButton = document.getElementById('menuBtn');
141  var backdrop = document.getElementById('menuBackdrop');
142
143  function setMenu(open) {
144    document.body.classList.toggle('menu-open', open);
145    menuButton.setAttribute('aria-expanded', open ? 'true' : 'false');
146    backdrop.hidden = !open;
147  }
148
149  menuButton.addEventListener('click', function () { setMenu(!document.body.classList.contains('menu-open')); });
150  backdrop.addEventListener('click', function () { setMenu(false); });
151  document.addEventListener('keydown', function (event) { if (event.key === 'Escape') setMenu(false); });
152
153  /* Cookies */
154  function setCookie(name, value, days) {
155    var expires = new Date(Date.now() + days * 86400000).toUTCString();
156    document.cookie = name + '=' + value + '; expires=' + expires + '; path=/; SameSite=Lax';
157  }
158
159  function getCookie(name) {
160    return document.cookie.split('; ').reduce(function (found, pair) {
161      var parts = pair.split('=');
162      return parts[0] === name ? parts.slice(1).join('=') : found;
163    }, null);
164  }
165
166  function autoplayOn() {
167    var saved = getCookie('autoP');
168    return saved === null ? CONFIG.autoplay : saved === '1';
169  }
170
171  /* Programação: as grades dos 7 dias já vêm do servidor, aqui só troca a visível */
172  document.addEventListener('click', function (event) {
173    var tab = event.target.closest('.prog-day');
174    if (!tab) return;
175
176    var day = tab.getAttribute('data-day');
177
178    document.querySelectorAll('.prog-day').forEach(function (item) {
179      var on = item === tab;
180      item.classList.toggle('is-active', on);
181      item.setAttribute('aria-selected', on ? 'true' : 'false');
182    });
183
184    document.querySelectorAll('.prog-list').forEach(function (list) {
185      list.hidden = list.getAttribute('data-day') !== day;
186    });
187  });
188
189  /* Recados: a primeira página vem renderizada, o scroll busca as seguintes */
190  var recados = { loading: false, observer: null };
191
192  function skeletons(total) {
193    var html = '';
194    for (var i = 0; i < total; i++) {
195      html += '<li class="recado is-skeleton"><span class="sk sk-line"></span><span class="sk sk-line sk-short"></span><span class="sk sk-meta"></span></li>';
196    }
197    return html;
198  }
199
200  function escapeHtml(value) {
201    return String(value).replace(/[&<>"]/g, function (char) {
202      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[char];
203    });
204  }
205
206  function loadRecados() {
207    var list = document.getElementById('recadosList');
208    if (!list || recados.loading || list.getAttribute('data-more') !== '1') return;
209
210    recados.loading = true;
211
212    var next = parseInt(list.getAttribute('data-loaded'), 10) + 1;
213    var placeholder = document.createElement('div');
214    placeholder.className = 'rec-skeletons';
215    placeholder.innerHTML = skeletons(3);
216    list.after(placeholder);
217
218    fetch('/app/api/recados.php?page=' + next, { headers: { 'X-Requested-With': 'fetch' } })
219      .then(function (response) { return response.json(); })
220      .then(function (data) {
221        placeholder.remove();
222        list.setAttribute('data-loaded', next);
223        list.setAttribute('data-more', data.hasMore ? '1' : '0');
224
225        (data.items || []).forEach(function (item) {
226          list.insertAdjacentHTML('beforeend',
227            '<li class="recado"><p class="recado-text">' + escapeHtml(item.text) + '</p>' +
228            '<div class="recado-meta"><strong>' + escapeHtml(item.author) + '</strong>' +
229            '<span>' + escapeHtml(item.date) + '</span></div></li>');
230        });
231
232        recados.loading = false;
233        if (sentinelVisible()) loadRecados();
234      })
235      .catch(function () {
236        placeholder.remove();
237        recados.loading = false;
238      });
239  }
240
241  // O observer só dispara na entrada: se a sentinela continuar à vista depois
242  // do lote, a rolagem travaria sem essa recheca manual
243  function sentinelVisible() {
244    var sentinel = document.getElementById('recadosSentinel');
245    if (!sentinel) return false;
246
247    var box = sentinel.getBoundingClientRect();
248    return box.top <= (window.innerHeight || 0) + 300;
249  }
250
251  function initRecados() {
252    var sentinel = document.getElementById('recadosSentinel');
253    if (!sentinel || !('IntersectionObserver' in window)) return;
254
255    if (recados.observer) recados.observer.disconnect();
256
257    recados.observer = new IntersectionObserver(function (entries) {
258      if (entries[0].isIntersecting) loadRecados();
259    }, { root: content.scrollHeight > content.clientHeight ? content : null, rootMargin: '300px' });
260
261    recados.observer.observe(sentinel);
262  }
263
264  /* Timer de desligar */
265  var STEPS = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60];
266  var timer = { id: null, tick: null, endsAt: 0, index: STEPS.indexOf(30) };
267
268  function timerRunning() { return !!timer.id; }
269
270  function remaining(ms) {
271    var total = Math.max(0, Math.ceil(ms / 1000));
272    var seconds = total % 60;
273    return Math.floor(total / 60) + ':' + (seconds < 10 ? '0' : '') + seconds;
274  }
275
276  function startTimer() {
277    var minutes = STEPS[timer.index];
278
279    if (audio.paused) play();
280
281    timer.endsAt = Date.now() + minutes * 60000;
282    timer.id = setTimeout(function () {
283      audio.pause();
284      stopTimer();
285      showToast(TEXT.timerDone);
286    }, minutes * 60000);
287    timer.tick = setInterval(syncTimer, 1000);
288
289    syncTimer();
290  }
291
292  function stopTimer() {
293    if (timer.id) { clearTimeout(timer.id); timer.id = null; }
294    if (timer.tick) { clearInterval(timer.tick); timer.tick = null; }
295    syncTimer();
296  }
297
298  function positionStrip(animate) {
299    var picker = document.getElementById('timerPicker');
300    var strip = document.getElementById('timerStrip');
301    if (!picker || !strip || !strip.children.length) return;
302
303    var slot = picker.clientWidth / 3;
304    strip.classList.toggle('snapping', !!animate);
305    strip.style.transform = 'translateX(' + (slot * (1 - timer.index)) + 'px)';
306
307    Array.prototype.forEach.call(strip.children, function (item, i) {
308      item.classList.toggle('is-center', i === timer.index);
309    });
310  }
311
312  function syncTimer() {
313    var picker = document.getElementById('timerPicker');
314    if (!picker) return;
315
316    var countdown = document.getElementById('timerCountdown');
317    var unit = document.getElementById('timerUnit');
318    var button = document.getElementById('timerStart');
319    var running = timerRunning();
320
321    picker.hidden = running;
322    countdown.hidden = !running;
323    unit.textContent = running ? TEXT.timerRemaining : TEXT.timerMinutes;
324    button.textContent = running ? TEXT.timerCancel : TEXT.timerStart;
325
326    if (running) {
327      countdown.textContent = remaining(timer.endsAt - Date.now());
328    } else {
329      positionStrip(false);
330    }
331  }
332
333  function initTimer() {
334    var picker = document.getElementById('timerPicker');
335    var strip = document.getElementById('timerStrip');
336    var button = document.getElementById('timerStart');
337    if (!picker) return;
338
339    var dragging = false;
340    var dragged = false;
341    var startX = 0;
342    var baseX = 0;
343    var slot = 0;
344
345    strip.innerHTML = STEPS.map(function (value) { return '<div class="timer-item">' + value + '</div>'; }).join('');
346
347    Array.prototype.forEach.call(strip.children, function (item, i) {
348      item.addEventListener('click', function () {
349        if (timerRunning() || dragged) return;
350        timer.index = i;
351        positionStrip(true);
352      });
353    });
354
355    picker.addEventListener('pointerdown', function (event) {
356      if (timerRunning()) return;
357      dragging = true;
358      dragged = false;
359      slot = picker.clientWidth / 3;
360      startX = event.clientX;
361      baseX = slot * (1 - timer.index);
362      strip.classList.remove('snapping');
363      try { picker.setPointerCapture(event.pointerId); } catch (error) {}
364    });
365
366    picker.addEventListener('pointermove', function (event) {
367      if (!dragging) return;
368      var delta = event.clientX - startX;
369      if (Math.abs(delta) > 4) dragged = true;
370      strip.style.transform = 'translateX(' + (baseX + delta) + 'px)';
371    });
372
373    function release(event) {
374      if (!dragging) return;
375      dragging = false;
376      var delta = ((event && event.clientX) || startX) - startX;
377      var moved = Math.round(-delta / slot);
378      timer.index = Math.min(STEPS.length - 1, Math.max(0, timer.index + moved));
379      positionStrip(true);
380    }
381
382    picker.addEventListener('pointerup', release);
383    picker.addEventListener('pointercancel', release);
384    button.addEventListener('click', function () { timerRunning() ? stopTimer() : startTimer(); });
385
386    syncTimer();
387  }
388
389  /* Configurações */
390  function initConfig() {
391    var toggle = document.getElementById('autoplaySwitch');
392    if (!toggle) return;
393
394    function paint(on) {
395      toggle.classList.toggle('is-on', on);
396      toggle.setAttribute('aria-checked', on ? 'true' : 'false');
397    }
398
399    paint(autoplayOn());
400
401    toggle.addEventListener('click', function () {
402      var on = !autoplayOn();
403      setCookie('autoP', on ? '1' : '0', 365);
404      paint(on);
405      showToast(on ? TEXT.autoplayOn : TEXT.autoplayOff);
406    });
407  }
408
409  /* Formulários de recado e pedido */
410  function fieldOf(form, name) {
411    var input = form.elements[name];
412    return input ? input.closest('.field') : null;
413  }
414
415  function clearErrors(form) {
416    form.querySelectorAll('.field.has-error').forEach(function (field) {
417      field.classList.remove('has-error');
418      field.querySelector('.field-error').textContent = '';
419    });
420  }
421
422  document.addEventListener('input', function (event) {
423    var input = event.target;
424
425    if (input.name === 'message') {
426      var counter = input.closest('.field').querySelector('.js-count');
427      if (counter) counter.textContent = input.value.length;
428    }
429
430    var field = input.closest('.field');
431    if (field && field.classList.contains('has-error')) {
432      field.classList.remove('has-error');
433      field.querySelector('.field-error').textContent = '';
434    }
435  });
436
437  document.addEventListener('submit', function (event) {
438    var form = event.target.closest('.js-send');
439    if (!form) return;
440
441    event.preventDefault();
442
443    var button = form.querySelector('button[type="submit"]');
444    button.disabled = true;
445    clearErrors(form);
446
447    fetch(form.action, { method: 'POST', body: new FormData(form) })
448      .then(function (response) {
449        return response.json().then(function (data) { return { ok: response.ok, data: data }; });
450      })
451      .then(function (result) {
452        button.disabled = false;
453
454        if (result.ok && result.data.success) {
455          form.reset();
456          form.querySelectorAll('.js-count').forEach(function (counter) { counter.textContent = '0'; });
457          showFeedback(form.getAttribute('data-success'), form.getAttribute('data-success-text'), function () {
458            go(form.getAttribute('data-redirect'));
459          });
460          return;
461        }
462
463        var errors = result.data.errors;
464
465        if (errors) {
466          Object.keys(errors).forEach(function (name) {
467            var field = fieldOf(form, name);
468            if (!field) return;
469            field.classList.add('has-error');
470            field.querySelector('.field-error').textContent = errors[name];
471          });
472          return;
473        }
474
475        showToast(result.data.error || TEXT.sendError);
476      })
477      .catch(function () {
478        button.disabled = false;
479        showToast(TEXT.connectionError);
480      });
481  });
482
483  /* Roteador: as URLs são reais (/app/home, /app/programacao, ...) e o
484     servidor devolve o mesmo fragmento do primeiro carregamento */
485  function routeFromPath() {
486    var name = location.pathname.replace(/^\/app\/?/, '').replace(/\/+$/, '');
487    return ROUTES[name] ? name : HOME;
488  }
489
490  function setActive(name) {
491    document.querySelectorAll('.nav-link').forEach(function (link) {
492      link.classList.toggle('is-active', link.getAttribute('data-page') === name);
493    });
494
495    document.body.classList.toggle('home', name === HOME);
496    document.body.setAttribute('data-page', name);
497    appTitle.textContent = name === HOME ? CONFIG.name : ROUTES[name];
498    document.title = name === HOME ? CONFIG.name : ROUTES[name] + ' · ' + CONFIG.name;
499  }
500
501  function onRender(name) {
502    if (name === 'timer') initTimer();
503    else if (name === 'config') initConfig();
504    else if (name === 'recados') initRecados();
505  }
506
507  function loadPage(name) {
508    setActive(name);
509    content.classList.add('is-loading');
510
511    fetch('/app/' + name + '?fragment=1', { headers: { 'X-Requested-With': 'fetch' } })
512      .then(function (response) {
513        if (!response.ok) throw new Error(response.status);
514        return response.text();
515      })
516      .then(function (html) {
517        content.innerHTML = html;
518        content.classList.remove('is-loading');
519        content.scrollTop = 0;
520        onRender(name);
521      })
522      .catch(function () {
523        content.classList.remove('is-loading');
524        content.innerHTML = '<section class="page"><p class="muted">' + TEXT.pageError + '</p></section>';
525      });
526  }
527
528  function go(name) {
529    if (!ROUTES[name]) return;
530    history.pushState({ page: name }, '', '/app/' + name);
531    loadPage(name);
532  }
533
534  // Só links navegam: o <body> também carrega data-page e casaria com o seletor
535  document.addEventListener('click', function (event) {
536    var link = event.target.closest('a[data-page]');
537    if (!link) return;
538
539    event.preventDefault();
540    setMenu(false);
541
542    var name = link.getAttribute('data-page');
543    if (name === routeFromPath()) return;
544
545    go(name);
546  });
547
548  window.addEventListener('popstate', function () { loadPage(routeFromPath()); });
549
550  onRender(routeFromPath());
551
552  /* Auto-play */
553  if (autoplayOn()) {
554    audio.preload = 'auto';
555    audio.src = CONFIG.stream;
556    play();
557
558    // Navegador que bloqueia som sem gesto: começa na primeira interação
559    var events = ['pointerdown', 'keydown'];
560    var kick = function (event) {
561      if (event.target.closest && event.target.closest('.js-play')) return;
562      events.forEach(function (name) { document.removeEventListener(name, kick, true); });
563      if (audio.paused) play();
564    };
565    events.forEach(function (name) { document.addEventListener(name, kick, true); });
566  }
567
568  /* Instalação: a chamada no topo só aparece enquanto o app não estiver instalado */
569  var installBar = document.getElementById('installBar');
570  var installHint = document.getElementById('installHint');
571  var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
572  var standalone = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
573  var installed = standalone;
574  // The offer the head caught before this file even ran
575  var installPrompt = window.brasInstall || null;
576
577  function dismissed() {
578    try { return localStorage.getItem('appInstallDismissed') === '1'; } catch (error) { return false; }
579  }
580
581  function showInstallBar() {
582    installBar.hidden = installed || dismissed();
583  }
584
585  showInstallBar();
586
587  window.addEventListener('bras:install', function () {
588    installPrompt = window.brasInstall;
589    showInstallBar();
590  });
591
592  // Sem o evento nativo (iOS e afins) resta ensinar o caminho pelo menu do navegador
593  document.getElementById('installBtn').addEventListener('click', function () {
594    if (installPrompt) {
595      installPrompt.prompt();
596      installPrompt.userChoice.then(function (choice) {
597        if (choice && choice.outcome === 'accepted') installBar.hidden = true;
598      }).finally(function () { installPrompt = null; window.brasInstall = null; });
599      return;
600    }
601
602    installHint.querySelector(isIOS ? '.install-hint__ios' : '.install-hint__other').hidden = false;
603    installHint.hidden = false;
604  });
605
606  document.getElementById('installDismiss').addEventListener('click', function () {
607    installBar.hidden = true;
608    installHint.hidden = true;
609    try { localStorage.setItem('appInstallDismissed', '1'); } catch (error) {}
610  });
611
612  document.getElementById('installHintClose').addEventListener('click', function () {
613    installHint.hidden = true;
614  });
615
616  window.addEventListener('appinstalled', function () {
617    installed = true;
618    installBar.hidden = true;
619    installHint.hidden = true;
620    showToast(TEXT.installed);
621    fetch('/app/set.php').catch(function () {});
622  });
623
624  /* O bate-papo ocupa a altura exata da tela: o mini player fica por cima e o
625     que estiver antes do .app, como a barra de instalacao, empurra tudo */
626  function appMetrics() {
627    var mini = document.querySelector('.miniplayer');
628    var app = document.querySelector('.app');
629    var height = (mini && getComputedStyle(mini).display !== 'none' ? Math.round(mini.getBoundingClientRect().height) : 0);
630    var above = (app ? Math.round(app.getBoundingClientRect().top + window.scrollY) : 0);
631
632    document.documentElement.style.setProperty('--mini-h', height + 'px');
633    document.documentElement.style.setProperty('--above-h', above + 'px');
634  }
635
636  appMetrics();
637  window.addEventListener('resize', appMetrics);
638  window.addEventListener('load', appMetrics);
639
640  /* Service worker */
641  if ('serviceWorker' in navigator) {
642    window.addEventListener('load', function () {
643      navigator.serviceWorker.register('/app/sw.js', { scope: '/app/' }).catch(function () {});
644    });
645  }
646
647  /* Conteúdo do app não é para ser copiado do navegador */
648  document.addEventListener('contextmenu', function (event) { event.preventDefault(); });
649  document.addEventListener('keydown', function (event) {
650    var key = event.key.toLowerCase();
651    var blocked = event.key === 'F12' || (event.ctrlKey && event.shiftKey && key === 'i') || (event.ctrlKey && (key === 'u' || key === 's'));
652    if (blocked) event.preventDefault();
653  });
654})();

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.