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