PageSourceSearch

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

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

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.