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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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.