PageSourceSearch

https://www.1011thepulse.com/wp-content/plugins/srr-streamplayer/includes/js/streamplayer.js

js 1011thepulse.com collected 2026-10-06 04:42:10 UTC 162,963 bytes, 3,645 lines download raw bytes

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(/&amp;/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.