1/* Player controls, shared by every skin. No jQuery. */ 2(function () { 3 "use strict"; 4 5 // The Cast SDK fires this as soon as it loads, which happens before the DOM 6 // is ready: the hook is registered now and the setup runs when both are in. 7 var castApiReady = false; 8 var castSetup = null; 9 10 window.__onGCastApiAvailable = function (available) { 11 if (!available) return; 12 13 castApiReady = true; 14 if (castSetup) castSetup(); 15 }; 16 17 function ready(fn) { 18 if (document.readyState === "loading") { 19 document.addEventListener("DOMContentLoaded", fn); 20 return; 21 } 22 fn(); 23 } 24 25 ready(function () { 26 var player = document.getElementById("fallplayer"); 27 var source = document.getElementById("srcP"); 28 29 if (!player || !source || player.dataset.brasReady) return; 30 31 // Guard against a second run: duplicated handlers used to break playback 32 player.dataset.brasReady = "1"; 33 34 var config = window.brasPlayer || {}; 35 36 var playBtn = document.querySelector(".fallplayer-play"); 37 var pauseBtn = document.querySelector(".fallplayer-pause"); 38 var volume = document.getElementById("volumeSlider"); 39 var cover = document.querySelector(".bras-player-background"); 40 var label1 = document.querySelector(".rf-playing-label1"); 41 var label2 = document.querySelector(".rf-playing-label2"); 42 43 var hasMediaSession = "mediaSession" in navigator; 44 45 function setPlaying(on) { 46 if (playBtn) { 47 playBtn.classList.toggle("active", !on); 48 playBtn.classList.toggle("inactive", on); 49 } 50 if (pauseBtn) { 51 pauseBtn.classList.toggle("active", on); 52 pauseBtn.classList.toggle("inactive", !on); 53 } 54 if (hasMediaSession) navigator.mediaSession.playbackState = on ? "playing" : "paused"; 55 } 56 57 var STOP_KEY = "brasPlayerStopped"; 58 59 function stoppedByHand(value) { 60 try { 61 if (value === undefined) return sessionStorage.getItem(STOP_KEY) === "1"; 62 if (value) sessionStorage.setItem(STOP_KEY, "1"); 63 else sessionStorage.removeItem(STOP_KEY); 64 } catch (error) {} 65 66 return false; 67 } 68 69 function togglePlay() { 70 // paused is enough: duration only arrives with the metadata, and until 71 // then a click on stop was starting the stream over instead 72 if (!player.paused) { 73 stoppedByHand(true); 74 setPlaying(false); 75 player.pause(); 76 source.src = ""; 77 player.currentTime = 0; 78 return; 79 } 80 81 stoppedByHand(false); 82 setPlaying(true); 83 player.currentTime = 0; 84 source.src = window.srcstream; 85 player.load(); 86 87 var started = player.play(); 88 if (started && started.catch) started.catch(function () { setPlaying(false); }); 89 } 90 91 // Playback 92 source.addEventListener("error", function () { setPlaying(false); }); 93 player.addEventListener("playing", function () { setPlaying(true); }); 94 player.addEventListener("ended", function () { setPlaying(false); }); 95 player.addEventListener("error", function () { setPlaying(false); }); 96 97 document.querySelectorAll(".icopl").forEach(function (button) { 98 button.addEventListener("click", togglePlay); 99 }); 100 101 // Notification and lock screen controls follow the same button 102 if (hasMediaSession) { 103 navigator.mediaSession.setActionHandler("play", function () { if (player.paused) togglePlay(); }); 104 navigator.mediaSession.setActionHandler("pause", function () { if (!player.paused) togglePlay(); }); 105 navigator.mediaSession.setActionHandler("stop", function () { if (!player.paused) togglePlay(); }); 106 } 107 108 // Volume 109 var volumeBox = document.querySelector(".fallplayer-volume"); 110 var muteBtn = document.querySelector(".fallplayer-mute-unmute"); 111 var lastVolume = 100; 112 113 function isMuted() { 114 return player.muted || player.volume === 0; 115 } 116 117 function syncVolume() { 118 var off = isMuted(); 119 var label = (off ? config.unmute : config.mute) || (off ? "Ativar som" : "Silenciar"); 120 121 // Skins paint the filled part of the track with this, so the level shows 122 if (volume) volume.style.setProperty("--pl-vol", (off ? 0 : Number(volume.value)) + "%"); 123 124 if (volumeBox) volumeBox.classList.toggle("is-muted", off); 125 126 if (muteBtn) { 127 muteBtn.setAttribute("title", label); 128 muteBtn.setAttribute("aria-label", label); 129 muteBtn.setAttribute("aria-pressed", off ? "true" : "false"); 130 } 131 } 132 133 if (volume) { 134 volume.addEventListener("input", function () { 135 var value = Number(volume.value); 136 137 if (value > 0) lastVolume = value; 138 player.muted = value === 0; 139 player.volume = value / 100; 140 syncVolume(); 141 }); 142 } 143 144 if (muteBtn) { 145 muteBtn.addEventListener("click", function () { 146 var off = !isMuted(); 147 148 player.muted = off; 149 if (!off) player.volume = lastVolume / 100; 150 if (volume) volume.value = off ? 0 : lastVolume; 151 syncVolume(); 152 }); 153 } 154 155 // Skins that fold the slider away: the button only opens and closes it, a 156 // click outside or Escape puts it back. Muting is the slider at zero. 157 var volumeToggle = document.querySelector(".fallplayer-volume-toggle"); 158 159 if (volumeToggle && volumeBox) { 160 var closeVolume = function () { 161 volumeBox.classList.remove("is-open"); 162 volumeToggle.setAttribute("aria-expanded", "false"); 163 }; 164 165 volumeToggle.addEventListener("click", function (e) { 166 e.stopPropagation(); 167 var open = volumeBox.classList.toggle("is-open"); 168 volumeToggle.setAttribute("aria-expanded", open ? "true" : "false"); 169 }); 170 171 document.addEventListener("click", function (e) { 172 if (!volumeBox.contains(e.target)) closeVolume(); 173 }); 174 175 document.addEventListener("keydown", function (e) { 176 if (e.key === "Escape") closeVolume(); 177 }); 178 } 179 180 syncVolume(); 181 182 // Metadata 183 var bar = document.querySelector(".bras-player"); 184 185 // App shortcuts stay folded until the dots button is pressed 186 var moreBtn = document.getElementById("playerMore"); 187 188 if (moreBtn && bar) { 189 moreBtn.addEventListener("click", function (e) { 190 e.stopPropagation(); 191 var open = bar.classList.toggle("is-apps"); 192 moreBtn.setAttribute("aria-expanded", open ? "true" : "false"); 193 }); 194 195 document.addEventListener("click", function (e) { 196 if (bar.contains(e.target)) return; 197 bar.classList.remove("is-apps"); 198 moreBtn.setAttribute("aria-expanded", "false"); 199 }); 200 201 document.addEventListener("keydown", function (e) { 202 if (e.key !== "Escape") return; 203 bar.classList.remove("is-apps"); 204 moreBtn.setAttribute("aria-expanded", "false"); 205 }); 206 } 207 208 // Nothing on air: hide controls and metadata, show the offline notice 209 function setOffline(off) { 210 if (!bar) return; 211 212 bar.classList.toggle("is-offline", off); 213 214 if (off && !player.paused) { 215 player.pause(); 216 source.src = ""; 217 setPlaying(false); 218 } 219 } 220 221 222 223 function cssUrl(url) { 224 return 'url("' + String(url).replace(/["\\]/g, "") + '")'; 225 } 226 227 function coverType(url) { 228 if (/^data:image\/svg/i.test(url)) return "image/svg+xml"; 229 if (/\.png(\?|$)/i.test(url)) return "image/png"; 230 if (/\.webp(\?|$)/i.test(url)) return "image/webp"; 231 return "image/jpeg"; 232 } 233 234 // The cover API sends the replacement character, sometimes escaped as text, 235 // where the accent was lost at the source 236 function cleanLabel(text) { 237 return String(text || "") 238 .replace(/\\u[0-9a-fA-F]{4}/g, "") 239 .replace(/\\x[0-9a-fA-F]{2}/g, "") 240 .replace(/�/g, "") 241 .replace(/\s{2,}/g, " ") 242 .replace(/[\s_\-]+$/, "") 243 .trim(); 244 } 245 246 // The browser panel shows the same thing the player bar does: song or 247 // program, with the artwork that came with it 248 function updateMediaSession(current, song) { 249 if (!hasMediaSession) return; 250 251 var cover = (current && current.cover) || config.nocover; 252
253 try { 254 navigator.mediaSession.metadata = new MediaMetadata({ 255 title: song || config.name || "", 256 artist: config.name || "", 257 artwork: cover ? [{ src: new URL(cover, location.href).href, sizes: "512x512", type: coverType(cover) }] : [] 258 }); 259 } catch (error) {} 260 } 261 262 // Skins that mark a label with pl-marquee slide it when the title is wider 263 // than its box. Skins that use the marquee element scroll on their own and 264 // only need the text, which is why the caller never rewrites it in place. 265 function syncMarquee(el) { 266 if (!el || !el.classList.contains("pl-marquee") || !el.parentElement) return; 267 268 el.classList.remove("is-scrolling"); 269 270 var over = el.scrollWidth - el.parentElement.clientWidth; 271 if (over <= 4) return; 272 273 el.style.setProperty("--pl-marquee-shift", -over + "px"); 274 el.style.setProperty("--pl-marquee-time", Math.min(30, Math.max(9, over / 14)) + "s"); 275 el.classList.add("is-scrolling"); 276 } 277 278 window.brasSyncMarquee = syncMarquee; 279 280 // The web font lands after the first render and changes how wide the title 281 // is: without measuring again a long title would sit still 282 if (document.fonts && document.fonts.ready) { 283 document.fonts.ready.then(function () { syncMarquee(label2); }); 284 } 285 286 // One empty answer is usually a hiccup of the stream server, not the station 287 // going off air. The notice waits for three in a row, and those retries come 288 // faster than the normal poll so a real outage still shows up quickly. 289 var OFFLINE_TRIES = 3; 290 var misses = 0; 291 var retryTimer = null; 292 293 /* Whether the station is on air is a question for the stream, not for the 294 metadata: plenty of them broadcast with no title at all. Icecast answers 295 it in under a kilobyte, from the same host that serves the audio, so the 296 check costs our servers nothing. */ 297 var statusUrl = String(window.srcstream || "").replace(/\/[^\/?#]*(\?.*)?$/, "/status-json.xsl"); 298 299 function streamOnline(done) { 300 if (!statusUrl) { done(false); return; } 301 302 var stop = (window.AbortController ? new AbortController() : null); 303 var timer = window.setTimeout(function () { if (stop) stop.abort(); }, 4000); 304 305 fetch(statusUrl, { cache: "no-store", signal: (stop ? stop.signal : undefined) }) 306 .then(function (response) { return response.ok ? response.json() : null; }) 307 .then(function (data) { 308 var source = data && data.icestats ? data.icestats.source : null; 309 310 window.clearTimeout(timer); 311 done(!!source && (!Array.isArray(source) || source.length > 0)); 312 }) 313 .catch(function () { 314 window.clearTimeout(timer); 315 316 // The answer never came: the stream is the one to blame 317 done(false); 318 }); 319 } 320 321 function render(current) { 322 if (!current) { 323 misses++; 324 325 if (misses < OFFLINE_TRIES) { 326 window.clearTimeout(retryTimer); 327 retryTimer = window.setTimeout(function () { playerData(); }, 5000); 328 return; 329 } 330 331 // No metadata after the retries: only the stream says if it is over 332 streamOnline(function (up) { 333 if (up) { paint({ label1: config.live || "", label2: config.name || "", cover: config.nocover }); return; } 334 335 paint(null); 336 }); 337 338 return; 339 } 340 341 misses = 0; 342 window.clearTimeout(retryTimer); 343 paint(current); 344 } 345 346 function paint(current) { 347 348 var song = current ? cleanLabel(current.label2) : ""; 349 350 setOffline(!current); 351 updateMediaSession(current, song); 352 353 if (label1) { 354 label1.textContent = (current && current.label1) || ""; 355 label1.classList.remove("pl-skeleton"); 356 } 357 358 if (label2) { 359 label2.classList.remove("pl-skeleton"); 360 361 // Same song: leave the element alone. Rewriting it every poll restarted 362 // the marquee from the top, which read as the title freezing 363 if (label2.getAttribute("data-text") !== song) { 364 label2.setAttribute("data-text", song); 365 label2.textContent = song; 366 syncMarquee(label2); 367 } 368 } 369 370 if (cover) { 371 // The fallback stays as a second layer under the new artwork: while it 372 // downloads there is something in the frame instead of a hole 373 if (current && current.cover) cover.style.backgroundImage = cssUrl(current.cover) + (config.nocover ? ", " + cssUrl(config.nocover) : ""); 374 cover.classList.remove("pl-skeleton"); 375 } 376 } 377 378 // Song mode talks to the cover API directly, keeping the load off this server 379 function songData() { 380 fetch(config.coverApi, { cache: "reload" }) 381 .then(function (response) { return response.ok ? response.json() : null; }) 382 .then(function (data) { 383 var title = data && data.title ? data.title : ""; 384 if (!title) { render(null); return; } 385 386 render({ 387 label1: config.nowPlaying || "Tocando agora", 388 label2: title, 389 cover: (config.cover && data.image_url ? data.image_url : config.nocover) 390 }); 391 }) 392 .catch(function () { render(null); }); 393 } 394 395 function scheduleData() { 396 fetch("/player-data", { credentials: "same-origin" }) 397 .then(function (response) { return response.ok ? response.json() : null; }) 398 .then(function (data) { 399 var current = data && data.current ? data.current : null; 400 401 var online = current && (current.online === undefined 402 ? !!(current.label1 || current.label2) 403 : !!Number(current.online)); 404 405 render(online ? current : null); 406 }) 407 .catch(function () { render(null); }); 408 } 409 410 /* Google Cast. The launcher only shows up when the framework loads and a 411 device answers on the network, so nothing appears on browsers without 412 support (Firefox, Safari) or when there is no Chromecast around. */ 413 var castBtn = document.getElementById("playerCast"); 414 var castSession = null; 415 416 function castMedia() { 417 var media = new chrome.cast.media.MediaInfo(window.srcstream, "audio/mpeg"); 418 419 // Song on top, station below it. The line above both is the receiver name 420 // and only a registered receiver id changes what it says 421 media.streamType = chrome.cast.media.StreamType.LIVE; 422 media.metadata = new chrome.cast.media.GenericMediaMetadata(); 423 media.metadata.title = (label2 && label2.textContent) || config.name || ""; 424 media.metadata.subtitle = config.name || ""; 425 426 // The TV fetches the artwork on its own, so it needs the full address 427 // Only the first layer is the artwork: the second one is the fallback 428 var art = (cover && cover.style.backgroundImage.split('), ')[0].replace(/^url\(["\']?/, "").replace(/["\']?\)?$/, "")) || ""; 429 if (art) { 430 try { media.metadata.images = [new chrome.cast.Image(new URL(art, location.href).href)]; } catch (error) {} 431 } 432 433 // Our own receiver dresses itself with this: name, logo, colours and 434 // where to read the song from, so the TV never asks our servers who we are 435 media.customData = Object.assign({}, config.cast, { coverApi: config.coverApi }); 436 437 return new chrome.cast.media.LoadRequest(media); 438 } 439 440 function castStart() { 441 castSession = cast.framework.CastContext.getInstance().getCurrentSession(); 442 if (!castSession) return; 443 444 // The stream now plays on the TV: the page keeps quiet 445 castSession.loadMedia(castMedia()).catch(function () {}); 446 447 if (!player.paused) { 448 player.pause(); 449 source.src = ""; 450 setPlaying(false); 451 } 452 } 453 454 function initCast() { 455 var context = cast.framework.CastContext.getInstance(); 456 457 context.setOptions({ 458 receiverApplicationId: config.castApp || chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, 459 autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED 460 }); 461 462 // The button shows on every browser that has the framework: hiding it when 463 // no device answered yet left it missing while the network is scanned. 464 // States: NO_DEVICES_AVAILABLE, NOT_CONNECTED, CONNECTING, CONNECTED 465 function paintCast(state) { 466 if (castBtn) castBtn.hidden = false;
467 if (bar) bar.classList.toggle("is-casting", state === "CONNECTED" || state === "CONNECTING"); 468 } 469 470 context.addEventListener(cast.framework.CastContextEventType.CAST_STATE_CHANGED, function (e) { 471 paintCast(e.castState); 472 }); 473 474 context.addEventListener(cast.framework.CastContextEventType.SESSION_STATE_CHANGED, function (e) { 475 if (e.sessionState === "SESSION_STARTED" || e.sessionState === "SESSION_RESUMED") castStart(); 476 if (e.sessionState === "SESSION_ENDED") castSession = null; 477 }); 478 479 if (castBtn) { 480 castBtn.addEventListener("click", function () { 481 if (context.getCastState() === "CONNECTED") { 482 context.endCurrentSession(true); 483 return; 484 } 485 486 context.requestSession().catch(function () {}); 487 }); 488 } 489 490 paintCast(context.getCastState()); 491 } 492 493 castSetup = initCast; 494 if (castApiReady) initCast(); 495 496 // Autoplay: browsers only allow it after the visitor has interacted with the 497 // site, so a blocked attempt arms the next click instead of failing quietly 498 if (config.autoplay && !stoppedByHand()) { 499 var armed = false; 500 501 var arm = function () { 502 if (armed) return; 503 504 armed = true; 505 document.addEventListener("click", fire, { once: true }); 506 document.addEventListener("keydown", fire, { once: true }); 507 document.addEventListener("touchstart", fire, { once: true }); 508 }; 509 510 var fire = function () { 511 if (player.paused) togglePlay(); 512 }; 513 514 var first = (function () { 515 setPlaying(true); 516 source.src = window.srcstream; 517 player.load(); 518 519 return player.play(); 520 })(); 521 522 if (first && first.catch) { 523 first.catch(function () { setPlaying(false); arm(); }); 524 } 525 } 526 527 var playerData = (config.song && config.coverApi ? songData : scheduleData); 528 529 window.addEventListener("resize", function () { syncMarquee(label2); }); 530 531 playerData(); 532 setInterval(playerData, 30000); 533 }); 534})();
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.