1"use strict"; 2// Declare basic 3const doc = document; 4const win = window; 5 6// Main function of STREAMPLAYER 7class StreamPlayer { 8 constructor() { 9 if (doc.querySelector(".streamplayer") || doc.querySelector('.streamplayer--disabled')) return; 10 let playerContainer = Object.assign(doc.createElement("aside"), { 11 className: "streamplayer", 12 innerHTML: `<div class="streamplayer--overlay"></div> <div class="progress--bar"></div> 13 <div class="streamplayer--bar"> 14 <a href="#" class="hide--btn" role="button" aria-label="Minimize player"> 15 <span class="srricon icon-down-open"></span> 16 <span class="srricon icon-up-open"></span> 17 </a> 18 <a href="#" class="streamplayer--play player--btn" role="button" aria-label="Play" aria-pressed="false"><span class="srricon icon-stream-play"></span><span class="srricon icon-stream-pause"></span></a> 19 <div class="streamplayer--current-station"> 20 <figure class="streamplayer--img-station"><img src="" alt="Logo station"></figure> 21 <div class="streamplayer--onair"></div> 22 <div class="streamplayer--desc" aria-live="polite" aria-atomic="true"> 23 <div class="tick"><h3>Station Name</h3></div> 24 <div class="tick"><p>Player Position</p></div> 25 </div> 26 </div> 27 <div class="streamplayer--adv"></div> 28 <!-- <a href="#" class="streamplayer--volume player--btn"><span class="srricon icon-stream-volume"></span></a> --> 29 <a href="#" class="streamplayer--playlist--toggler player--btn" role="button" aria-label="Toggle station list" aria-expanded="false"><span class="srricon icon-stream-list"></span></a> 30 </div> 31 <ul class="streamplayer--prerolls"> 32 </ul> 33 <ul class="streamplayer--list" role="listbox" aria-label="Stations"> 34 </ul>`, 35 }); 36 37 // basic settings 38 this.siteUrl = window.location.protocol + '//' + window.location.hostname; 39 this.artistPostEnabled = true; 40 this.artistEndpointFailCount = 0; 41 this.artistEndpointLastFailAt = null; 42 this.userInteracted = false; 43 // The body classes WordPress rendered for the page we are currently on. 44 // Loading a page without a reload has to take these off and put the next 45 // page's on, or every rule written against .single-post, .page-id-42, 46 // .home and the like keeps matching the page the visitor arrived from. 47 // Anything else on <body> â .srr-keyboard-user, .srb-loaded, .modal-open, 48 // .fancybox-active, .webp-support â was put there at runtime by the theme, 49 // a modal, a lightbox or another plugin, and must survive untouched. Only 50 // by knowing exactly what the server wrote can the two be told apart; a 51 // missing list means add-only, which is wrong but never destructive. 52 this.serverBodyClasses = ( win.SRRStreamplayer && typeof win.SRRStreamplayer.bodyClass === 'string' ) 53 ? win.SRRStreamplayer.bodyClass.split(/\s+/).filter(Boolean) 54 : null; 55 // For lazy content loader 56 // IMPORTANT: Do not rebuild the entire body via innerHTML. 57 // Also, avoid moving existing embed iframes/scripts (e.g. Second Street), 58 // because removing/reinserting iframes can cause them to reload and "flicker". 59 const hasSecondStreetEmbed = !!doc.querySelector('script[data-ss-embed], iframe[data-ss-embed]'); 60 if (hasSecondStreetEmbed) { 61 this.contentSite = doc.body; 62 doc.body.appendChild(playerContainer); 63 } else { 64 let bodyWrapper = doc.createElement("div"); 65 bodyWrapper.className = "content-site"; 66 // Preserve the existing DOM by moving nodes into our wrapper. 67 while (doc.body.firstChild) { 68 bodyWrapper.appendChild(doc.body.firstChild); 69 } 70 doc.body.appendChild(bodyWrapper); 71 doc.body.appendChild(playerContainer); 72 this.contentSite = bodyWrapper; 73 } 74 // Player obj 75 this.player = { 76 playerContainer, 77 progressBar: playerContainer.querySelector(".progress--bar"), 78 hideBtn: playerContainer.querySelector(".hide--btn"), 79 playBtn: playerContainer.querySelector(".streamplayer--play"), 80 desc: playerContainer.querySelector(".streamplayer--desc"), 81 currImg: playerContainer.querySelector(".streamplayer--current-station img"), 82 onAir: playerContainer.querySelector(".streamplayer--onair"), 83 currTitle: playerContainer.querySelector(".streamplayer--current-station h3"), 84 currPosition: playerContainer.querySelector(".streamplayer--current-station p"), 85 playListBtn: playerContainer.querySelector(".streamplayer--playlist--toggler"), 86 playList: playerContainer.querySelector(".streamplayer--list"), 87 prerollsContainter: playerContainer.querySelector(".streamplayer--prerolls"), 88 overlay: playerContainer.querySelector(".streamplayer--overlay"), 89 adsContainer: playerContainer.querySelector(".streamplayer--adv") 90 }; 91 92 // const host = document.querySelector('#srr-site-url')?.getAttribute('data-site-url'); 93 94 // Set the API Endpoints 95 this.ENDPOINT = `${this.siteUrl}/wp-json/streamplayer/v1/stations`; 96 this.PLAYCOUNTER_ENDPOINT = `${this.siteUrl}/wp-json/streamplayer/v1/play-counter/`; 97 this.LISTENING_ENDPOINT = `${this.siteUrl}/wp-json/streamplayer/v1/listening/`; 98 this.ONAIR_ENDPOINT = `${this.siteUrl}/wp-json/onair/v1/currentshow`; 99 // Shared API key used for the OnAir currentshow REST endpoint 100 this.ONAIR_API_KEY = 'srr_onair_shared_v1'; 101 // Audio 102 this.streamAudio = new Audio(); 103 win.currentlyPlaying = this.streamAudio; 104 105 // Set 85% volume 106 this.streamAudio.volume = 0.85; 107 this.streamAudio.preload = "none"; 108 this.prerollAudio = new Audio(); 109 110 // True only while streamAudio's source is the selected station's stream. 111 // `this.type` stays "station" once any station has been chosen, even when 112 // a podcast or embedded audio is later played through the player, so it 113 // cannot tell the two apart. Plays and listener check-ins go by this. 114 this.sourceIsStation = false;
115 // Listener check-in state â see startListening(). 116 this.listenStationId = null; 117 this.listenTimer = null; 118 this.listenDue = {}; 119 120 // HLS playback state. hls.js is lazy-loaded only when an HLS station is 121 // played in a browser that doesn't support HLS natively (Chrome / Firefox / 122 // Edge). The instance is torn down on every station switch to free MSE 123 // buffers â see _setStreamSource() below. 124 this.hlsInstance = null; 125 this.hlsScriptPromise = null; 126 // Stream type for the currently selected station. Defaults to 'auto' so 127 // legacy stations without an explicit type fall through to URL-suffix 128 // detection. Possible values: 'auto', 'progressive', 'hls'. 129 this.streamType = 'auto'; 130 this.timer = null; 131 this.pauseTimer = null; 132 this.nowPlayingUpdatesPaused = false; 133 this._lastTimeUpdateSent = null; 134 // OnAir polling state 135 this.onAirPollTimeoutId = null; 136 this.onAirPollBackoffMs = 60000; // base interval: 60s between requests 137 this.onAirPollCategoryId = null; 138 this.onAirPollMaxBackoffMs = 300000; // cap backoff at 5 minutes 139 140 // Stream feed polling state (for error backoff) 141 this.streamFeedBackoffMs = 8000; // base interval: 8s 142 this.streamFeedMaxBackoffMs = 60000; // cap at 60 seconds 143 this.streamFeedConsecutiveErrors = 0; 144 145 // Geo-restriction / stream unavailable state 146 this.streamUnavailable = false; 147 148 // Sleep timer state 149 this.sleepTimerId = null; 150 151 // Stations API caching configuration 152 this.stationsCacheKey = 'srr_streamplayer_stations'; 153 this.stationsCacheTTL = 30000; // 30 seconds local cache 154 this.stationsApiBackoffMs = 1000; // initial retry delay 155 this.stationsApiMaxBackoffMs = 30000; // max 30 seconds between retries 156 this.stationsApiRetryCount = 0; 157 this.stationsApiMaxRetries = 3; 158 } 159 160 // Init 161 async _initialize() { 162 if (doc.querySelector('.streamplayer--disabled')) return; 163 this.API = await this.fetchAPI(); 164 await this.detectArtistSupport(); 165 166 // Handle failed API response - but be more flexible about what constitutes valid data 167 if (!this.API) { 168 console.warn('StreamPlayer: Failed to load API data'); 169 return; 170 } 171 172 // Check if stations exist and is an array (even if empty) 173 if (!this.API.stations || !Array.isArray(this.API.stations)) { 174 console.warn('StreamPlayer: Invalid stations data format', this.API); 175 return; 176 } 177 178 this.stations = this.API.stations.reverse(); 179 this.settings = this.API.settings || {}; 180 this.currentPreroll = null; 181 // Check if player is enabled 182 if (this.settings.streamplayer !== "enabled") return; 183 this.initSettings(); 184 // Run playlist builder 185 this.listBuilder(); 186 // Run advertise builder 187 this.adsBuilder(); 188 // Choose first station or from the query 189 this.initStation(); 190 // Mount listeners 191 this.listeners(); 192 this.linkListeners(); 193 194 // Show the player when all data loaded 195 setTimeout(() => { 196 this.player.playerContainer.classList.add("streamplayer--loaded"); 197 }, 800); 198 199 } 200 201 // Set properties 202 initSettings() { 203 const settings = this.settings, playerContainer = this.player.playerContainer; 204 // Set player position 205 playerContainer.classList.add(`player--${settings.player_position}`); 206 // Add the .player--side class for left/right positions 207 if (settings.player_position === "left" || settings.player_position === "right") { 208 playerContainer.classList.add(`player--side`); 209 } 210 this.player.height = `${this.player.playerContainer.offsetHeight}px`; 211 if (settings.player_position == "bottom") { 212 this.contentSite.style.paddingBottom = this.player.height; 213 } else if (settings.player_position === "top") { 214 this.contentSite.style.paddingTop = this.player.height; 215 } 216 // Check if minimizing is enabled 217 if (settings.player_min !== "enabled") this.player.hideBtn.remove(); 218 playerContainer.style.setProperty("--textColor", settings.player_text); 219 playerContainer.style.setProperty("--bgColor", settings.player_bg); 220 playerContainer.style.setProperty("--textContentColor", settings.playlist_text); 221 if (settings.logo_bg && settings.logo_bg !== 'transparent') { 222 playerContainer.style.setProperty("--logoBgColor", settings.logo_bg); 223 } 224 if (settings.station_card_width) { 225 playerContainer.style.setProperty("--stationCardWidth", settings.station_card_width + "px"); 226 } 227 228 // Set the hover color 229 playerContainer.style.setProperty("--hoverColor", settings.playlist_sel_color); 230 playerContainer.style.setProperty("--hoverContentColor", settings.playlist_sel_color); 231 playerContainer.style.setProperty("--hoverBgColor", settings.playlist_sel_bg["background-color"]); 232 playerContainer.style.setProperty("--hoverContentBgColor", settings.playlist_sel_bg["background-color"]); 233 // Set player backgrounds 234 let playerStyle = { 235 background: `${settings.player_bg} ` 236 }; 237 Object.assign(playerContainer.style, playerStyle); 238 Object.assign(this.player.hideBtn.style, playerStyle); 239 // Set play list backgrounds 240 let playListStyle = { 241 background: `${settings.playlist_bg} ` 242 }; 243 Object.assign(this.player.playList.style, playListStyle); 244 Object.assign(this.player.prerollsContainter.style, playListStyle); 245 if (settings.player_content === "content") this.player.playList.classList.add('streamplayer--content', `streamplayer--cols-${settings.player_sections}`); 246 } 247 248 // Init station 249 initStation() { 250 const settings = this.settings; 251 // Check if window location has a query for station (streamplayer=stationId) 252 let winLocation = new URLSearchParams(win.location.href.split('?')[1]); 253 if (winLocation.has('streamplayer')) { 254 this.autoChoose(winLocation, true); 255 return; 256 } 257 if (this.stations.length > 1 && settings.cta_name) { 258 this.setInfoBar(settings.cta_name, settings.cta_image, settings.cta_text); 259 this.player.playerContainer.classList.add('streamplayer--disabled-play'); 260 return; 261 } 262 // Set initial station 263 let stationId = 0, Play = false;
264 // Choose station 265 this.chooseStation(stationId, Play); 266 } 267 268 // Detect if artist image endpoint is available 269 async detectArtistSupport() { 270 try { 271 // REST index check removed for performance reasons. 272 // Assume endpoint exists and disable later if request fails. 273 this.artistPostEnabled = true; 274 } catch (e) { 275 this.artistPostEnabled = false; 276 } 277 } 278 279 // Global listeners 280 async listeners() { 281 const $this = this, player = this.player; 282 let isScrolling; 283 284 285 // Listen for user-initiated events 286 window.addEventListener('click', () => $this.userInteracted = true, { once: true }); 287 window.addEventListener('keydown', () => $this.userInteracted = true, { once: true }); 288 289 // Listen on playlist button 290 player.playListBtn.addEventListener("click", (e) => { 291 e.preventDefault(); 292 $this.togglePlaylist(); 293 }); 294 // Listen on current station div - only works in initial state 295 const currentStationDiv = player.playerContainer.querySelector(".streamplayer--current-station"); 296 if (currentStationDiv) { 297 currentStationDiv.addEventListener("click", (e) => { 298 // Only toggle playlist if in initial state (has streamplayer--disabled-play class) 299 if (player.playerContainer.classList.contains('streamplayer--disabled-play')) { 300 e.preventDefault(); 301 $this.togglePlaylist(); 302 } 303 }); 304 } 305 // Listen on play button 306 player.playBtn.addEventListener("click", async (e) => { 307 e.preventDefault(); 308 $this.userInteracted = true; 309 if (player.playBtn.classList.contains("active") || player.playBtn.classList.contains("loading")) { 310 player.playBtn.classList.remove("active", "loading"); 311 player.playBtn.setAttribute('aria-pressed', 'false'); 312 $this.streamAudio.pause(); 313 } else { 314 await $this.playStream($this.stream, false); 315 } 316 }); 317 // Listen on playlist items 318 player.playList.querySelectorAll("li.streamplayer--station-item").forEach((li) => { 319 li.addEventListener("click", (e) => { 320 const clicked = e.currentTarget; 321 if (clicked.classList.contains("active")) return; 322 $this.chooseStation(clicked.dataset.id); 323 }); 324 }); 325 // Listen on Hide button 326 player.hideBtn.addEventListener("click", (e) => { 327 e.preventDefault(); 328 this.togglePlayer(e); 329 }); 330 player.overlay.addEventListener("click", (e) => $this.togglePlaylist()); 331 // Keyboard support for interactive elements 332 player.playBtn.addEventListener("keydown", (e) => { 333 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); player.playBtn.click(); } 334 }); 335 player.playListBtn.addEventListener("keydown", (e) => { 336 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); player.playListBtn.click(); } 337 }); 338 player.hideBtn.addEventListener("keydown", (e) => { 339 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); player.hideBtn.click(); } 340 }); 341 // Listen on scroll if player position = top 342 if ($this.settings.player_position === "top") { 343 doc.addEventListener('scroll', (e) => { 344 clearTimeout(isScrolling); 345 isScrolling = setTimeout(() => { 346 if (!player.playerContainer.classList.contains('hiddenPlayer') && win.scrollY > 100) $this.togglePlayer(e); 347 if (player.playerContainer.classList.contains('hiddenPlayer') && win.scrollY < 100) $this.togglePlayer(e); 348 }, 100); 349 }, false); 350 } 351 // Listen on preroll Audio 352 // Listen on streaming Audio 353 this._streamStallTimerId = null; 354 this.streamAudio.onloadstart = () => { 355 // Clear any previous stall timer 356 clearTimeout(this._streamStallTimerId); 357 // Start a 15-second stall detection timer for station streams
358 if (this.type === 'station') { 359 this._streamStallTimerId = setTimeout(() => { 360 // If still loading and not playing after 15s, treat as unavailable 361 if (this.streamAudio.readyState < 2 && this.streamAudio.networkState === 2 && !this.streamUnavailable) { 362 this.handleStreamUnavailable(); 363 } 364 }, 15000); 365 } 366 player.playBtn.classList.toggle("loading", true); 367 // Notify mini-players that loading has started 368 this.notifyMiniPlayers({ 369 isPlaying: false, 370 audioSrc: this.streamAudio.src, 371 isLoading: true 372 }); 373 } 374 this.streamAudio.oncanplay = () => { 375 // Clear stall timer â stream loaded successfully 376 clearTimeout(this._streamStallTimerId); 377 player.playBtn.classList.remove("loading"); 378 player.playBtn.classList.add("active"); 379 player.playBtn.setAttribute('aria-pressed', 'true'); 380 // Notify mini-players that audio can play and send full state 381 this.notifyMiniPlayers({ 382 isPlaying: !this.streamAudio.paused, 383 audioSrc: this.streamAudio.src, 384 isLoading: false, 385 currentTime: this.streamAudio.currentTime, 386 duration: this.streamAudio.duration 387 }); 388 } 389 this.streamAudio.onplay = () => { 390 // Clear the pause timer and reset the flag 391 clearTimeout(this.pauseTimer); 392 this.nowPlayingUpdatesPaused = false; 393 // Clear geo-restriction state on successful play 394 this.streamUnavailable = false; 395 // Start sleep timer when playback begins 396 this.startSleepTimer(); 397 // Notify mini-players that audio has started playing 398 this.notifyMiniPlayers({ 399 isPlaying: true, 400 audioSrc: this.streamAudio.src, 401 currentTime: this.streamAudio.currentTime, 402 duration: this.streamAudio.duration 403 }); 404 } 405 // Plays and listener check-ins start when audio is actually flowing, not 406 // when play is requested â a geo-blocked or dead stream never gets here. 407 this.streamAudio.addEventListener('playing', () => { 408 if (this.sourceIsStation && this.currentStation?.id) { 409 this.countPlay(this.currentStation.id); 410 this.startListening(this.currentStation.id); 411 } else { 412 this.stopListening(); 413 } 414 }); 415 ['pause', 'ended', 'error', 'emptied'].forEach(evt => { 416 this.streamAudio.addEventListener(evt, () => this.stopListening()); 417 }); 418 this.streamAudio.onpause = () => { 419 // Clear sleep timer when paused 420 this.clearSleepTimer(); 421 // Start a 10-minute timer to stop 'now playing' updates 422 this.pauseTimer = setTimeout(() => { 423 this.nowPlayingUpdatesPaused = true; 424 }, 600000); // 10 minutes in milliseconds 425 426 // Notify mini-players that audio has paused 427 this.notifyMiniPlayers({ 428 isPlaying: false, 429 audioSrc: this.streamAudio.src 430 }); 431 } 432 // Detect stream errors (geo-restriction, unavailable, etc.) 433 this.streamAudio.onerror = () => { 434 // Only handle geo-restriction for station streams, not embedded audio 435 if (this.type === 'station' && !this.streamUnavailable) { 436 this.handleStreamUnavailable(); 437 } 438 } 439 this.streamAudio.ontimeupdate = () => { 440 // Send timeupdate events more frequently (every 100ms) to ensure smoother progress bar updates 441 if (!this._lastTimeUpdateSent || Date.now() - this._lastTimeUpdateSent > 100) { 442 this._lastTimeUpdateSent = Date.now(); 443 444 // Improved check for playing state that handles edge cases 445 const isPlaying = !this.streamAudio.paused && !this.streamAudio.ended && 446 this.streamAudio.readyState > 2; 447 448 // Only notify mini-players if we have valid metadata to avoid conflicts 449 const audioSrc = this.currentAudioMetadata?.audioSrc || this.streamAudio.src; 450 if (audioSrc) { 451 this.notifyMiniPlayers({ 452 isPlaying: isPlaying, 453 audioSrc: audioSrc, 454 currentTime: this.streamAudio.currentTime, 455 duration: this.streamAudio.duration 456 }); 457 } 458 } 459 } 460 this.streamAudio.onended = () => { 461 this.streamAudio.currentTime = 0; 462 player.playBtn.classList.remove("active"); 463 player.playBtn.setAttribute('aria-pressed', 'false'); 464 if (this.playNext) this.playStream(); 465 this.audioTrigger.forEach(audio => { 466 if (audio.classList.contains('active')) audio.classList.toggle('active'); 467 }); 468 // Notify mini-players that audio has ended 469 this.notifyMiniPlayers({ 470 isPlaying: false, 471 audioSrc: this.streamAudio.src, 472 currentTime: 0, 473 duration: this.streamAudio.duration 474 }); 475 } 476 this.otherAudioListeners(); 477 // Listen on popstate and use lazyLoad 478 win.addEventListener('popstate', (e) => { 479 let href = doc.location.href; 480 if (href.includes('#/')) return; 481 $this.lazyLoad(href, false); 482 }); 483 } 484 485 otherAudioListeners() { 486 const $this = this; 487 this.otherAudio = doc.querySelectorAll(`audio:not(.streamplayer, .ad-audio)`); 488 this.audioTrigger = doc.querySelectorAll(`.streamplayer--audio-trigger`); 489 490 // Initialize the currentAudioSource tracking object if it doesn't exist 491 if (!window.currentAudioSource) { 492 window.currentAudioSource = { 493 src: null, 494 isMinimalPlayer: false, 495 element: null 496 }; 497 } 498 499 // Mount listeners on another audio (but not minimal player audio)
500 this.otherAudio.forEach(otherAudio => { 501 // Skip if this is inside a minimal player or has the custom-enhanced class 502 if (otherAudio.closest('.minimal-player') || otherAudio.classList.contains('custom-enhanced')) { 503 return; 504 } 505 506 otherAudio.onplay = (e) => { 507 e.preventDefault(); 508 509 // If the theme's minimal player is enabled, do not hijack native audio elements here. 510 // The minimal player uses custom event handoff (playInStreamPlayer) instead. 511 const swapPlayer = (window.SRR_Launch && window.SRR_Launch.swapPlayer) ? window.SRR_Launch.swapPlayer : null; 512 if (swapPlayer === 'enabled') { 513 return; 514 } 515 516 // Minimal player is disabled: prevent double playback by stopping the original element 517 // before starting StreamPlayer playback. 518 try { 519 if (!otherAudio.paused) { 520 otherAudio.pause(); 521 } 522 otherAudio.currentTime = 0; 523 otherAudio.muted = true; 524 } catch (err) { 525 // no-op 526 } 527 528 $this.onPlayAnotherAudio(e.target); 529 }; 530 }); 531 532 // Listen on audio trigger 533 this.audioTrigger.forEach(audio => { 534 audio.addEventListener('click', e => { 535 e.preventDefault(); 536 $this.userInteracted = true; 537 $this.onPlayAnotherAudio(audio); 538 }) 539 }); 540 541 // Listen for custom events from minimal player 542 document.addEventListener('playInStreamPlayer', (e) => { 543 if (e.detail && e.detail.src) { 544 // Store the current minimal player container for reference 545 $this.currentMinimalPlayer = e.detail.container; 546 547 // Store the original audio element if provided 548 if (e.detail.audioElement) { 549 $this.originalAudioElement = e.detail.audioElement; 550 551 // Make sure the original audio is paused to prevent double playback 552 if ($this.originalAudioElement.pause) { 553 $this.originalAudioElement.pause(); 554 } 555 } 556 557 // Play the audio in the stream player 558 $this.onPlayAnotherAudio({ 559 currentSrc: e.detail.src, 560 dataset: { 561 title: e.detail.title || e.detail.src.substring(e.detail.src.lastIndexOf('/') + 1).split('.')[0], 562 artist: e.detail.artist || '', 563 image: e.detail.image || '', 564 originalAudioId: e.detail.originalAudioId || null 565 }, 566 container: e.detail.container, 567 isFromEvent: true 568 }); 569 } else { 570 // Send failure response if no source provided 571 document.dispatchEvent(new CustomEvent('streamPlayerResponse', { 572 detail: { 573 success: false, 574 error: "No source provided in playInStreamPlayer event" 575 } 576 })); 577 } 578 }); 579 580 document.addEventListener('pauseInStreamPlayer', (e) => { 581 582 // Store the minimal player container for reference if provided 583 if (e.detail && e.detail.container) { 584 $this.currentMinimalPlayer = e.detail.container; 585 } 586 587 // Store the original audio element if provided 588 if (e.detail && e.detail.audioElement) { 589 $this.originalAudioElement = e.detail.audioElement; 590 } 591 592 // Forcefully pause the stream audio 593 if ($this.streamAudio) { 594 $this.streamAudio.pause(); 595 $this.player.playBtn.classList.remove("active"); 596 $this.player.playBtn.setAttribute('aria-pressed', 'false'); 597 } 598 }); 599 } 600 601 linkListeners() {
602 let $this = this; 603 // Listen on all links 604 doc.querySelectorAll(`a[href*="${win.location.hostname}"]`) 605 .forEach((link) => { 606 // Break if link has target="_blank" or data-toggle attribute or #/ 607 if (link.target === "_blank" || link.hasAttribute('data-toggle') || link.href.includes('#')) return; 608 609 link.addEventListener("click", (e) => { 610 e.preventDefault(); 611 $this.lazyLoad(e.currentTarget.href); 612 }); 613 }); 614 } 615 616 // Hide & show player 617 togglePlayer(e) { 618 const $this = this, player = this.player; 619 let isTop = player.playerContainer.classList.contains('player--top'), 620 isBottom = player.playerContainer.classList.contains('player--bottom'), 621 paddingTop = $this.contentSite.style.paddingTop, 622 paddingBottom = $this.contentSite.style.paddingBottom; 623 // Top 624 if (isTop && Number.parseInt(paddingTop) > 1) { 625 $this.contentSite.style.paddingTop = '0px'; 626 } else if (isTop) { 627 $this.contentSite.style.paddingTop = player.height; 628 } 629 // Bottom 630 if (isBottom && Number.parseInt(paddingBottom) > 1) { 631 $this.contentSite.style.paddingBottom = '0px'; 632 } else if (isBottom) { 633 $this.contentSite.style.paddingBottom = player.height; 634 } 635 // Toggle classes 636 player.playerContainer.classList.toggle("hiddenPlayer"); 637 if (player.playList.classList.contains("opened")) $this.togglePlaylist(); 638 } 639 640 // Play stream 641 async playStream(srcAudio = this.stream, pauseClicked = false, isEmbeddedAudio = false) { 642 // Store a reference to the original audio source 643 if (srcAudio && srcAudio !== this.stream) { 644 this.originalAudioSrc = srcAudio; 645 } 646 // If this is an embedded audio, consider it as user interaction 647 if (isEmbeddedAudio) { 648 this.userInteracted = true; 649 650 // Store reference to the current minimal player for later use 651 if (this.currentMinimalPlayer) { 652 // Store the original audio element for later reference 653 this.originalAudioElement = this.currentMinimalPlayer.querySelector('audio'); 654 } 655 656 // Make sure the play button is enabled for embedded audio 657 this.player.playBtn.disabled = false; 658 this.player.playBtn.style.opacity = 1; 659 } 660 661 // Check if the user has interacted with the page 662 if (this.userInteracted !== true) { 663 this.setInfoBar("Auto-play disabled. Click on page to play..", null, " ", false); 664 doc.addEventListener('click', () => this.playStream(srcAudio, pauseClicked, isEmbeddedAudio), { once: true }); 665 doc.addEventListener('keydown', () => this.playStream(srcAudio, pauseClicked, isEmbeddedAudio), { once: true }); 666 doc.addEventListener('scroll', () => this.playStream(srcAudio, pauseClicked, isEmbeddedAudio), { once: true }); 667 return; 668 } 669 670 const playBtn = this.player.playBtn; 671 const isStream = srcAudio === this.stream; 672 const isPreroll = this.prerolls?.length !== 0 && this.prerolls !== false && this.getValidPrerolls().length !== 0; 673 674 675 // If we're pausing, handle that first 676 if (pauseClicked) { 677 // Make sure we pause the StreamPlayer audio 678 if (this.streamAudio) { 679 this.streamAudio.pause(); 680 } 681 682 // Also pause the original audio element if we have it 683 if (this.originalAudioElement && this.originalAudioElement.pause) { 684 this.originalAudioElement.pause(); 685 } 686 687 playBtn.classList.remove("active"); 688 playBtn.setAttribute('aria-pressed', 'false'); 689 690 // If this is from a minimal player, notify it 691 if (this.currentMinimalPlayer) { 692 const playPauseBtn = this.currentMinimalPlayer.querySelector('.play-pause-btn'); 693 if (playPauseBtn) { 694 playPauseBtn.innerHTML = '<i class="srricons icon-play"></i>'; 695 } 696 } 697 return; 698 } 699 700 // Toggle class on the play button 701 playBtn.classList.add("active"); 702 playBtn.setAttribute('aria-pressed', 'true'); 703 704 // Ensure the play button remains clickable 705 playBtn.disabled = false;
706 playBtn.style.pointerEvents = 'auto'; 707 playBtn.style.cursor = 'pointer'; 708 709 // For embedded audio, make sure the play button is properly set up 710 if (isEmbeddedAudio) { 711 712 // Make sure we have a valid source 713 if (!srcAudio) { 714 playBtn.classList.remove("active"); 715 playBtn.setAttribute('aria-pressed', 'false'); 716 return; 717 } 718 719 // IMPORTANT: Check if we have metadata and update the UI immediately 720 if (this.currentAudioMetadata) { 721 this.updatePlayerWithMetadata(this.currentAudioMetadata); 722 } 723 724 // IMPORTANT DEBUG: Log the audio element state before playing 725 726 // Store a reference to the minimal player container and metadata if sent from an event 727 if (isEmbeddedAudio) { 728 729 if (window.currentEmbeddedAudio) { 730 // Store reference to the container 731 this.currentMinimalPlayer = window.currentEmbeddedAudio.container; 732 733 // IMPORTANT: Log all properties of the currentEmbeddedAudio object 734 735 // Get metadata from the currentEmbeddedAudio object 736 const metadata = { 737 title: window.currentEmbeddedAudio.title || null, 738 image: window.currentEmbeddedAudio.image || null, 739 isPodcast: window.currentEmbeddedAudio.isPodcast || false, 740 artist: window.currentEmbeddedAudio.artist || null, 741 album: window.currentEmbeddedAudio.album || null 742 }; 743 744 745 // Store metadata for later use 746 this.currentAudioMetadata = metadata; 747 748 // Update the StreamPlayer UI with the metadata 749 this.updatePlayerWithMetadata(metadata); 750 } else { 751 752 // Try to get metadata directly from the source 753 if (srcAudio && !this.currentAudioMetadata) { 754 755 // Extract filename as title 756 const fileName = srcAudio.split('/').pop().split('.')[0]; 757 const prettyFileName = fileName.replace(/[-_]/g, ' '); 758 759 const metadata = { 760 title: prettyFileName, 761 image: null, 762 isPodcast: false, 763 artist: null, 764 album: 'Audio File' 765 }; 766 767 this.currentAudioMetadata = metadata; 768 this.updatePlayerWithMetadata(metadata); 769 } 770 } 771 } 772 773 // IMPORTANT: Make sure the play button is enabled and clickable for embedded audio 774 if (isEmbeddedAudio) { 775 // Enable the play button 776 playBtn.disabled = false; 777 playBtn.style.pointerEvents = 'auto'; 778 playBtn.style.opacity = '1'; 779 780 // Make sure the button is visible and clickable 781 const parentElements = [playBtn.parentElement, this.player.playerContainer]; 782 parentElements.forEach(el => { 783 if (el) { 784 el.style.pointerEvents = 'auto'; 785 } 786 }); 787 788 } 789 790 // Check if the URL is valid and has a supported audio format 791 const supportedFormats = ['.mp3', '.wav', '.ogg', '.aac', '.m4a']; 792 const hasValidExtension = supportedFormats.some(ext => srcAudio.toLowerCase().endsWith(ext)); 793 794 795 if (!hasValidExtension) { 796 // Try to append .mp3 if no extension is present 797 if (!srcAudio.includes('.')) { 798 srcAudio = srcAudio + '.mp3'; 799 } 800 } 801 802 // IMPORTANT: For regular audio files, ensure the URL is properly formatted 803 // Some audio files might have query parameters that cause issues 804 if (!this.currentAudioMetadata?.isPodcast) { 805 // Try to clean up the URL if it has query parameters 806 const urlParts = srcAudio.split('?'); 807 if (urlParts.length > 1) { 808 // Keep only the base URL without query parameters 809 srcAudio = urlParts[0]; 810 } 811 } 812 813 // Set the source and play 814 if (this.streamAudio) { 815 this.sourceIsStation = false;
816 this.streamAudio.src = srcAudio; 817 this.streamAudio.preload = "auto"; 818 819 // Force load the audio before playing 820 this.streamAudio.load(); 821 822 // IMPORTANT: Set volume to ensure it's audible 823 this.streamAudio.volume = 0.8; 824 825 // Store the original audio source for reference 826 this.originalAudioSrc = srcAudio; 827 } 828 829 // IMPORTANT: Add a small delay before playing to ensure the source is loaded 830 setTimeout(() => { 831 try { 832 // Add error event listener to catch any playback errors 833 const errorHandler = (error) => { 834 835 // Try to recover by forcing a reload and play 836 837 try { 838 // Make sure the audio element is valid before trying to load 839 if (this.streamAudio) { 840 this.streamAudio.load(); 841 setTimeout(() => { 842 if (this.streamAudio) { 843 this.streamAudio.play().catch(e => { }); 844 } 845 }, 500); 846 } 847 } catch (e) { } 848 }; 849 850 // Only add the error listener if the audio element exists 851 if (this.streamAudio) { 852 this.streamAudio.addEventListener('error', errorHandler); 853 // Add an event listener for the 'play' event 854 this.streamAudio.addEventListener('play', () => { 855 this.clearTimers(); 856 // Remove the error listener after successful play 857 this.streamAudio.removeEventListener('error', errorHandler); 858 // If this is from a minimal player, update its UI 859 if (this.currentMinimalPlayer) { 860 const playPauseBtn = this.currentMinimalPlayer.querySelector('.play-pause-btn'); 861 if (playPauseBtn) { 862 playPauseBtn.innerHTML = '<i class="srricons icon-pause"></i>'; 863 } 864 } 865 // Update the StreamPlayer UI with file information 866 if (isEmbeddedAudio && this.originalAudioSrc && !this.currentAudioMetadata) { 867 // Extract file name from the URL 868 const fileName = this.originalAudioSrc.split('/').pop().split('.')[0]; 869 const prettyFileName = fileName.replace(/[-_]/g, ' '); 870 // Update the StreamPlayer UI 871 this.setInfoBar(prettyFileName, null, "Playing embedded audio", false); 872 } 873 // Enable progress tracking 874 this.trackEmbeddedAudioProgress(); 875 }, { once: true }); 876 // Add error event listener 877 this.streamAudio.addEventListener('error', (error) => { 878 // Handle any errors during playback 879 // Try to recover by reloading the audio 880 if (this.streamAudio) { 881 try { 882 // Try creating a new audio element and play through that 883 const tempAudio = new Audio(); 884 tempAudio.src = this.originalAudioSrc; 885 tempAudio.preload = "auto"; 886 887 // Listen for the canplay event 888 tempAudio.addEventListener('canplay', () => { 889 // Now that we know the audio can play, set it to StreamPlayer 890 this.streamAudio.src = tempAudio.currentSrc; 891 this.streamAudio.load(); 892 this.streamAudio.play() 893 .then(() => { 894 playBtn.classList.add("active"); 895 }) 896 .catch(err => { 897 playBtn.classList.remove("active"); 898 this.setInfoBar("Unable to play this audio format", null, " ", false); 899 }); 900 }, { once: true }); 901 902 // Also handle errors on the temp audio 903 tempAudio.addEventListener('error', () => { 904 playBtn.classList.remove("active"); 905 this.setInfoBar("Unable to play this audio format", null, " ", false); 906 }, { once: true }); 907 908 tempAudio.load(); 909 } catch (fallbackError) { 910 playBtn.classList.remove("active"); 911 this.setInfoBar("Error playing audio", null, " ", false); 912 } 913 } else { 914 // For other errors 915 playBtn.classList.remove("active"); 916 this.setInfoBar("Error playing audio: " + error.message, null, " ", false); 917 } 918 }, { once: true }); 919 920 // Start playing the audio 921 this.streamAudio.play().catch(err => { }); 922 } 923 } catch (error) { 924 playBtn.classList.remove("active"); 925 this.setInfoBar("Error playing audio: " + error.message, null, " ", false); 926 } 927 }, 100); 928 return; 929 } 930 931 // Handle regular stream playback 932 // Reset geo-restriction state for fresh play attempts 933 this.streamUnavailable = false;
934 935 if (isPreroll && isStream) await this.playPreroll(); 936 937 // Show loading animation for station streams â swap active for loading 938 // so the pulse animation shows instead of the pause icon. 939 // oncanplay will add active back when the stream is ready. 940 if (isStream) { 941 playBtn.classList.remove("active"); 942 playBtn.setAttribute('aria-pressed', 'false'); 943 playBtn.classList.add("loading"); 944 } 945 946 this.streamAudio.preload = "none"; 947 // Dispatcher: routes progressive URLs to the legacy `audio.src = url` 948 // path and HLS URLs to hls.js (or native Safari/iOS playback). On any 949 // failure or feature-flag-off scenario, falls back to the legacy path 950 // â so older browsers keep working byte-for-byte. 951 if (isStream) { 952 await this._setStreamSource(srcAudio || ""); 953 } else { 954 this.sourceIsStation = false; 955 this.streamAudio.src = srcAudio || ""; 956 } 957 958 // Support for another audio 959 if (!isStream) { 960 this.playNext = !!this.currentStation?.length; 961 962 // For regular audio files, make sure we update the UI with metadata 963 if (this.currentAudioMetadata) { 964 this.updatePlayerWithMetadata(this.currentAudioMetadata); 965 } else { 966 // If no metadata is available, extract from filename 967 const fileName = srcAudio.split('/').pop().split('.')[0].replace(/[-_]/g, ' '); 968 this.setInfoBar(fileName, null, ""); 969 } 970 971 await this.streamAudio.play().catch((error) => { 972 playBtn.classList.remove("active", "loading"); 973 }); 974 } else if (!isPreroll) { 975 if (this.type !== "other-audio") { 976 await this.toggleInfoBar(); 977 } 978 await this.streamAudio.play().catch(error => { 979 playBtn.classList.remove("active", "loading"); 980 }); 981 // Start sleep timer when stream playback begins 982 this.startSleepTimer(); 983 } 984 } 985 986 // Play preroll 987 async playPreroll() { 988 // Check if prerolls is undefined 989 if (!this.prerolls) return; 990 991 // Get valid prerolls 992 const validPrerolls = this.getValidPrerolls(); 993 if (!validPrerolls.length) return; 994 995 // check if main stream is playing 996 if (!this.streamAudio.paused) this.streamAudio.pause(); 997 this.clearTimers(); 998 999 // Select random preroll from valid ones 1000 this.currentPreroll = validPrerolls[Math.floor(Math.random() * validPrerolls.length)]; 1001 this.togglePreroll(); 1002 1003 // Add mobile class 1004 this.player.prerollsContainter.classList.add(this.getMobileOS()); 1005 1006 // Check preroll type 1007 switch (this.currentPreroll.preroll_type) { 1008 case "audio": 1009 await this.playAudioPreroll(); 1010 break; 1011 case "youtube": 1012 this.playYoutubePreroll(); 1013 break; 1014 case "vimeo": 1015 this.playVimeoPreroll(); 1016 break; 1017 case "video": 1018 this.playVideoPreroll(); 1019 break; 1020 } 1021 1022 if (this.currentPreroll.preroll_type === "audio") return; 1023 // Set timeout to hide video preroll 1024 this.videoPrerollTimeout = setTimeout(() => { 1025 this.toggleVideoPreroll(); 1026 }, 31000); 1027 } 1028 1029 // Get valid prerolls based on date 1030 getValidPrerolls() { 1031 if (!this.prerolls || !this.prerolls.length) return []; 1032 1033 const currentDate = new Date().getTime(); 1034 return this.prerolls.filter(preroll => { 1035 const startDate = preroll.start_date ? new Date(preroll.start_date).getTime() : 0; 1036 const endDate = preroll.end_date ? new Date(preroll.end_date).getTime() : Infinity; 1037 return currentDate >= startDate && currentDate <= endDate; 1038 }); 1039 } 1040 1041 // Play audio preroll 1042 async playAudioPreroll() { 1043 this.prerollAudio.src = this.currentPreroll.preroll_audio; 1044 await this.prerollAudio.play(); 1045 //console.info(`Preroll ${this.currentPreroll.preroll_name} is playing now`); 1046 this.prerollAudio.onended = async () => { 1047 this.streamAudio.play(); 1048 this.togglePreroll(); 1049 await this.toggleInfoBar(); 1050 } 1051 } 1052 1053 // Play youtube preroll 1054 async playYoutubePreroll() {
1055 let $this = this, player = this.player; 1056 // Check if youtube api is loaded 1057 if (!this.youtubeApiScript) { 1058 this.youtubeApiScript = doc.createElement("script"); 1059 this.youtubeApiScript.src = "https://www.youtube.com/iframe_api"; 1060 let firstScriptTag = document.getElementsByTagName('script')[0]; 1061 firstScriptTag.parentNode.insertBefore(this.youtubeApiScript, firstScriptTag); 1062 } 1063 // create video player container 1064 const LI = doc.createElement("li"), videoId = this.currentPreroll.preroll_youtube; 1065 LI.classList.add("video-preroll"); 1066 LI.id = 'youtube'; 1067 // set ID for the video container 1068 LI.innerHTML = '<div id="youtube-player"></div>'; 1069 // Append video container to the preroll container 1070 player.prerollsContainter.appendChild(LI); 1071 if (!player.playerContainer.classList.contains('hasBanner')) player.playerContainer.classList.toggle('hasBanner'); 1072 1073 $this.youtubePlayerReady = false; 1074 1075 // Define playback maintenance interval outside setTimeout to be able to clear it later 1076 let playbackInterval = null; 1077 1078 setTimeout(() => { 1079 $this.youtubePlayer = new YT.Player('youtube-player', { 1080 height: '500', 1081 width: '720', 1082 videoId: videoId, 1083 playerVars: { 1084 mute: 1, // Start muted to help with autoplay policies 1085 controls: 0, 1086 showinfo: 0, 1087 rel: 0, 1088 autoplay: 0, 1089 playsinline: 1, 1090 enablejsapi: 1, 1091 origin: window.location.origin, 1092 widget_referrer: window.location.href 1093 }, 1094 events: { 1095 onReady: () => { 1096 $this.youtubePlayerReady = true; 1097 $this.youtubePlayer.playVideo(); 1098 1099 // Store start time for delayed unmute 1100 $this.youtubeStartTime = new Date().getTime(); 1101 1102 // For non-iOS devices, use a continuous playback maintenance strategy 1103 if ($this.getMobileOS() !== "iOS") { 1104 // Create an interval that continuously checks player state 1105 // and forces playback if needed - works around browser autoplay restrictions 1106 playbackInterval = setInterval(() => { 1107 if ($this.youtubePlayer) { 1108 const state = $this.youtubePlayer.getPlayerState(); 1109 // If not playing, try to resume playback 1110 if (state !== YT.PlayerState.PLAYING) { 1111 $this.youtubePlayer.playVideo(); 1112 } 1113 1114 // After a few seconds, attempt to unmute if still muted 1115 const elapsed = new Date().getTime() - $this.youtubeStartTime; 1116 if ($this.youtubePlayer.isMuted() && elapsed > 2000) { 1117 LI.click(); 1118 } 1119 } else { 1120 // Clear interval if player no longer exists 1121 clearInterval(playbackInterval); 1122 } 1123 }, 450); // Check frequently 1124 } 1125 }, 1126 1127 onStateChange: (e) => { 1128 if (e.data === YT.PlayerState.PLAYING) { 1129 // Clear timeout to hide video preroll 1130 clearTimeout($this.videoPrerollTimeout); 1131 1132 // Try to unmute once playback has started 1133 if ($this.youtubePlayer.isMuted()) { 1134 setTimeout(() => { 1135 if ($this.youtubePlayer) { 1136 $this.youtubePlayer.unMute(); 1137 $this.player.prerollsContainter.classList.remove($this.getMobileOS()); 1138 } 1139 }, 2000); 1140 } 1141 } else if (e.data === YT.PlayerState.PAUSED || e.data === YT.PlayerState.BUFFERING) { 1142 // If video was paused or buffering, try to resume it immediately 1143 // This provides additional resilience beyond the interval 1144 if ($this.youtubePlayerReady && !$this.manualPause) { 1145 setTimeout(() => { 1146 if ($this.youtubePlayer) { 1147 $this.youtubePlayer.playVideo(); 1148 } 1149 }, 150); 1150 } 1151 } else if (e.data === YT.PlayerState.ENDED) { 1152 // Clear maintenance interval when video has ended 1153 if (playbackInterval) { 1154 clearInterval(playbackInterval); 1155 } 1156 $this.toggleVideoPreroll(); 1157 } 1158 } 1159 } 1160 }); 1161 1162 // Handle user click on video container 1163 LI.addEventListener('click', (e) => { 1164 if ($this.youtubePlayer) { 1165 // This is a user interaction, so unmute and play should work reliably 1166 $this.manualPause = false;
1167 $this.youtubePlayer.unMute(); 1168 $this.youtubePlayer.playVideo(); 1169 $this.player.prerollsContainter.classList.remove($this.getMobileOS()); 1170 1171 // Stop automatic playback maintenance after user interaction 1172 if (playbackInterval) { 1173 clearInterval(playbackInterval); 1174 } 1175 } 1176 }); 1177 }, 1000); 1178 } 1179 1180 // Play vimeo preroll 1181 async playVimeoPreroll() { 1182 let $this = this, player = this.player; 1183 // Check if vimeo api is loaded 1184 if (!this.vimeoApiScript) { 1185 this.vimeoApiScript = doc.createElement("script"); 1186 this.vimeoApiScript.src = "https://player.vimeo.com/api/player.js"; 1187 let firstScriptTag = document.getElementsByTagName('script')[0]; 1188 firstScriptTag.parentNode.insertBefore(this.vimeoApiScript, firstScriptTag); 1189 } 1190 // create video player container 1191 const LI = doc.createElement("li"), videoId = this.currentPreroll.preroll_vimeo; 1192 LI.classList.add("video-preroll"); 1193 // set ID for the video container 1194 LI.id = "vimeo-player"; 1195 // Append video container to the preroll container 1196 player.prerollsContainter.appendChild(LI); 1197 if (!player.playerContainer.classList.contains('hasBanner')) player.playerContainer.classList.toggle('hasBanner'); 1198 setTimeout(() => { 1199 $this.vimeoPlayer = new Vimeo.Player('vimeo-player', { 1200 autoplay: false, 1201 loop: false, 1202 muted: true, 1203 width: 720, 1204 height: 500, 1205 controls: false, 1206 id: videoId, 1207 origin: win.location.origin, 1208 }); 1209 $this.vimeoPlayer.on('loaded', () => { 1210 // Clear timeout to hide video preroll 1211 clearTimeout($this.videoPrerollTimeout); 1212 $this.vimeoPlayer.play(); 1213 let timer = $this.getMobileOS() === "iOS" ? false : 50; 1214 if (!timer) return; 1215 // Trigger click event to unmute the video 1216 setTimeout(() => { 1217 LI.click(); 1218 }, timer); 1219 }); 1220 $this.vimeoPlayer.on('ended', () => { 1221 $this.toggleVideoPreroll(); 1222 }); 1223 }, 800); 1224 LI.addEventListener('click', (e) => { 1225 // Replay video 1226 $this.vimeoPlayer.setCurrentTime(0); 1227 // play 1228 $this.vimeoPlayer.setVolume(1); 1229 $this.player.prerollsContainter.classList.remove($this.getMobileOS()); 1230 }); 1231 } 1232 1233 // Play video preroll 1234 async playVideoPreroll() { 1235 let $this = this, player = this.player; 1236 // create video player container 1237 const LI = doc.createElement("li"), videoSrc = this.currentPreroll.preroll_video.url; 1238 $this.player.prerollsContainter.classList.remove($this.getMobileOS()); 1239 1240 LI.classList.add("video-preroll"); 1241 // set ID for the video container 1242 LI.id = "video-player"; 1243 player.prerollsContainter.appendChild(LI); 1244 1245 // Append HTML video container 1246 let videoPlayer = Object.assign(document.createElement('video'), { 1247 src: videoSrc, 1248 autoplay: true, 1249 muted: false, 1250 controls: false, 1251 playsInline: true 1252 }); 1253 LI.appendChild(videoPlayer); 1254 if (!player.playerContainer.classList.contains('hasBanner')) player.playerContainer.classList.toggle('hasBanner'); 1255 videoPlayer.addEventListener('ended', () => { 1256 $this.toggleVideoPreroll(); 1257 }); 1258 } 1259 1260 // Toggle video preroll 1261 async toggleVideoPreroll() { 1262 clearTimeout(this.videoPrerollTimeout); 1263 this.streamAudio.play(); 1264 this.togglePreroll(); 1265 this.toggleInfoBar(); 1266 } 1267 1268 // Playing another audio 1269 async onPlayAnotherAudio(otherAudio, minimalPlayerContainer = false) { 1270 // If this is coming from a custom event, don't skip it 1271 const isFromCustomEvent = otherAudio.isFromEvent || (otherAudio.currentSrc && !otherAudio.classList); 1272 // Skip handling if this is a custom-enhanced audio element or part of a minimal player 1273 // unless it's coming from our custom event 1274 if (!isFromCustomEvent && (otherAudio.classList?.contains('custom-enhanced') || 1275 otherAudio.closest('.minimal-player'))) { 1276 console.log("Skipping handling of custom-enhanced audio element"); 1277 return; 1278 } 1279 1280 // Use the standard StreamPlayer controls for handling this audio 1281 // First, stop the current station if one is playing 1282 if (this.isPlaying) { 1283 this.isPlaying = false;
1284 this.player.playBtn.classList.remove("active"); 1285 this.player.playBtn.setAttribute('aria-pressed', 'false'); 1286 } 1287 1288 // Get the source from the audio element or from the data attribute 1289 let src = null; 1290 1291 // For elements coming from our custom event 1292 if (isFromCustomEvent) { 1293 src = otherAudio.currentSrc; 1294 1295 // Store reference to the container if provided 1296 if (otherAudio.container) { 1297 this.currentMinimalPlayer = otherAudio.container; 1298 } 1299 1300 // Update metadata for display in the player 1301 this.currentAudioMetadata = { 1302 audioSrc: src, 1303 title: otherAudio.dataset?.title || src.substring(src.lastIndexOf('/') + 1).split('.')[0], 1304 image: otherAudio.dataset?.image || null, 1305 artist: otherAudio.dataset?.artist || null 1306 }; 1307 } else { 1308 // For regular audio elements or trigger elements 1309 const isAudioElement = otherAudio.tagName === 'AUDIO'; 1310 1311 if (isAudioElement) { 1312 src = otherAudio.currentSrc || otherAudio.src || otherAudio.querySelector('source')?.src; 1313 } else if (otherAudio.dataset?.src) { 1314 src = otherAudio.dataset.src; 1315 } 1316 1317 if (!src) { 1318 console.error('No source found for audio element'); 1319 return; 1320 } 1321 1322 // Get metadata from the element's data attributes 1323 this.currentAudioMetadata = { 1324 audioSrc: src, 1325 title: otherAudio.dataset?.title || src.substring(src.lastIndexOf('/') + 1).split('.')[0], 1326 image: otherAudio.dataset?.image || null, 1327 artist: otherAudio.dataset?.artist || null 1328 }; 1329 } 1330 let currentAudioMetadata = this.currentAudioMetadata; 1331 // Update the streamplayer display with the audio metadata 1332 this.setInfoBar(currentAudioMetadata.title, currentAudioMetadata.image, currentAudioMetadata.artist); 1333 1334 // Reset the audio element 1335 this.streamAudio.currentTime = 0; 1336 1337 // Make sure preload is set to auto for embedded audio 1338 if (isFromCustomEvent || minimalPlayerContainer) { 1339 this.streamAudio.preload = "auto"; 1340 } 1341 1342 // Pass true as the third parameter to indicate this is an embedded audio file 1343 await this.playStream(src, false, true); 1344 1345 // Send a response event for the minimal player to know the audio was successfully handled 1346 document.dispatchEvent(new CustomEvent('streamPlayerResponse', { 1347 detail: { 1348 success: true, 1349 audioSrc: src 1350 } 1351 })); 1352 } 1353 1354 // Find podcast title 1355 findTitle(element, selectorToFind = '.podcast_instance') { 1356 let podcastInstance = element.closest(selectorToFind), title; 1357 if (podcastInstance) title = podcastInstance.querySelector('.podcast_title').innerText; 1358 return title; 1359 } 1360 1361 // Preroll toggler 1362 togglePreroll() { 1363 let player = this.player.playerContainer; 1364 // Toggle .preroll class 1365 player.classList.toggle('isPreroll'); 1366 if (player.classList.contains('isPreroll')) { 1367 this.setInfoBar(this.currentPreroll.preroll_name, null, ' ', true); 1368 if (this.currentPreroll.preroll_adblock !== 0) this.prerollBuilder(); 1369 } else { 1370 this.setInfoBar(this.currentStation.title, this.currentStation.thumb[0], this.currentStation.station_position[0]); 1371 this.prerollBuilder(false); 1372 } 1373 } 1374 1375 // Check mobile device OS 1376 getMobileOS() { 1377 const ua = navigator.userAgent 1378 if (/android/i.test(ua)) { 1379 return "Android" 1380 } else if (/iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) { 1381 return "iOS" 1382 } 1383 return "Other" 1384 } 1385 1386 // Choose Station 1387 chooseStation(stationId, play = true, isLink = false) { 1388 this.clearTimers(); 1389 this.clearSleepTimer(); 1390 this.streamUnavailable = false;
1391 this.type = "station"; 1392 // this.clearTimers(); 1393 const station = this.currentStation = this.stations[stationId], 1394 player = this.player; 1395 let title = station.title, thumb = station.thumb[0], subTitle = station.station_position[0]; 1396 //Toggle class 1397 const prevActive = player.playList.querySelector(".active"); 1398 if (prevActive) { prevActive.classList.remove("active"); prevActive.setAttribute('aria-selected', 'false'); } 1399 const newActive = player.playList.querySelectorAll("li.streamplayer--station-item")[stationId]; 1400 if (newActive) { newActive.classList.add("active"); newActive.setAttribute('aria-selected', 'true'); } 1401 this.player.playerContainer.classList.remove('streamplayer--disabled-play'); 1402 1403 this.stream = station?.station_stream[0]; 1404 // Capture stream format for the dispatcher. Defaults to 'auto' so legacy 1405 // stations stored before the 1.7.0 schema flow through URL-suffix 1406 // detection. Stations explicitly tagged 'progressive' or 'hls' skip the 1407 // sniff entirely. 1408 this.streamType = station?.station_stream_type?.[0] || 'auto'; 1409 // Set pre rolls 1410 this.prerolls = station?.station_preroll ? station?.station_preroll[0] : []; 1411 if (play) { 1412 this.playStream(); 1413 if (!isLink) this.togglePlaylist(); 1414 } else { 1415 this.setInfoBar(title, thumb, subTitle); 1416 } 1417 } 1418 1419 // Support for stream feeds - shows "now playing" information 1420 async streamFeed(streamingProvider) { 1421 // Make sure we have a current station with now playing URL 1422 if (!this.currentStation || !this.currentStation.station_nowplaying || !this.currentStation.station_nowplaying[0]) { 1423 if (this.currentStation) { 1424 this.setInfoBar(this.currentStation.title, this.currentStation.thumb[0], this.currentStation.station_position[0]); 1425 } 1426 return; 1427 } 1428 1429 let $this = this; 1430 let streamURL = $this.currentStation.station_nowplaying[0].replace(/&/g, '&'); 1431 let item, songTitle, artist, cover = null; 1432 let duration = 8000; 1433 1434 // Store the current provider 1435 $this.currentProvider = streamingProvider; 1436 1437 // Clear stream feed timer but don't clear onAirFeedTimer 1438 clearInterval(this.streamFeedTimer); 1439 clearTimeout(this.errorTimer); 1440 1441 try { 1442 // Fetch the stream data. Cool Radio / Centova feeds are HTTP-only with a 1443 // TLS certificate that does not match the stream host, so the browser 1444 // blocks a direct fetch (mixed content / handshake failure) â route those 1445 // through the same-origin server-side proxy, which also transparently 1446 // returns whichever version (RSS or JSON) the station saved. 1447 let resp; 1448 const proxyBase = (typeof SRRStreamplayer !== 'undefined' && SRRStreamplayer.restUrl) ? SRRStreamplayer.restUrl : ''; 1449 if (streamingProvider === 'cool' && proxyBase && $this.currentStation.id) { 1450 const proxyURL = `${proxyBase}streamplayer/v1/nowplaying?id=${encodeURIComponent($this.currentStation.id)}`; 1451 const response = await fetch(proxyURL); 1452 if (!response.ok) { 1453 throw new Error(`HTTP error! status: ${response.status}`); 1454 } 1455 const envelope = await response.json(); 1456 resp = (envelope && typeof envelope.body === 'string') ? envelope.body : ''; 1457 } else { 1458 const response = await fetch(streamURL); 1459 if (!response.ok) { 1460 throw new Error(`HTTP error! status: ${response.status}`); 1461 } 1462 resp = await response.text(); 1463 } 1464 let xmlDOM; 1465 1466 // Process based on response type. 1467 // The Cool Radio feed is offered as either RSS (XML) or JSON depending 1468 // on which URL the station submitted (.../rss vs .../json), so sniff the 1469 // payload instead of relying on the URL extension alone. 1470 const looksLikeJson = /^\s*[{\[]/.test(resp); 1471 if (streamURL.indexOf('.json') === -1 && 1472 streamingProvider !== 'live365' && 1473 streamingProvider !== 'radioco' && 1474 streamingProvider !== 'guys' && streamingProvider !== 'aiir' && streamingProvider !== 'soundstack' && 1475 !(streamingProvider === 'cool' && looksLikeJson)) { 1476 // XML response - clean and parse 1477 resp = resp.replace(/ & /g, '--and ').replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, ''); 1478 let parser = new DOMParser(); 1479 xmlDOM = parser.parseFromString(resp, "text/xml"); 1480 } else { 1481 // JSON response 1482 resp = JSON.parse(resp); 1483 } 1484 1485 // Extract data based on provider 1486 switch (streamingProvider) { 1487 case 'aiir': 1488 if (resp) { 1489 const titleText = resp[0].title.trim(); 1490 // console.log(titleText) 1491 // Check for various delimiters that might separate artist and title 1492 let parts = []; 1493
1494 // Check if title starts with a tilde (special case) 1495 if (titleText.startsWith('~')) { 1496 // Remove the leading tilde and any leading whitespace 1497 const cleanTitle = titleText.substring(1).trim(); 1498 1499 // Now process the rest of the title normally 1500 if (cleanTitle.includes(' - ')) { 1501 parts = cleanTitle.split(' - '); 1502 } else { 1503 // No additional delimiter, use entire string as title 1504 parts = [cleanTitle]; 1505 } 1506 } else if (titleText.includes(' - ')) { 1507 parts = titleText.split(' - '); 1508 } else if (titleText.includes(' ~ ')) { 1509 parts = titleText.split(' ~ '); 1510 } else if (titleText.includes('~')) { 1511 parts = titleText.split('~'); 1512 } else { 1513 // No delimiter found, treat entire string as song title 1514 parts = [titleText]; 1515 } 1516 1517 // Handle different cases where artist or title might be missing 1518 if (parts.length > 1) { 1519 artist = parts[0].trim(); 1520 songTitle = parts[1].trim(); 1521 1522 // Handle case where artist might be empty but delimiter exists 1523 if (!artist && songTitle) { 1524 artist = ''; 1525 } 1526 1527 // Handle case where title might be empty but delimiter exists 1528 if (artist && !songTitle) { 1529 songTitle = ''; 1530 } 1531 } else { 1532 // If no delimiter or only one part, use entire string as song title 1533 songTitle = parts[0]; 1534 artist = ''; 1535 } 1536 1537 // If both are empty, use station title as fallback 1538 if (!songTitle && !artist) { 1539 songTitle = this.currentStation.title; 1540 } 1541 } else { 1542 // Fallback to station title if no data available 1543 songTitle = this.currentStation.title; 1544 artist = ''; 1545 } 1546 break; 1547 1548 case 'azuracast': 1549 item = resp.now_playing.song; 1550 songTitle = item.title; 1551 artist = item.artist; 1552 cover = item.art; 1553 break; 1554 1555 case 'icecast': 1556 item = xmlDOM.querySelectorAll('track')[0]; 1557 songTitle = item.querySelector('title')?.innerHTML; 1558 artist = item.querySelector('artist')?.innerHTML; 1559 cover = item.querySelector('imageurl')?.innerHTML; 1560 break; 1561 1562 case 'leanstream': 1563 songTitle = xmlDOM.querySelector('songtitle')?.textContent; 1564 artist = xmlDOM.querySelector('artist')?.textContent; 1565 cover = ''; 1566 break; 1567 1568 case 'live365': 1569 songTitle = resp['current-track']['title']; 1570 artist = resp['current-track']['artist']; 1571 cover = resp['current-track']['art']; 1572 break; 1573 1574 case 'cirrus': 1575 item = xmlDOM.querySelectorAll('song')[0]; 1576 songTitle = item.querySelector('title')?.innerHTML; 1577 artist = item.querySelector('artist')?.innerHTML; 1578 cover = item.querySelector('cover')?.innerHTML; 1579 break; 1580 1581 case 'cool': 1582 // Works with either the RSS (XML) or JSON version of the feed. 1583 if (xmlDOM) { 1584 item = xmlDOM.querySelectorAll('item')[0]; 1585 let fullTitle = item?.querySelector('title')?.innerHTML.split(' - ') || []; 1586 songTitle = fullTitle[0]?.trim(); 1587 artist = fullTitle[1]?.trim(); 1588 } else if (resp && Array.isArray(resp.items) && resp.items[0]) { 1589 let fullTitle = (resp.items[0].title || '').split(' - '); 1590 songTitle = fullTitle[0]?.trim(); 1591 artist = fullTitle[1]?.trim(); 1592 } 1593 duration = 15000; 1594 break; 1595 1596 case 'triton': 1597 item = xmlDOM.querySelectorAll('nowplaying-info')[0]; 1598 if (item) { 1599 songTitle = item.querySelector('property[name="cue_title"]')?.innerHTML || ''; 1600 artist = item.querySelector('property[name="track_artist_name"]')?.innerHTML?.replace(/--and /g, ' & ') || ''; 1601 } else { 1602 songTitle = this.currentStation.title; 1603 artist = ''; 1604 } 1605 duration = 15000; 1606 break; 1607 1608 case 'futuri': 1609 songTitle = resp[0].TIT2; 1610 artist = resp[0].TPE1; 1611 break; 1612 1613 case 'soundstack': 1614 songTitle = resp['title']; 1615 artist = resp['artist']; 1616 break; 1617 1618 case 'radioco': 1619 let fullTitleRC = resp['data']['title']?.split(' - '); 1620 if (fullTitleRC && fullTitleRC.length > 1) { 1621 songTitle = fullTitleRC[0]; 1622 artist = fullTitleRC[1]; 1623 } else { 1624 songTitle = resp['data']['title']; 1625 artist = ''; 1626 } 1627 cover = resp['data']['artwork_urls']?.['standard']; 1628 break; 1629 1630 case 'guys': 1631 if (resp.StreamTitle) { 1632 const titleWithArtist = resp.StreamTitle.trim(); 1633 1634 if (titleWithArtist.startsWith('(') && titleWithArtist.endsWith(')')) { 1635 songTitle = ''; 1636 artist = titleWithArtist.substring(1, titleWithArtist.length - 1); 1637 } else { 1638 let lastOpenParenIndex = -1; 1639 let depth = 0; 1640 1641 for (let i = 0; i < titleWithArtist.length; i++) { 1642 if (titleWithArtist[i] === '(') { 1643 if (depth === 0) { 1644 lastOpenParenIndex = i; 1645 } 1646 depth++; 1647 } else if (titleWithArtist[i] === ')') { 1648 depth--; 1649 } 1650 } 1651 1652 if (lastOpenParenIndex !== -1) { 1653 songTitle = titleWithArtist.substring(0, lastOpenParenIndex).trim(); 1654 artist = titleWithArtist.substring(lastOpenParenIndex + 1, titleWithArtist.length - 1); 1655 } else { 1656 songTitle = titleWithArtist; 1657 artist = ''; 1658 } 1659 } 1660 1661 if (!songTitle && !artist) { 1662 songTitle = this.currentStation.title; 1663 } 1664 } else { 1665 songTitle = this.currentStation.title; 1666 artist = ''; 1667 } 1668 break; 1669 1670 default: 1671 console.log('Unknown streaming provider:', streamingProvider); 1672 songTitle = this.currentStation.title; 1673 artist = ''; 1674 break; 1675 } 1676 1677 // Get station thumbnail for fallback 1678 const stationThumb = this.currentStation && this.currentStation.thumb ? this.currentStation.thumb[0] : null; 1679 1680 // If we already have cover art from the stream feed, use it 1681 if (cover && !cover.includes('nocover') && !cover.includes('no-cover') && !cover.includes('noimage') && !cover.includes('no-image') && !cover.includes('placeholder') && !cover.includes('blankart') && !cover.includes('default')) { 1682 this.setInfoBar( 1683 songTitle || 'Live Stream', 1684 stationThumb, // Use station thumbnail as the main image 1685 artist || '', 1686 false, 1687 cover // Pass the album cover to the onAir parameter 1688 ); 1689 } else { 1690 // Try to find album artwork in this order: 1) artist post, 2) iTunes 1691 this.findAlbumArtwork(songTitle, artist, stationThumb); 1692 } 1693 1694 // Set up timer to refresh the feed 1695 clearInterval(this.streamFeedTimer); 1696 1697 // Reset backoff on successful fetch 1698 this.streamFeedConsecutiveErrors = 0; 1699 this.streamFeedBackoffMs = duration; 1700 1701 this.streamFeedTimer = setInterval(() => { 1702 clearInterval(this.streamFeedTimer); 1703 this.streamFeed(this.currentProvider); 1704 }, duration); 1705 1706 } catch (error) { 1707 console.error(`Error fetching stream data for ${streamingProvider}:`, error); 1708 1709 // Set the infoBar for current station with fallback 1710 const stationTitle = this.currentStation?.title || 'Live Stream'; 1711 const stationThumb = this.currentStation?.thumb?.[0] || null; 1712 const stationPosition = this.currentStation?.station_position?.[0] || ''; 1713 1714 this.setInfoBar(stationTitle, stationThumb, stationPosition); 1715 1716 // Exponential backoff on errors to prevent hammering the server 1717 this.streamFeedConsecutiveErrors++; 1718 const backoffDelay = Math.min( 1719 this.streamFeedBackoffMs * Math.pow(1.5, this.streamFeedConsecutiveErrors), 1720 this.streamFeedMaxBackoffMs 1721 ); 1722 1723 console.warn(`StreamPlayer: Stream feed error, retry in ${Math.round(backoffDelay / 1000)}s (consecutive errors: ${this.streamFeedConsecutiveErrors})`); 1724 1725 // Try again after backoff delay 1726 clearTimeout(this.errorTimer); 1727 this.errorTimer = setTimeout(() => { 1728 this.streamFeed(streamingProvider); 1729 }, backoffDelay); 1730 } 1731 } 1732 1733 clearTimers() { 1734 clearInterval(this.onAirFeedTimer); 1735 clearInterval(this.streamFeedTimer); 1736 clearTimeout(this.errorTimer); 1737 clearTimeout(this._streamStallTimerId); 1738 1739 // Also stop any pending OnAir polling timeout 1740 if (this.onAirPollTimeoutId) { 1741 clearTimeout(this.onAirPollTimeoutId); 1742 this.onAirPollTimeoutId = null; 1743 } 1744 this.onAirPollCategoryId = null; 1745 } 1746 1747 // Mark the current stream as geo-restricted / unavailable 1748 handleStreamUnavailable() { 1749 this.streamUnavailable = true; 1750 this.streamAudio.pause(); 1751 // Tear down any active hls.js instance so its segment loader stops 1752 // hammering the geo-blocked endpoint and emitting more error events. 1753 if (typeof this._teardownHls === 'function') { 1754 this._teardownHls(); 1755 } 1756 this.streamAudio.src = ''; 1757 this.player.playBtn.classList.remove('active', 'loading'); 1758 this.clearTimers(); 1759 this.clearSleepTimer(); 1760 const stationThumb = this.currentStation?.thumb?.[0] || null; 1761 this.setInfoBar('Stream not available in your location.', stationThumb, ''); 1762 } 1763 1764 // Start the sleep timer based on settings 1765 startSleepTimer() { 1766 this.clearSleepTimer(); 1767 const seconds = parseInt(this.settings?.sleep_timer, 10); 1768 if (!seconds || seconds <= 0) return; 1769 this.sleepTimerId = setTimeout(() => { 1770 if (this.streamAudio && !this.streamAudio.paused) { 1771 this.streamAudio.pause(); 1772 this.player.playBtn.classList.remove('active'); 1773 } 1774 this.sleepTimerId = null; 1775 }, seconds * 1000); 1776 } 1777 1778 // Clear the sleep timer 1779 clearSleepTimer() { 1780 if (this.sleepTimerId) { 1781 clearTimeout(this.sleepTimerId); 1782 this.sleepTimerId = null; 1783 } 1784 } 1785 1786 // Determine if the main player is actively playing (used to gate OnAir polling) 1787 isPlayerActive() { 1788 try { 1789 return this.streamAudio && !this.streamAudio.paused; 1790 } catch (e) { 1791 return false;
1792 } 1793 } 1794 1795 // Start a guarded polling loop for the OnAir currentshow endpoint 1796 startOnAirPolling(categoryId, forceRestart = false) { 1797 if (this.nowPlayingUpdatesPaused) return; 1798 1799 // Do not start polling when page is hidden or player is not active 1800 if (document.visibilityState === 'hidden' || !this.isPlayerActive()) { 1801 return; 1802 } 1803 1804 // If we're already polling this category and not forcing restart, do nothing 1805 if (!forceRestart && this.onAirPollCategoryId === categoryId && this.onAirPollTimeoutId) { 1806 return; 1807 } 1808 1809 this.onAirPollCategoryId = categoryId; 1810 this.onAirPollBackoffMs = 60000; // reset backoff to 60s base interval 1811 1812 // Clear any existing timeout before starting a new loop 1813 if (this.onAirPollTimeoutId) { 1814 clearTimeout(this.onAirPollTimeoutId); 1815 this.onAirPollTimeoutId = null; 1816 } 1817 1818 this._scheduleNextOnAirPoll(false); 1819 } 1820 1821 // Stop the OnAir polling loop completely 1822 stopOnAirPolling() { 1823 if (this.onAirPollTimeoutId) { 1824 clearTimeout(this.onAirPollTimeoutId); 1825 this.onAirPollTimeoutId = null; 1826 } 1827 this.onAirPollCategoryId = null; 1828 } 1829 1830 // Internal helper to schedule the next OnAir poll with optional backoff 1831 _scheduleNextOnAirPoll(isError) { 1832 if (this.nowPlayingUpdatesPaused || !this.onAirPollCategoryId) return; 1833 1834 // Exponential backoff on errors (including 429s) 1835 if (isError) { 1836 this.onAirPollBackoffMs = Math.min(this.onAirPollBackoffMs * 2, this.onAirPollMaxBackoffMs); 1837 } else { 1838 this.onAirPollBackoffMs = 60000; 1839 } 1840 1841 const delay = Math.max(this.onAirPollBackoffMs, 60000); 1842 1843 if (this.onAirPollTimeoutId) { 1844 clearTimeout(this.onAirPollTimeoutId); 1845 } 1846 1847 this.onAirPollTimeoutId = setTimeout(() => { 1848 this._onAirPollTick(); 1849 }, delay); 1850 } 1851 1852 // Single tick of the OnAir polling loop 1853 async _onAirPollTick() { 1854 if (this.nowPlayingUpdatesPaused || !this.onAirPollCategoryId) { 1855 this.stopOnAirPolling(); 1856 return; 1857 } 1858 1859 if (document.visibilityState === 'hidden' || !this.isPlayerActive()) { 1860 this.stopOnAirPolling(); 1861 return; 1862 } 1863 1864 let isError = false; 1865 1866 try { 1867 const onAir = await this.fetchOnAirCurrentshow(this.onAirPollCategoryId); 1868 this.updateOnAirFromResponse(onAir); 1869 } catch (error) { 1870 isError = true; 1871 console.error('Error in OnAir polling loop:', error); 1872 this.handleOnAirErrorFallback(); 1873 } 1874 1875 this._scheduleNextOnAirPoll(isError); 1876 } 1877 1878 // Toggle info 1879 async toggleInfoBar() { 1880 try { 1881 this.clearTimers() 1882 const station = this.currentStation; 1883 1884 // Handle case where station is null (for regular audio files) 1885 if (!station) { 1886 console.log('No station data available, using current audio metadata'); 1887 1888 // If we have metadata for the current audio, use that 1889 if (this.currentAudioMetadata) { 1890 this.updatePlayerWithMetadata(this.currentAudioMetadata); 1891 } else { 1892 // Use a generic title if no metadata is available 1893 const audioSrc = this.streamAudio?.src || ''; 1894 const fileName = audioSrc.split('/').pop().split('.')[0] || "Unknown Track"; 1895 const prettyFileName = fileName.replace(/[-_]/g, ' '); 1896 1897 // Update the StreamPlayer UI with just the file name 1898 this.setInfoBar(prettyFileName, null, "Playing embedded audio", false); 1899 } 1900 return; 1901 } 1902 1903 // Original implementation with minimal changes for error handling 1904 if (!station.station_shows) { 1905 console.log('Station has no shows data:', station); 1906 this.setInfoBar(station.title, station.thumb && station.thumb[0] ? station.thumb[0] : null, 1907 station.station_position && station.station_position[0] ? station.station_position[0] : ''); 1908 return; 1909 } 1910 1911 // Check info type using the original implementation 1912 switch (station.station_shows[0]) { 1913 case 'onair': { 1914 // Determine category: explicit or "all" when missing 1915 let categoryId = 'all'; 1916 if (station.station_onaircat && station.station_onaircat[0]) { 1917 categoryId = String(station.station_onaircat[0]); 1918 } 1919 1920 // Immediate one-shot update 1921 await this.onAirFeed(categoryId); 1922 1923 // Start guarded polling for subsequent updates 1924 this.startOnAirPolling(categoryId, true); 1925 break; 1926 } 1927 case 'stream': 1928 if (station.station_streamer && station.station_streamer[0]) { 1929 let streamer = station.station_streamer[0]; 1930 await this.streamFeed(streamer); 1931 } else { 1932 this.setInfoBar(station.title, station.thumb && station.thumb[0] ? station.thumb[0] : null, ''); 1933 } 1934 break; 1935 default: 1936 this.setInfoBar(station.title, station.thumb && station.thumb[0] ? station.thumb[0] : null, 1937 station.station_position && station.station_position[0] ? station.station_position[0] : ''); 1938 break; 1939 } 1940 } catch (error) { 1941 console.error('Error in toggleInfoBar:', error); 1942 // Fallback to basic display if there's an error 1943 if (this.currentStation) { 1944 this.setInfoBar(this.currentStation.title || 'Live Stream', 1945 this.currentStation.thumb && this.currentStation.thumb[0] ? this.currentStation.thumb[0] : null, ''); 1946 } else { 1947 this.setInfoBar('Live Stream', null, ''); 1948 } 1949 } 1950 } 1951 1952 // Set an info on the current bar 1953 setInfoBar(title, img, belowContent, link = null, onAir = null) { 1954 // Ensure we have valid values to prevent errors 1955 title = title || 'Playing'; 1956 belowContent = belowContent || ''; 1957 // Make sure player and its elements exist 1958 if (!this.player) { 1959 console.error('Player object is null or undefined'); 1960 return; 1961 } 1962 1963 const player = this.player; 1964 1965 // Check if player elements exist before using them 1966 if (!player.currImg) { 1967 console.error('Player image element is null or undefined'); 1968 return; 1969 } 1970 1971 const imgWrapper = player.currImg.parentElement; 1972 if (!imgWrapper) { 1973 console.error('Image wrapper element is null or undefined'); 1974 return; 1975 } 1976 1977 // Handle image display 1978 if (img) { 1979 if (player.currImg.src !== img) player.currImg.src = img; 1980 if (imgWrapper.classList.contains('hidden')) imgWrapper.classList.remove('hidden'); 1981 } else { 1982 if (!imgWrapper.classList.contains('hidden')) imgWrapper.classList.add('hidden'); 1983 } 1984 1985 // Toggle tickers safely 1986 try { 1987 this.toggleTickers(title, belowContent); 1988 } catch (error) { 1989 console.error('Error in toggleTickers:', error); 1990 } 1991 1992 // Handle on-air display 1993 if (player.onAir) { 1994 if (onAir) { 1995 this.setOnAir(onAir); 1996 } else { 1997 player.onAir.innerHTML = ''; 1998 } 1999 } 2000 2001 // Set title and position text if elements exist 2002 if (player.currTitle) { 2003 player.currTitle.innerText = title; 2004 } 2005 2006 if (player.currPosition) { 2007 player.currPosition.innerHTML = !link ? belowContent : `<a href="${belowContent}" class="preroll--link">${belowContent}</a>`; 2008 } 2009 2010 if ('mediaSession' in navigator) {
2011 navigator.mediaSession.metadata = new MediaMetadata({ 2012 title: title, 2013 artist: belowContent, 2014 album: 'Skyrocket Radio Player', 2015 artwork: [{ src: img, sizes: '256x256', type: 'image/jpg' }], 2016 }); 2017 } 2018 } 2019 2020 // Tickers 2021 toggleTickers(title, belowContent) { 2022 // Ensure we have valid values and player exists 2023 if (!this.player) { 2024 console.error('Player object is null or undefined in toggleTickers'); 2025 return; 2026 } 2027 2028 const player = this.player; 2029 2030 // Ensure player elements exist 2031 if (!player.playerContainer || !player.currTitle || !player.currPosition || !player.desc) { 2032 console.error('Required player elements are missing in toggleTickers'); 2033 return; 2034 } 2035 2036 // Ensure title and belowContent are strings 2037 title = title || ''; 2038 belowContent = belowContent || ''; 2039 2040 const isPlayerSide = player.playerContainer.classList.contains('player--side'); 2041 let ticker = false; 2042 2043 // Check Title 2044 if (title.length > 20 && (isPlayerSide || win.outerWidth <= 580)) { 2045 ticker = true; 2046 if (player.currTitle.parentElement && !player.currTitle.parentElement.classList.contains('ticker--active')) { 2047 player.currTitle.parentElement.classList.add('ticker--active'); 2048 } 2049 } else { 2050 if (player.currTitle.parentElement && player.currTitle.parentElement.classList.contains('ticker--active')) { 2051 player.currTitle.parentElement.classList.remove('ticker--active'); 2052 } 2053 } 2054 2055 // Check below content 2056 if (belowContent.length > 20 && (isPlayerSide || win.outerWidth <= 580)) { 2057 ticker = true; 2058 if (player.currPosition.parentElement && !player.currPosition.parentElement.classList.contains('ticker--active')) { 2059 player.currPosition.parentElement.classList.add('ticker--active'); 2060 } 2061 } else { 2062 if (player.currPosition.parentElement && player.currPosition.parentElement.classList.contains('ticker--active')) { 2063 player.currPosition.parentElement.classList.remove('ticker--active'); 2064 } 2065 } 2066 2067 // Toggle ticker enabled class 2068 if (ticker) { 2069 player.desc.classList.add('tickerEnabled'); 2070 } else { 2071 player.desc.classList.remove('tickerEnabled'); 2072 } 2073 } 2074 2075 // Fetch image from custom Artist Image endpoint (fast) 2076 async fetchImageFromArtistPost(title) { 2077 if (!title || !this.artistPostEnabled) { 2078 return false; 2079 } 2080 2081 try { 2082 const encodedTitle = encodeURIComponent(title.trim()); 2083 2084 // Prefer the lightweight endpoint you already have: 2085 // /wp-json/srr/v1/artist-image?title=... 2086 const res = await this.fetchAPI(`${this.siteUrl}/wp-json/srr/v1/artist-image?title=${encodedTitle}`); 2087 2088 // Your endpoint returns: { source_url: "..." } with 200, or { source_url: null } with 404 2089 if (res && res.source_url) { 2090 return res.source_url; 2091 } 2092 2093 return false; 2094 2095 } catch (error) { 2096 // Keep it quiet in production; one failure shouldn't spam console 2097 return false; 2098 } 2099 } 2100 2101 // Support for OnAir - Handles both category-specific and all shows cases 2102 async onAirFeed(categoryId) { 2103 // If updates are paused, do nothing 2104 if (this.nowPlayingUpdatesPaused) return; 2105 clearInterval(this.streamFeedTimer); 2106 2107 // Do not perform OnAir fetch when page is hidden 2108 // (Allow a one-shot UI update even if the player is not yet actively playing) 2109 if (document.visibilityState === 'hidden') { 2110 return; 2111 } 2112 2113 try { 2114 const onAir = await this.fetchOnAirCurrentshow(categoryId); 2115 this.updateOnAirFromResponse(onAir); 2116 } catch (error) { 2117 console.error('Error in onAirFeed:', error); 2118 this.handleOnAirErrorFallback(); 2119 } 2120 } 2121 2122 // Fetch helper dedicated to the /wp-json/onair/v1/currentshow endpoint 2123 async fetchOnAirCurrentshow(categoryId) { 2124 const baseUrl = `${this.siteUrl}/wp-json/onair/v1/currentshow`; 2125 const url = categoryId === 'all' ? baseUrl : `${baseUrl}/${categoryId}`; 2126 2127 const controller = new AbortController(); 2128 const timeoutId = setTimeout(() => controller.abort(), 5000); 2129 2130 // Optional API key support: use the shared hardcoded key from the player instance 2131 const headers = {}; 2132 if (this.ONAIR_API_KEY) { 2133 headers['X-OnAir-Api-Key'] = this.ONAIR_API_KEY; 2134 } 2135 2136 try { 2137 const response = await fetch(url, { 2138 signal: controller.signal, 2139 method: 'GET', 2140 headers 2141 }); 2142 2143 clearTimeout(timeoutId); 2144 2145 if (!response.ok) { 2146 const error = new Error(`OnAir currentshow request failed with status ${response.status}`); 2147 error.status = response.status; 2148 throw error; 2149 } 2150 2151 return await response.json(); 2152 } catch (error) { 2153 clearTimeout(timeoutId); 2154 throw error; 2155 } 2156 } 2157 2158 // Normalize and apply the OnAir response to the player UI 2159 updateOnAirFromResponse(onAir) { 2160 // Basic error handling for null/undefined response 2161 if (!onAir) { 2162 this.handleOnAirErrorFallback(); 2163 return; 2164 } 2165
2166 // Handle array response (multiple shows) 2167 if (Array.isArray(onAir)) { 2168 onAir = onAir[0]; 2169 2170 // If no shows found in array 2171 if (!onAir) { 2172 this.handleOnAirErrorFallback(); 2173 return; 2174 } 2175 } 2176 2177 // Make sure we have the required properties 2178 if (!onAir.start || !onAir.end) { 2179 console.error('Invalid on-air data format, missing start/end times:', onAir); 2180 this.handleOnAirErrorFallback(onAir); 2181 return; 2182 } 2183 2184 // Format the times 2185 let startTime = `${onAir.start.slice(0, 2)}:${onAir.start.slice(2, 4)}`; 2186 let endTime = `${onAir.end.slice(0, 2)}:${onAir.end.slice(2, 4)}`; 2187 2188 // Format the times for display 2189 startTime = this.timeFormat(startTime); 2190 endTime = this.timeFormat(endTime); 2191 2192 // Format the display as "Show Name" with "time - time" below 2193 const showTitle = onAir.show; 2194 let timeRange = ''; 2195 2196 // Always show time range, including shows that end at midnight (2400) 2197 // Only skip for 24-hour shows (0000-2400) 2198 if (onAir.start === "0000" && onAir.end === "2400") { 2199 // 24-hour show, don't show time range 2200 timeRange = ''; 2201 } else { 2202 // For shows with specific times, including those ending at midnight 2203 timeRange = `${startTime.time} - ${endTime.time}`; 2204 } 2205 2206 // Update the player UI elements 2207 if (this.player.currTitle) { 2208 this.player.currTitle.innerText = showTitle; 2209 } 2210 2211 if (this.player.currPosition) { 2212 this.player.currPosition.innerText = timeRange; 2213 } 2214 2215 if (this.player.currImg && this.currentStation && this.currentStation.thumb) { 2216 this.player.currImg.src = this.currentStation.thumb[0]; 2217 } 2218 2219 // Set the onAir image if available 2220 if (onAir.image) { 2221 this.setOnAir(onAir.image); 2222 } 2223 } 2224 2225 // Fallback handler used when OnAir data is missing or an error occurs 2226 handleOnAirErrorFallback(onAir = null) { 2227 if (this.currentStation && this.currentStation.thumb) { 2228 this.setInfoBar((onAir && onAir.show) || this.currentStation.title || 'Live Stream', this.currentStation.thumb[0], ''); 2229 } else { 2230 this.setInfoBar((onAir && onAir.show) || 'Live Stream', null, ''); 2231 } 2232 } 2233 2234 // Time formatter 2235 timeFormat(time) { 2236 time = time.split(':'); 2237 let date = new Date(), 2238 today = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds())), 2239 hour = time[0], minutes = time[1], fullDate, ampm; 2240 2241 fullDate = new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), hour, minutes); 2242 ampm = (hour >= 12) ? 'pm' : 'am'; 2243 hour = hour % 12; 2244 hour = hour ? hour : 12; 2245 minutes = minutes.toString().padStart(2, '0'); 2246 return { 2247 fullDate, 2248 time: hour + ':' + minutes + ' ' + ampm 2249 } 2250 } 2251 2252 // Update the embedded player's UI when playing through StreamPlayer 2253 trackEmbeddedAudioProgress() { 2254 try { 2255 2256 // Check if player exists 2257 if (!this.player) { 2258 console.error("Player object is null or undefined"); 2259 return; 2260 } 2261 2262 // Store reference to the original audio element and its container 2263 if (this.currentMinimalPlayer && !this.originalAudioElement) { 2264 this.originalAudioElement = this.currentMinimalPlayer.querySelector('audio'); 2265 } 2266 2267 // Update the file information in the StreamPlayer if we have metadata 2268 if (this.currentAudioMetadata) { 2269 this.updatePlayerWithMetadata(this.currentAudioMetadata); 2270 } else if (this.originalAudioSrc) { 2271 // Fallback to just using the file name 2272 try { 2273 const fileName = this.originalAudioSrc.split('/').pop().split('.')[0] || "Unknown Track"; 2274 const prettyFileName = fileName.replace(/[-_]/g, ' '); 2275 2276 // Update the StreamPlayer UI with just the file name 2277 this.setInfoBar(prettyFileName, null, "Playing embedded audio", false); 2278 } catch (parseError) { 2279 this.setInfoBar("Audio Track", null, "Playing embedded audio", false); 2280 } 2281 } else { 2282 this.setInfoBar("Audio Track", null, "Playing embedded audio", false); 2283 } 2284 2285 // Make sure the player elements exist before accessing them 2286 if (this.player.playBtn) { 2287 // Make sure the play button is clickable 2288 this.player.playBtn.disabled = false;
2289 this.player.playBtn.style.pointerEvents = 'auto'; 2290 this.player.playBtn.style.cursor = 'pointer'; 2291 this.player.playBtn.style.opacity = '1'; 2292 } else { 2293 console.warn("Play button not found in player"); 2294 } 2295 2296 // Also ensure the player container allows clicks if it exists 2297 if (this.player.playerContainer) { 2298 this.player.playerContainer.style.pointerEvents = 'auto'; 2299 2300 // Add a class to indicate we're playing embedded audio 2301 this.player.playerContainer.classList.add('playing-embedded-audio'); 2302 } else { 2303 console.warn("Player container not found"); 2304 } 2305 2306 //console.log("trackEmbeddedAudioProgress completed successfully"); 2307 } catch (error) { 2308 console.error("Error in trackEmbeddedAudioProgress:", error); 2309 } 2310 } 2311 2312 // Update the StreamPlayer UI with metadata from the embedded audio 2313 updatePlayerWithMetadata(metadata) { 2314 // Get the metadata values 2315 const title = metadata.title || ''; 2316 const image = metadata.image || null; 2317 const isPodcast = metadata.isPodcast || false; 2318 const artist = metadata.artist || null; 2319 const album = metadata.album || null; 2320 2321 // IMPORTANT DEBUG: Log extracted values 2322 //console.log('DEBUG - Extracted metadata values:', { 2323 // title, 2324 // image, 2325 // isPodcast, 2326 // artist, 2327 // album 2328 //}); 2329 2330 // Determine what to display in the info bar 2331 let displayTitle = title; 2332 let displayArtist = artist; 2333 let displayAlbum = album; 2334 2335 // For podcasts, handle differently 2336 if (isPodcast) { 2337 // If we have an artist, use that instead of 'Podcast' 2338 if (artist) { 2339 displayArtist = artist; 2340 // Only show 'Podcast' as album if we don't have an artist 2341 displayAlbum = ''; 2342 } else { 2343 displayAlbum = 'Podcast'; 2344 } 2345 } else if (!displayAlbum) { 2346 // For regular audio files, don't show 'Audio File' as the album if we have a title 2347 displayAlbum = title ? '' : 'Audio File'; 2348 2349 // If we have an artist for regular audio, make sure it's displayed 2350 if (artist) { 2351 displayArtist = artist; 2352 } 2353 } 2354 2355 // IMPORTANT: For regular audio files, ensure we show the title prominently 2356 // If we don't have an album but have a title, use an empty string for album 2357 // to make the title more prominent 2358 if (!isPodcast && !album && title) { 2359 displayAlbum = ''; 2360 } 2361 2362 // IMPORTANT: Use the existing setInfoBar method to display title, image, and artist 2363 // This is the same method used for live streams 2364 2365 // Call setInfoBar with the artist as the third parameter (belowContent) 2366 // This matches how live stream artist info is displayed 2367 this.setInfoBar(displayTitle, image, displayArtist, false); 2368 2369 // Set the image if we have one 2370 if (image && this.player.currImg) { 2371 this.player.currImg.src = image; 2372 this.player.currImg.style.display = 'block'; 2373 } 2374 2375 // Force update the title display 2376 if (this.player.infoBarTitle && displayTitle) { 2377 this.player.infoBarTitle.innerHTML = displayTitle; 2378 } 2379 } 2380 2381 // Set on air thumbnail 2382 setOnAir(img) { 2383 const onAirElement = this.player?.onAir; 2384 if (!onAirElement) return; 2385 2386 const currentImg = onAirElement.querySelector('img'); 2387 if (currentImg?.src === img) return; 2388 2389 const labelName = this.currentStation.station_shows[0] === 'onair' ? 'On Air' : 'Now Playing'; 2390 onAirElement.innerHTML = `<span>${labelName}</span><figure><img src="${img}" alt="${labelName}" /></figure>`; 2391 2392 // Handle broken images in onAir 2393 const newImg = onAirElement.querySelector('img'); 2394 if (newImg) { 2395 newImg.onerror = function () { 2396 this.style.opacity = '0'; 2397 }; 2398 } 2399 } 2400 2401 // Open & hide playlist 2402 2403 togglePlaylist() { 2404 this.player.playList.classList.toggle("opened"); 2405 this.player.playListBtn.classList.toggle("active"); 2406 this.player.overlay.classList.toggle("active"); 2407 this.contentSite.classList.toggle('streamplayer--list-opened'); 2408 const isOpen = this.player.playList.classList.contains("opened"); 2409 this.player.playListBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); 2410 if (isOpen) { 2411 // Run autoclose timer 2412 this.timer = setTimeout(() => this.togglePlaylist(), 15000); 2413 } else { 2414 clearTimeout(this.timer); 2415 } 2416 } 2417 2418 // List & content builder 2419 2420 listBuilder() { 2421 if (this.player.playList.querySelectorAll('li').length) return; 2422 let stationsList = doc.createElement('li'); 2423 stationsList.classList.add('streamplayer--stations'); 2424 if (this.stations.length >= 1) { 2425 this.stations.forEach((station, id) => { 2426 if (station.station_enable === "0") return; 2427 const LI = doc.createElement("li"); 2428 LI.classList.add("streamplayer--station-item"); 2429 LI.setAttribute("data-id", id); 2430 LI.innerHTML = `<span></span> 2431 <figure class="streamplayer--img-station"> 2432 <img src="${station.thumb && station.thumb[0] ? station.thumb[0] : 'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%22400%22%20height=%22400%22%3E%3Crect%20width=%22400%22%20height=%22400%22%20fill=%22gray%22%20/%3E%3C/svg%3E'}" alt="${station.title}"> 2433 </figure> 2434 <div class="streamplayer--desc"> 2435 <h3>${station.title}</h3> 2436 <p>${station.station_position && station.station_position[0] ? station.station_position[0] : ''}</p> 2437 </div> 2438 `; 2439 // Handle broken images in playlist 2440 const img = LI.querySelector('img'); 2441 if (img) { 2442 img.onerror = function () { 2443 this.style.opacity = '0'; 2444 }; 2445 } 2446 // Keyboard accessibility for station items 2447 LI.setAttribute('tabindex', '0'); 2448 LI.setAttribute('role', 'option'); 2449 LI.addEventListener('keydown', (e) => { 2450 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); LI.click(); } 2451 }); 2452 stationsList.appendChild(LI); 2453 }); 2454 } 2455 if (this.settings.player_content === "stations") { 2456 if (this.stations.length <= 1) { 2457 this.player.playListBtn.remove(); 2458 } else {
2459 let stationsLi = stationsList.querySelectorAll('li'); 2460 stationsLi.forEach(LI => this.player.playList.appendChild(LI)); 2461 } 2462 2463 } else if (this.settings.player_content === "content") { 2464 let liElements = doc.querySelectorAll('.streamplayer--content-tpl .streamplayer--cnt li'); 2465 if (this.stations.length >= 1) this.player.playList.appendChild(stationsList); 2466 liElements.forEach(li => this.player.playList.appendChild(li)); 2467 } else if (this.settings.player_content === "disabled") { 2468 this.player.playListBtn.remove(); 2469 } 2470 } 2471 2472 // Advertise builder 2473 2474 adsBuilder() { 2475 let adsTpl = doc.querySelector('.streamplayer--content-tpl .streamplayer--adv-tpl'); 2476 if (adsTpl) { 2477 this.player.adsContainer.innerHTML = adsTpl.innerHTML; 2478 2479 // Minimal integration with SRR Ads: after the ad block HTML has been 2480 // inserted into the visible player, let the SRR Ad Refresher 2481 // initialize any carousels/rotations inside the player area. 2482 if (window.srrAdRefresher && typeof window.srrAdRefresher.randomizeAdCarousels === 'function') { 2483 window.srrAdRefresher.randomizeAdCarousels(); 2484 } 2485 } 2486 adsTpl?.remove(); 2487 } 2488 2489 // Preroll content builder 2490 2491 prerollBuilder(add = true) { 2492 const player = this.player.playerContainer; 2493 if (add) { 2494 const preroll = this.currentPreroll; 2495 if (!preroll || !preroll.preroll_adblock) return; 2496 2497 const LI = doc.createElement("li"); 2498 const adScript = doc.createElement("script"); 2499 // Toggle hasBanner class to the player 2500 player.classList.toggle('hasBanner'); 2501 adScript.setAttribute("type", "text/javascript"); 2502 adScript.innerHTML = `jQuery(document).ready(function() { 2503 jQuery('.adlink').click(function(){ 2504 var linkid=jQuery(this).attr('id'); 2505 jQuery.get("/wp-content/plugins/srr-ads/click.php", { id: linkid } ); 2506 }); 2507 });`; 2508 2509 // Safely set innerHTML and append script 2510 try { 2511 // Clean the preroll content to prevent JavaScript syntax errors 2512 let cleanContent = preroll.preroll_adblock; 2513 if (typeof cleanContent === 'string') { 2514 // Remove any potential malformed JavaScript that could cause endLine errors 2515 cleanContent = cleanContent.replace(/endLine/g, ''); 2516 // Remove any incomplete script tags or malformed syntax 2517 cleanContent = cleanContent.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ''); 2518 } 2519 2520 LI.innerHTML = cleanContent; 2521 LI.appendChild(adScript); 2522 this.player.prerollsContainter.appendChild(LI); 2523 } catch (error) { 2524 // If HTML is malformed, create a safe container 2525 LI.textContent = 'Ad content unavailable'; 2526 this.player.prerollsContainter.appendChild(LI); 2527 } 2528 } else { 2529 // Safely remove existing preroll items 2530 const existingItems = this.player.prerollsContainter.querySelectorAll('li'); 2531 if (existingItems && existingItems.length > 0) { 2532 existingItems.forEach(LI => LI.remove()); 2533 } 2534 // Toggle hasBanner class to the player 2535 if (player.classList.contains('hasBanner')) player.classList.toggle('hasBanner'); 2536 } 2537 } 2538 2539 // Fetch DATA from the server 2540 2541 /** 2542 * Check if we have valid cached stations data in sessionStorage 2543 * @returns {object|null} Cached data or null if expired/missing 2544 */ 2545 getStationsFromCache() { 2546 try { 2547 const cached = sessionStorage.getItem(this.stationsCacheKey); 2548 if (!cached) return null; 2549 2550 const { data, timestamp } = JSON.parse(cached); 2551 const age = Date.now() - timestamp; 2552 2553 // Check if cache is still valid 2554 if (age < this.stationsCacheTTL) { 2555 return data; 2556 } 2557 2558 // Cache expired, but return stale data if we're in backoff 2559 if (this.stationsApiRetryCount > 0 && data) { 2560 return data; 2561 } 2562 2563 return null; 2564 } catch (e) { 2565 return null; 2566 } 2567 } 2568 2569 /**
2570 * Save stations data to sessionStorage cache 2571 * @param {object} data Stations data to cache 2572 */ 2573 saveStationsToCache(data) { 2574 try { 2575 const cacheEntry = { 2576 data: data, 2577 timestamp: Date.now() 2578 }; 2579 sessionStorage.setItem(this.stationsCacheKey, JSON.stringify(cacheEntry)); 2580 } catch (e) { 2581 // sessionStorage might be full or disabled - silently ignore 2582 } 2583 } 2584 2585 // Fetch API with silent error handling, local caching, and retry backoff 2586 async fetchAPI(ENDPOINT = this.ENDPOINT) { 2587 const isStationsEndpoint = ENDPOINT === this.ENDPOINT; 2588 const isArtistEndpoint = 2589 /\/wp-json\/srr\/v1\/artist-image(?:\?|$)/.test(ENDPOINT) || 2590 /\/wp-json\/artist\/v1(?:\/|\?|$)/.test(ENDPOINT); 2591 2592 // For stations endpoint, check local cache first 2593 if (isStationsEndpoint) { 2594 const cachedData = this.getStationsFromCache(); 2595 if (cachedData) { 2596 // Reset retry count on cache hit 2597 this.stationsApiRetryCount = 0; 2598 this.stationsApiBackoffMs = 1000; 2599 return cachedData; 2600 } 2601 } 2602 2603 // Skip artist requests if they've failed before 2604 if (isArtistEndpoint && this.artistPostEnabled === false) { 2605 return null; 2606 } 2607 2608 try { 2609 const controller = new AbortController(); 2610 const timeoutId = setTimeout(() => controller.abort(), 5000); 2611 2612 const response = await fetch(ENDPOINT, { 2613 signal: controller.signal, 2614 method: 'GET' 2615 }).catch(() => null); 2616 2617 clearTimeout(timeoutId); 2618 2619 if (!response || !response.ok) { 2620 if (isArtistEndpoint) { 2621 this.artistEndpointFailCount = (this.artistEndpointFailCount || 0) + 1; 2622 this.artistEndpointLastFailAt = Date.now(); 2623 if (this.artistEndpointFailCount >= 2) { 2624 this.artistPostEnabled = false; 2625 } 2626 } 2627 2628 // For stations endpoint, implement retry with backoff 2629 if (isStationsEndpoint) { 2630 this.stationsApiRetryCount++; 2631 if (this.stationsApiRetryCount <= this.stationsApiMaxRetries) { 2632 // Exponential backoff 2633 const delay = Math.min( 2634 this.stationsApiBackoffMs * Math.pow(2, this.stationsApiRetryCount - 1), 2635 this.stationsApiMaxBackoffMs 2636 ); 2637 console.warn(`StreamPlayer: Stations API failed, retrying in ${delay}ms (attempt ${this.stationsApiRetryCount}/${this.stationsApiMaxRetries})`); 2638 await new Promise(resolve => setTimeout(resolve, delay)); 2639 return this.fetchAPI(ENDPOINT); 2640 } else { 2641 console.error('StreamPlayer: Stations API failed after max retries, using stale cache if available'); 2642 // Return stale cache as last resort 2643 const staleCache = this.getStationsFromCache(); 2644 if (staleCache) return staleCache; 2645 } 2646 } 2647 2648 return null; 2649 } 2650 2651 // Parse JSON response 2652 let data = null; 2653 try { 2654 data = await response.json(); 2655 } catch (e) { 2656 if (isArtistEndpoint) { 2657 this.artistEndpointFailCount = (this.artistEndpointFailCount || 0) + 1; 2658 this.artistEndpointLastFailAt = Date.now(); 2659 if (this.artistEndpointFailCount >= 2) { 2660 this.artistPostEnabled = false; 2661 } 2662 } 2663 return null; 2664 } 2665 2666 // For stations endpoint, cache the successful response 2667 if (isStationsEndpoint && data) { 2668 this.saveStationsToCache(data); 2669 // Reset retry state on success 2670 this.stationsApiRetryCount = 0; 2671 this.stationsApiBackoffMs = 1000; 2672 2673 // If response indicates lightweight mode, schedule a re-fetch later 2674 if (data.lightweight_mode) { 2675 console.log('StreamPlayer: Server returned lightweight mode, will re-fetch full data in 60s'); 2676 setTimeout(() => { 2677 // Clear cache and re-fetch 2678 try { sessionStorage.removeItem(this.stationsCacheKey); } catch (e) {} 2679 this.fetchAPI(this.ENDPOINT).then(newData => { 2680 if (newData && !newData.lightweight_mode) { 2681 this.API = newData; 2682 this.stations = newData.stations?.reverse() || []; 2683 this.settings = newData.settings || {}; 2684 } 2685 }); 2686 }, 60000); 2687 } 2688 } 2689 2690 // If the response is valid but empty, just return it 2691 return data; 2692 2693 } catch (error) { 2694 if (isArtistEndpoint) { 2695 this.artistEndpointFailCount = (this.artistEndpointFailCount || 0) + 1; 2696 this.artistEndpointLastFailAt = Date.now(); 2697 if (this.artistEndpointFailCount >= 2) { 2698 this.artistPostEnabled = false;
2699 } 2700 } 2701 return null; 2702 } 2703 } 2704 2705 // One play per station per browser per 30 minutes. `playing` fires again on 2706 // every resume after a pause and after a stall, which used to add a play 2707 // each time. localStorage carries the rule across page loads; the in-memory 2708 // copy covers browsers where storage is unavailable. 2709 countPlay(stationId) { 2710 const key = `srr_sp_play_${stationId}`; 2711 const now = Date.now(); 2712 this._playedAt = this._playedAt || {}; 2713 let last = this._playedAt[key] || 0; 2714 try { 2715 last = Math.max(last, parseInt(win.localStorage.getItem(key), 10) || 0); 2716 } catch (e) { } 2717 if (now - last < 30 * 60 * 1000) return; 2718 2719 this._playedAt[key] = now; 2720 try { 2721 win.localStorage.setItem(key, String(now)); 2722 } catch (e) { } 2723 this.fetchPlayCounter(stationId); 2724 } 2725 2726 // Listener check-ins: while a station plays, tell the site once per 2727 // counting window (2 minutes by default) so it can show "listening now". 2728 // Each response says how long to wait so the next check-in lands in the 2729 // middle of the next window â one check-in per listener per window, however 2730 // the timers drift. listenDue remembers each station's next scheduled 2731 // check-in, so pausing and resuming cannot count the listener twice: a resume 2732 // waits for that mid-window moment rather than the window's first second, 2733 // which a visitor's clock running a little fast could land in the old window. 2734 startListening(stationId) { 2735 if (!stationId) return; 2736 if (this.listenStationId === stationId) return; 2737 this.stopListening(); 2738 // Unknown to the site (404): never retried this page view. setTimeout 2739 // treats an Infinity delay as 0, so this has to be checked explicitly. 2740 if (this.listenDue[stationId] === Infinity) return; 2741 this.listenStationId = stationId; 2742 // A full page load starts a fresh player, so the schedule is also kept 2743 // in sessionStorage â otherwise reloading mid-window counts twice. 2744 let due = this.listenDue[stationId] || 0; 2745 try { 2746 due = Math.max(due, parseInt(win.sessionStorage.getItem(`srr_sp_listen_${stationId}`), 10) || 0); 2747 } catch (e) { } 2748 const wait = Math.max(0, due - Date.now()); 2749 this.listenTimer = setTimeout(() => this.sendListening(stationId), wait); 2750 } 2751 2752 stopListening() { 2753 clearTimeout(this.listenTimer); 2754 this.listenTimer = null; 2755 this.listenStationId = null; 2756 } 2757 2758 async sendListening(stationId) { 2759 this.listenTimer = null; 2760 // Provisional, until the response gives the next scheduled time. A 2761 // pause-and-resume while this request is out waits on it, and is 2762 // rescheduled below once the response arrives. 2763 this.listenDue[stationId] = Date.now() + 30000; 2764 2765 let nextIn = 120; 2766 try { 2767 const response = await fetch(`${this.LISTENING_ENDPOINT}${stationId}`, { 2768 method: 'POST', 2769 credentials: 'omit', 2770 keepalive: true 2771 }); 2772 if (response.status === 404) { 2773 // Not a published station: stop for this page view. 2774 this.listenDue[stationId] = Infinity; 2775 if (this.listenStationId === stationId) this.stopListening(); 2776 return; 2777 } 2778 const data = await response.json().catch(() => null); 2779 if (data && data.next_in > 0) nextIn = data.next_in; 2780 } catch (e) { } 2781 2782 this.listenDue[stationId] = Date.now() + nextIn * 1000; 2783 try { 2784 win.sessionStorage.setItem(`srr_sp_listen_${stationId}`, String(this.listenDue[stationId])); 2785 } catch (e) { } 2786 2787 if (this.listenStationId !== stationId) return; 2788 clearTimeout(this.listenTimer); 2789 this.listenTimer = setTimeout(() => this.sendListening(stationId), nextIn * 1000); 2790 } 2791 2792 // Separate method for the play counter which uses POST 2793 async fetchPlayCounter(stationId) { 2794 if (!stationId) return; 2795 2796 try { 2797 const controller = new AbortController(); 2798 const timeoutId = setTimeout(() => controller.abort(), 5000); 2799 2800 const response = await fetch(`${this.PLAYCOUNTER_ENDPOINT}${stationId}`, { 2801 signal: controller.signal, 2802 method: 'POST' 2803 }).catch(() => null); 2804 2805 clearTimeout(timeoutId); 2806 2807 if (response && response.ok) { 2808 return await response.json(); 2809 } 2810 2811 return null; 2812 } catch (error) { 2813 return null; 2814 } 2815 } 2816 2817 // Swap the server-rendered body classes for the incoming page's, leaving 2818 // every runtime-added class where it is. See serverBodyClasses above. 2819 updateBodyClasses(dom) { 2820 if (!dom || !dom.body) return; 2821 const incoming = (dom.body.className || '').split(/\s+/).filter(
2821Boolean); 2822 // A page that rendered no body class at all tells us nothing; stripping 2823 // what is there on the strength of that would be a guess. 2824 if (!incoming.length) return; 2825 2826 if (this.serverBodyClasses) { 2827 const wanted = new Set(incoming); 2828 this.serverBodyClasses.forEach((cls) => { 2829 if (!wanted.has(cls)) doc.body.classList.remove(cls); 2830 }); 2831 } 2832 incoming.forEach((cls) => doc.body.classList.add(cls)); 2833 this.serverBodyClasses = incoming; 2834 } 2835 2836 // LAZY LOADING 2837 2838 lazyLoad(href, addState = true) { 2839 let $this = this; 2840 // Get potential params from the link 2841 const URLParams = new URLSearchParams(href.split('?')[1]); 2842 // Check if the URL has reply param 2843 if (URLParams.has('replytocom')) return; 2844 // Check if the URL has streamplayer param 2845 if (this.autoChoose(URLParams, addState)) return; 2846 const delayedScripts = []; 2847 // Clean up any StreamPlayer-injected inline assets from previous page loads 2848 // so they do not accumulate across SPA navigations. 2849 doc.querySelectorAll("style[data-srr-lazy-style='1']").forEach((el) => el.remove()); 2850 doc.querySelectorAll("script[data-srr-lazy-inline='1']").forEach((el) => el.remove()); 2851 doc.querySelectorAll("script[data-srr-dynamic-script='1']").forEach((el) => el.remove()); 2852 // Remove all scripts from the page, withou the player 2853 doc.querySelectorAll('script').forEach(script => { 2854 if ((!script.id.includes('streamplayer') && script.src) || script.matches('.gtag')) script.remove(); 2855 }); 2856 const progressBar = this.player.progressBar; 2857 let currentProgress = 0, timerProgress; 2858 progressBar.classList.add('active'); 2859 timerProgress = setInterval(() => { 2860 currentProgress += 5; 2861 if (currentProgress >= 140) { 2862 clearInterval(timerProgress); 2863 currentProgress = 0; 2864 progressBar.classList.remove('active'); 2865 } 2866 progressBar.style.width = `${currentProgress}%`; 2867 }, 80); 2868 fetch(href) 2869 .then((response) => response.text()) 2870 .then((newPageHTML) => { 2871 let parser = new DOMParser(), 2872 dom = parser.parseFromString(newPageHTML, "text/html"); 2873 // Remove streamplayer.js script 2874 dom.querySelector("#streamplayer-js").remove(); 2875 dom.querySelector("#streamplayer-css")?.remove(); 2876 // Re-run inline styles 2877 dom.querySelectorAll("style")?.forEach((style) => { 2878 const inlineStyle = doc.createElement("style"); 2879 // Use the correct CSS MIME type so browsers apply the styles 2880 inlineStyle.setAttribute("type", "text/css"); 2881 inlineStyle.setAttribute('data-srr-lazy-style', '1'); 2882 inlineStyle.innerHTML = style.innerHTML; 2883 doc.body.appendChild(inlineStyle); 2884 }); 2885 // Re-run css links 2886 dom.querySelectorAll("link[rel='stylesheet']")?.forEach((linkCss) => { 2887 // Skip it only if a copy is already loaded that will still be 2888 // here afterwards. A stylesheet enqueued late enough to be 2889 // printed inside <body> â which plenty are â sits in the very 2890 // markup the content swap below is about to replace, so 2891 // counting it as "already loaded" drops the stylesheet 2892 // altogether: the outgoing copy is destroyed and the incoming 2893 // page keeps its own in <head>, where nothing put it back. 2894 const existing = doc.querySelector(`link[href='${linkCss.href}']`); 2895 if (existing && !$this.contentSite.contains(existing)) return; 2896 const newLinkCss = doc.createElement("link"); 2897 newLinkCss.setAttribute("rel", "stylesheet"); 2898 newLinkCss.setAttribute("type", "text/css"); 2899 newLinkCss.setAttribute("href", linkCss.href); 2900 doc.head.appendChild(newLinkCss); 2901 }); 2902 2903 // Put the incoming page's body classes on before the content 2904 // lands, so it is painted once, already styled correctly. 2905 $this.updateBodyClasses(dom); 2906 2907 // Push new HTML content BEFORE executing any scripts so that 2908 // third-party widgets and builder code blocks have their 2909 // target DOM elements available when their scripts run. 2910 $this.contentSite.innerHTML = dom.body.innerHTML; 2911 2912 // Generic support for builder plugins (like srr-builder) that 2913 // inject additional <script> tags into the content AFTER the 2914 // initial HTML has been parsed. We attach a MutationObserver 2915 // once to the content container and execute any newly-added 2916 // script elements so that arbitrary code blocks work when 2917 // pages are loaded via the StreamPlayer. 2918 if (!$this._contentScriptObserverInitialized && $this.contentSite) { 2919 $this._contentScriptObserverInitialized = true; 2920 2921 const normalizeScriptSrc = (src) => { 2922 try { 2923 const u = new URL(src, win.location.href); 2924 u.searchParams.delete('cache'); 2925 return u.toString(); 2926 } catch (e) { 2927 return (src || '').replace(/([?&])cache=streamplayer(&|$)/, '$1').replace(/[?&]$/, ''); 2928 } 2929 }; 2930 2931 const executeDynamicScript = (scriptEl) => { 2932 if (!scriptEl || scriptEl.__srrExecuted) return; 2933 // Prevent infinite loops: do not re-process scripts injected by StreamPlayer. 2934 if (scriptEl.hasAttribute && (scriptEl.hasAttribute('data-srr-dynamic-script') || scriptEl.hasAttribute('data-srr-lazy-inline'))) { 2935 return; 2936 } 2937 // Mark to avoid double execution 2938 scriptEl.__srrExecuted = true; 2939 2940 const type = (scriptEl.type || '').toLowerCase(); 2941 const isJsType = !type || type === 'text/javascript' || type === 'application/javascript' || type === 'module';
2942 if (!isJsType) return; 2943 2944 if (!scriptEl.src) { 2945 // Inline script 2946 const newScript = doc.createElement('script'); 2947 newScript.type = scriptEl.type || 'text/javascript'; 2948 newScript.text = scriptEl.textContent || ''; 2949 newScript.setAttribute('data-srr-dynamic-script', '1'); 2950 doc.body.appendChild(newScript); 2951 // Remove immediately after execution to prevent tag accumulation 2952 setTimeout(() => newScript.remove(), 0); 2953 } else { 2954 // External script 2955 const isSecondStreetEmbed = scriptEl.hasAttribute('data-ss-embed'); 2956 if (isSecondStreetEmbed) { 2957 const newScript = doc.createElement('script'); 2958 newScript.type = scriptEl.type || 'text/javascript'; 2959 Array.from(scriptEl.attributes || []).forEach((attr) => { 2960 if (!attr || !attr.name) return; 2961 if (attr.name === 'src') return; 2962 newScript.setAttribute(attr.name, attr.value); 2963 }); 2964 const cacheBust = `srr_ss=${Date.now()}`; 2965 newScript.src = scriptEl.src + (scriptEl.src.includes('?') ? '&' : '?') + cacheBust; 2966 newScript.setAttribute('data-srr-dynamic-script', '1'); 2967 newScript.__srrExecuted = true; 2968 // Insert in-place so the embed hydrates where the shortcode is. 2969 if (scriptEl.parentNode) { 2970 scriptEl.parentNode.insertBefore(newScript, scriptEl); 2971 scriptEl.remove(); 2972 } else { 2973 doc.body.appendChild(newScript); 2974 } 2975 return; 2976 } 2977 const normalizedSrc = normalizeScriptSrc(scriptEl.src); 2978 const byId = scriptEl.id ? doc.getElementById(scriptEl.id) : null; 2979 const existingById = (byId && byId !== scriptEl) ? byId : null; 2980 const existingBySrc = normalizedSrc 2981 ? Array.from(doc.querySelectorAll('script[src]')).some((s) => s !== scriptEl && normalizeScriptSrc(s.src) === normalizedSrc) 2982 : false; 2983 if (existingById || existingBySrc) return; 2984 const newScript = doc.createElement('script'); 2985 newScript.type = scriptEl.type || 'text/javascript'; 2986 newScript.src = scriptEl.src; 2987 if (scriptEl.id) newScript.id = scriptEl.id; 2988 newScript.setAttribute('data-srr-dynamic-script', '1'); 2989 doc.body.appendChild(newScript); 2990 } 2991 }; 2992 2993 const observer = new MutationObserver((mutations) => { 2994 mutations.forEach((mutation) => { 2995 mutation.addedNodes.forEach((node) => { 2996 if (node.nodeType !== 1) return; // ELEMENT_NODE only 2997 2998 if (node.tagName === 'SCRIPT') { 2999 executeDynamicScript(node); 3000 } 3001 3002 node.querySelectorAll && node.querySelectorAll('script').forEach((childScript) => { 3003 executeDynamicScript(childScript); 3004 }); 3005 }); 3006 }); 3007 }); 3008 3009 observer.observe($this.contentSite, { childList: true, subtree: true }); 3010 } 3011 3012 // Dispatch the custom event to notify that new content has been loaded (Added 9/17/24) 3013 const pageContentLoadedEvent = new Event('pageContentLoaded'); 3014 window.dispatchEvent(pageContentLoadedEvent); 3015 3016 // Re-run scripts *after* the content has been injected so 3017 // that any script-based embeds can find their DOM nodes. 3018 // IMPORTANT: operate on the actual scripts inside the 3019 // injected content container so we execute exactly what is 3020 // present in the live DOM (including builder-generated 3021 // code blocks). 3022 $this.contentSite.querySelectorAll("script")?.forEach(async (script) => { 3023 const isDelayed = script.classList.contains('delay'); 3024 3025 // Only re-execute actual JavaScript. Skip JSON-LD and other non-JS script types. 3026 const type = (script.type || '').toLowerCase(); 3027 const isJsType = !type || type === 'text/javascript' || type === 'application/javascript' || type === 'module';
3028 if (!isJsType) return; 3029 3030 if (isDelayed) { 3031 delayedScripts.push(script); 3032 return; 3033 } 3034 if (!script.src) { 3035 if (script.innerHTML.includes(`_wp_unfiltered_html_comment_disabled`)) return; 3036 const inlineScript = doc.createElement("script"); 3037 inlineScript.setAttribute("type", script.type || "text/javascript"); 3038 inlineScript.setAttribute('data-srr-lazy-inline', '1'); 3039 3040 // Clean script content to prevent endLine errors 3041 let cleanScriptContent = script.innerHTML; 3042 if (typeof cleanScriptContent === 'string') { 3043 cleanScriptContent = cleanScriptContent.replace(/endLine/g, ''); 3044 // Remove any other potential malformed references 3045 cleanScriptContent = cleanScriptContent.replace(/undefined_variable_\w+/g, ''); 3046 } 3047 3048 // Log a short preview of the inline script being re-run 3049 try { 3050 const preview = (cleanScriptContent || '').trim().slice(0, 120).replace(/\s+/g, ' '); 3051 //console.log('[StreamPlayer][lazyLoad] Re-running inline script:', preview); 3052 } catch (e) { 3053 //console.log('[StreamPlayer][lazyLoad] Re-running inline script (preview unavailable)'); 3054 } 3055 3056 inlineScript.innerHTML = cleanScriptContent; 3057 inlineScript.classList = script.classList; 3058 doc.body.appendChild(inlineScript); 3059 // Remove immediately after execution to prevent tag accumulation 3060 setTimeout(() => inlineScript.remove(), 0); 3061 } else { 3062 const normalizeScriptSrc = (src) => { 3063 try { 3064 const u = new URL(src, win.location.href); 3065 u.searchParams.delete('cache'); 3066 return u.toString(); 3067 } catch (e) { 3068 return (src || '').replace(/([?&])cache=streamplayer(&|$)/, '$1').replace(/[?&]$/, ''); 3069 } 3070 }; 3071 3072 if (script.hasAttribute('data-ss-embed')) { 3073 const Script = doc.createElement("script"); 3074 Script.setAttribute("type", script.type || "text/javascript"); 3075 Array.from(script.attributes || []).forEach((attr) => { 3076 if (!attr || !attr.name) return; 3077 if (attr.name === 'src') return; 3078 Script.setAttribute(attr.name, attr.value); 3079 }); 3080 const cacheBust = `srr_ss=${Date.now()}`; 3081 Script.src = script.src + (script.src.includes('?') ? '&' : '?') + cacheBust; 3082 Script.setAttribute('data-srr-dynamic-script', '1'); 3083 Script.__srrExecuted = true; 3084 // Insert in-place so the embed hydrates where the shortcode is. 3085 const parent = script.parentNode; 3086 if (parent) { 3087 parent.insertBefore(Script, script); 3088 script.remove(); 3089 } else { 3090 script.remove(); 3091 doc.body.appendChild(Script); 3092 } 3093 return; 3094 } 3095 3096 // If this external script is already present (by id or src), don't append another copy. 3097 // Exclude the current element from the lookup so innerHTML-injected 3098 // scripts don't match themselves and get incorrectly skipped. 3099 const normalizedSrc = normalizeScriptSrc(script.src); 3100 const byId = script.id ? doc.getElementById(script.id) : null; 3101 const existingById = (byId && byId !== script) ? byId : null; 3102 const existingBySrc = normalizedSrc 3103 ? Array.from(doc.querySelectorAll('script[src]')).some((s) => s !== script && normalizeScriptSrc(s.src) === normalizedSrc) 3104 : false;
3105 if (existingById || existingBySrc) { 3106 script.remove(); 3107 return; 3108 } 3109 3110 const Script = doc.createElement("script"); 3111 // Copy all attributes (except src which gets cache-busted below) 3112 Array.from(script.attributes || []).forEach((attr) => { 3113 if (!attr || !attr.name || attr.name === 'src') return; 3114 Script.setAttribute(attr.name, attr.value); 3115 }); 3116 Script.src = script.src + (script.src.includes('?') ? '&' : '?') + 'cache=streamplayer'; 3117 Script.setAttribute('data-srr-dynamic-script', '1'); 3118 Script.__srrExecuted = true; 3119 // Insert in-place so widget embeds hydrate next to their 3120 // target DOM nodes; fall back to head/body for core scripts. 3121 const parent = script.parentNode; 3122 if (parent && script.id !== 'jquery-core-js') { 3123 parent.insertBefore(Script, script); 3124 script.remove(); 3125 } else { 3126 script.remove(); 3127 if (script.id === 'jquery-core-js') { 3128 doc.head.appendChild(Script); 3129 } else { 3130 doc.body.appendChild(Script); 3131 } 3132 } 3133 } 3134 }); 3135 3136 // Re-run delayed scripts 3137 delayedScripts.forEach(async (script) => { 3138 // Only re-execute actual JavaScript. Skip JSON-LD and other non-JS script types. 3139 const type = (script.type || '').toLowerCase(); 3140 const isJsType = !type || type === 'text/javascript' || type === 'application/javascript' || type === 'module';
3141 if (!isJsType) return; 3142 3143 // wait 450ms 3144 setTimeout(() => { 3145 const inlineScript = doc.createElement("script"); 3146 inlineScript.setAttribute("type", script.type || "text/javascript"); 3147 inlineScript.setAttribute('data-srr-lazy-inline', '1'); 3148 3149 // Clean script content to prevent endLine errors 3150 let cleanScriptContent = script.innerHTML; 3151 if (typeof cleanScriptContent === 'string') { 3152 cleanScriptContent = cleanScriptContent.replace(/endLine/g, ''); 3153 // Remove any other potential malformed references 3154 cleanScriptContent = cleanScriptContent.replace(/undefined_variable_\w+/g, ''); 3155 } 3156 3157 inlineScript.innerHTML = cleanScriptContent; 3158 inlineScript.classList = script.classList; 3159 doc.body.appendChild(inlineScript); 3160 // Remove immediately after execution to prevent tag accumulation 3161 setTimeout(() => inlineScript.remove(), 0); 3162 const domContentLoadedEvent = new Event('DOMContentLoaded'); 3163 window.dispatchEvent(domContentLoadedEvent); 3164 }, 550); 3165 }); 3166 3167 // Always dispatch a DOMContentLoaded event after scripts have 3168 // been (re)executed so that third-party widgets that listen 3169 // for DOM ready can initialize correctly, even when there 3170 // are no delayed scripts on the page. 3171 setTimeout(() => { 3172 const domContentLoadedEvent = new Event('DOMContentLoaded'); 3173 window.dispatchEvent(domContentLoadedEvent); 3174 }, 600); 3175 3176 // Update title 3177 doc.title = dom.querySelector("title").innerText; 3178 3179 // make it so the browser back button / history works 3180 if (addState) history.pushState({}, dom.querySelector("title").innerText, href); 3181 win.scrollTo(0, 0); 3182 if (dom.querySelector('.streamplayer--disabled')) { 3183 $this.player.playerContainer.classList.toggle('streamplayer--loaded', false); 3184 $this.streamAudio.pause(); 3185 // Reset paddings 3186 this.contentSite.style = { 3187 paddingTop: 0, 3188 paddingBottom: 0 3189 }; 3190 } else { 3191 $this.player.playerContainer.classList.toggle('streamplayer--loaded', true); 3192 // Reset paddings 3193 this.initSettings(); 3194 } 3195 // Re-mount listeners 3196 $this.linkListeners(); 3197 $this.otherAudioListeners(); 3198 // Rebuilds 3199 $this.listBuilder(); 3200 $this.adsBuilder(); 3201 // Choose first station or from the query 3202 if (!$this.currentAudioMetadata) $this.initStation(); 3203 }); // end fetch 3204 } 3205 3206 // Strip common parenthesized/bracketed qualifiers from titles for cleaner iTunes searches 3207 cleanForSearch(str) { 3208 if (!str) return ''; 3209 return str 3210 .replace(/[\(\[]\s*(?:feat\.?|ft\.?|featuring)\s+[^\)\]]*[\)\]]/gi, '') 3211 .replace(/[\(\[]\s*(?:radio\s*(?:edit|version|mix)|official\s*(?:video|audio|music\s*video)?|lyric\s*video|lyrics?|live\b[^\)\]]*|acoustic\b[^\)\]]*|(?:.*?\s)?remix|(?:.*?\s)?mix|remaster(?:ed)?\b[^\)\]]*|explicit|clean|single\s*version|album\s*version|deluxe\b[^\)\]]*|bonus\s*track|mono|stereo|video|audio|cover|unplugged|instrumental|extended\b[^\)\]]*|original\b[^\)\]]*)\s*[\)\]]/gi, '') 3212 .replace(/\s+(?:feat\.?|ft\.?|featuring)\s+.*$/i, '') 3213 .replace(/\s{2,}/g, ' ') 3214 .trim(); 3215 } 3216 3217 // Find album artwork for a song 3218 async findAlbumArtwork(songTitle, artist, fallbackImage) { 3219 // If we don't have at least one of title or artist, use fallback image 3220 if (!songTitle && !artist) { 3221 this.setInfoBar( 3222 'Live Stream', 3223 fallbackImage, 3224 '', 3225 false, 3226 null 3227 ); 3228 return; 3229 } 3230
3231 const resolvedTitle = songTitle || 'Live Stream'; 3232 const resolvedArtist = artist || ''; 3233 3234 try { 3235 // Step 1: Try to find artwork from artist custom post type (only if we have an artist name) 3236 let artistImage = false; 3237 if (resolvedArtist) { 3238 artistImage = await this.fetchImageFromArtistPost(resolvedArtist); 3239 } 3240 3241 if (artistImage) { 3242 // Found image in artist post type 3243 this.setInfoBar( 3244 resolvedTitle, 3245 fallbackImage, 3246 resolvedArtist, 3247 false, 3248 artistImage 3249 ); 3250 return; 3251 } 3252 3253 // Step 2: If no artist post image, try iTunes API 3254 const searchParts = []; 3255 if (resolvedArtist) { 3256 searchParts.push(this.cleanForSearch(resolvedArtist)); 3257 } 3258 if (songTitle) { 3259 searchParts.push(this.cleanForSearch(songTitle)); 3260 } 3261 3262 const rawSearchTerm = searchParts.join(' ').trim(); 3263 3264 if (rawSearchTerm) { 3265 const searchTerm = encodeURIComponent(rawSearchTerm); 3266 3267 // Make the iTunes API call using documented parameters 3268 const itunesResponse = await fetch( 3269 `https://itunes.apple.com/search?term=${searchTerm}&media=music&entity=song&limit=1` 3270 ); 3271 3272 if (itunesResponse.ok) { 3273 const itunesData = await itunesResponse.json(); 3274 3275 if (itunesData.resultCount > 0 && itunesData.results[0].artworkUrl100) { 3276 // Get the artwork URL and convert to higher resolution if possible 3277 let artworkUrl = itunesData.results[0].artworkUrl100; 3278 3279 // Try to get higher resolution by replacing '100x100' with '600x600' 3280 artworkUrl = artworkUrl.replace('100x100', '600x600'); 3281 3282 // Update player with iTunes artwork 3283 this.setInfoBar( 3284 resolvedTitle, 3285 fallbackImage, 3286 resolvedArtist, 3287 false, 3288 artworkUrl 3289 ); 3290 return; 3291 } 3292 } 3293 } 3294 3295 // If we get here, no artwork was found, use fallback 3296 this.setInfoBar( 3297 resolvedTitle, 3298 fallbackImage, 3299 resolvedArtist, 3300 false, 3301 null 3302 ); 3303 3304 } catch (error) { 3305 console.error('Error finding album artwork:', error); 3306 3307 // Use fallback on error 3308 this.setInfoBar( 3309 resolvedTitle, 3310 fallbackImage, 3311 resolvedArtist, 3312 false, 3313 null 3314 ); 3315 } 3316 } 3317 3318 autoChoose(URLParams, checkState = false) { 3319 // Never downgrade userInteracted once it has become true 3320 if (checkState === true) { 3321 this.userInteracted = true; 3322 } 3323 3324 if (!this.stations || !Array.isArray(this.stations) || this.stations.length === 0) { 3325 return false; 3326 } 3327 3328 if (!URLParams || typeof URLParams.has !== 'function') { 3329 return false; 3330 } 3331 3332 // Check if the URL has streamplayer param 3333 if (URLParams.has('streamplayer')) { 3334 const stationParam = URLParams.get('streamplayer'); 3335 const wantedId = parseInt(stationParam, 10); 3336 3337 if (Number.isNaN(wantedId)) { 3338 return false; 3339 } 3340 3341 const foundId = this.stations.findIndex(station => station.id === wantedId); 3342 3343 if (foundId !== -1) { 3344 this.chooseStation(foundId, true, true); 3345 return true; 3346 } 3347 } 3348 3349 return false; 3350 } 3351 3352 // ----- HLS support ------------------------------------------------------ 3353 // Resolves an effective transport for the given URL: 3354 // 'hls' - HLS (.m3u8) playlist. Use hls.js, or native if Safari. 3355 // 'progressive' - Anything else. Use plain <audio src=...>. 3356 // The result is governed by: 3357 // 1. The station's explicit `station_stream_type` (auto|progressive|hls). 3358 // 2. URL-suffix sniff (.m3u8 â HLS) when type is 'auto'. 3359 // 3. The site-wide `srr_enable_hls` feature flag â when disabled, HLS 3360 // detection is short-circuited to 'progressive' so the player falls 3361 // back to the pre-1.7.0 behaviour exactly. This is the kill-switch 3362 // that protects 100+ affiliate sites from any regression. 3363 _resolveStreamType(url) { 3364 const hlsEnabled = !!(window.SRRStreamplayer && window.SRRStreamplayer.hlsEnabled); 3365 if (!hlsEnabled) return 'progressive'; 3366 3367 const explicit = (this.streamType || 'auto').toLowerCase(); 3368 if (explicit === 'hls') return 'hls'; 3369 if (explicit === 'progressive') return 'progressive'; 3370 3371 // Auto-detect: sniff the path (ignore querystring + case) for .m3u8. 3372 if (typeof url !== 'string' || url === '') return 'progressive'; 3373 try { 3374 const parsed = new URL(url, window.location.href); 3375 if (/\.m3u8$/i.test(parsed.pathname)) return 'hls'; 3376 } catch (e) { 3377 // Malformed URL â let the legacy progressive path handle it. 3378 } 3379 return 'progressive'; 3380 } 3381 3382 // Lazy-loads hls.js the first time it's actually needed and memoises the 3383 // promise so concurrent play attempts share one fetch. Resolves to the 3384 // global `Hls` constructor, or rejects if the script fails to load. 3385 _ensureHlsLoaded() { 3386 if (typeof window.Hls !== 'undefined') { 3387 return Promise.resolve(window.Hls); 3388 } 3389 if (this.hlsScriptPromise) { 3390 return this.hlsScriptPromise; 3391 } 3392 const url = window.SRRStreamplayer && window.SRRStreamplayer.hlsScriptUrl; 3393 if (!url) { 3394 return Promise.reject(new Error('hls.js script URL not configured')); 3395 } 3396 this.hlsScriptPromise = new Promise((resolve, reject) => { 3397 const script = doc.createElement('script'); 3398 script.src = url; 3399 script.async = true; 3400 script.onload = () => { 3401 if (typeof window.Hls !== 'undefined') { 3402 resolve(window.Hls); 3403 } else { 3404 reject(new Error('hls.js loaded but window.Hls is undefined')); 3405 } 3406 }; 3407 script.onerror = () => { 3408 // Allow a retry on the next play attempt. 3409 this.hlsScriptPromise = null; 3410 reject(new Error('Failed to load hls.js')); 3411 }; 3412 doc.head.appendChild(script); 3413 }); 3414 return this.hlsScriptPromise; 3415 } 3416 3417 // Tears down any prior hls.js instance to prevent MSE buffer / event leaks 3418 // when the listener switches stations. Safe to call when no instance 3419 // exists. Errors during destroy() are swallowed â they cannot block the 3420 // next play attempt. 3421 _teardownHls() { 3422 if (this.hlsInstance) { 3423 try { this.hlsInstance.destroy(); } catch (e) { /* noop */ } 3424 this.hlsInstance = null; 3425 } 3426 } 3427 3428 // Single chokepoint for assigning a playable source to streamAudio. 3429 // Replaces the bare `this.streamAudio.src = url` for station streams. 3430 // 3431 // Failure modes are explicit: any error during HLS attach falls back to 3432 // assigning the URL directly to streamAudio.src, matching pre-1.7.0
3433 // behaviour. The player's existing error-handling backoff (controlled by 3434 // streamFeedConsecutiveErrors / streamFeedBackoffMs) then takes over 3435 // exactly as it did before this change. 3436 async _setStreamSource(url) { 3437 this.sourceIsStation = !!url; 3438 this._teardownHls(); 3439 3440 const type = this._resolveStreamType(url); 3441 3442 if (type !== 'hls') { 3443 // Progressive (Icecast / Shoutcast MP3 / AAC) â legacy path. 3444 this.streamAudio.src = url || ""; 3445 return; 3446 } 3447 3448 // HLS path. If the browser can play HLS natively (Safari desktop, 3449 // iOS WebKit, MacOS Safari), just assign the URL â the OS-level 3450 // player handles segment fetching and ABR for us, and hls.js is 3451 // never loaded. This keeps Safari listeners on a faster code path. 3452 const nativeHls = !!this.streamAudio.canPlayType 3453 && this.streamAudio.canPlayType('application/vnd.apple.mpegurl') !== ''; 3454 if (nativeHls) { 3455 this.streamAudio.src = url || ""; 3456 return; 3457 } 3458 3459 // Non-Safari: lazy-load hls.js and attach it to streamAudio. If the 3460 // script fails to load (CDN blocked, network blip, etc.), fall back 3461 // to the legacy progressive path so the player at least surfaces a 3462 // recognisable error instead of going silent. 3463 try { 3464 const Hls = await this._ensureHlsLoaded(); 3465 if (!Hls || !Hls.isSupported || !Hls.isSupported()) { 3466 // MSE unavailable â last-resort fallback. Most modern browsers 3467 // pass this check; the only realistic failure is very old or 3468 // restricted environments. 3469 this.streamAudio.src = url || ""; 3470 return; 3471 } 3472 this.hlsInstance = new Hls({ 3473 enableWorker: true, 3474 // Conservative defaults: live radio is short-burst, not VOD. 3475 // We don't need aggressive prefetching or extra-low latency. 3476 lowLatencyMode: false, 3477 });
3478 // Wire fatal errors into the player's existing error backoff so 3479 // geo-blocks, 403s, and 404s surface the same UI state as a 3480 // progressive stream failure. 3481 this.hlsInstance.on(Hls.Events.ERROR, (event, data) => { 3482 if (!data || !data.fatal) return; 3483 this.streamFeedConsecutiveErrors = (this.streamFeedConsecutiveErrors || 0) + 1; 3484 this._teardownHls(); 3485 // Re-raise as a media 'error' event so the existing onerror 3486 // listeners on streamAudio pick this up unchanged. 3487 try { 3488 this.streamAudio.dispatchEvent(new Event('error')); 3489 } catch (e) { /* noop */ } 3490 }); 3491 this.hlsInstance.loadSource(url); 3492 this.hlsInstance.attachMedia(this.streamAudio); 3493 } catch (err) { 3494 // hls.js failed to load (network, CSP, etc.). Fall back to the 3495 // legacy path so we degrade gracefully rather than going silent. 3496 this._teardownHls(); 3497 this.streamAudio.src = url || ""; 3498 } 3499 } 3500} 3501 3502// Helper method to notify mini-players of state changes 3503StreamPlayer.prototype.notifyMiniPlayers = function (detail) { 3504 // Dispatch an event that mini-players can listen to 3505 document.dispatchEvent(new CustomEvent('streamPlayerStateChanged', { 3506 detail: detail 3507 })); 3508}; 3509 3510// Create and initialize the StreamPlayer instance 3511window.StreamPlayerInstance = new StreamPlayer(); 3512window.StreamPlayerInstance._initialize();
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.