PageSourceSearch

https://nativafmbauru.com.br/assets/js/player.js?v=1790258102

js nativafmbauru.com.br collected 2026-09-24 13:55:15 UTC 43,343 bytes, 1,172 lines download raw bytes

1/**
2 * Audio Player Controller with Auto-Reconnection
3 * 
4 * Features:
5 * - State machine for player states
6 * - Network connectivity monitoring
7 * - Smart reconnection with exponential backoff
8 * - Buffer management before playback
9 * - Automatic recovery on connection restore
10 */
11
12// Player States
13const PLAYER_STATES = {
14    IDLE: 'idle',
15    CONNECTING: 'connecting',
16    BUFFERING: 'buffering',
17    TRANSMITTING: 'transmitting',
18    RECONNECTING: 'reconnecting',
19    NO_CONNECTION: 'no_connection',
20    ERROR: 'error'
21};
22
23// Error Types
24const ERROR_TYPES = {
25    NETWORK_OFFLINE: 'network_offline',
26    STREAM_UNAVAILABLE: 'stream_unavailable',
27    STREAM_TIMEOUT: 'stream_timeout',
28    STREAM_ERROR: 'stream_error',
29    UNKNOWN: 'unknown'
30};
31
32// Status Messages
33const STATUS_MESSAGES = {
34    [PLAYER_STATES.IDLE]: 'Pronto para transmitir',
35    [PLAYER_STATES.CONNECTING]: 'Conectando à transmissão...',
36    [PLAYER_STATES.BUFFERING]: 'Preparando áudio...',
37    [PLAYER_STATES.TRANSMITTING]: 'Transmitindo',
38    [PLAYER_STATES.RECONNECTING]: 'Reconectando...',
39    [PLAYER_STATES.NO_CONNECTION]: 'Sem conexão com a internet',
40    [PLAYER_STATES.ERROR]: 'Erro na transmissão'
41};
42
43/**
44 * Connection Monitor
45 * Monitors network connectivity using navigator.onLine and events
46 */
47class ConnectionMonitor {
48    constructor(callback) {
49        this.isOnline = navigator.onLine;
50        this.callback = callback;
51        this.init();
52    }
53
54    init() {
55        window.addEventListener('online', () => {
56            console.log('[ConnectionMonitor] Network online');
57            this.isOnline = true;
58            this.callback('online');
59        });
60
61        window.addEventListener('offline', () => {
62            console.log('[ConnectionMonitor] Network offline');
63            this.isOnline = false;
64            this.callback('offline');
65        });
66    }
67
68    checkConnection() {
69        return this.isOnline;
70    }
71}
72
73/**
74 * Reconnection Manager
75 * Handles reconnection attempts with exponential backoff
76 */
77class ReconnectionManager {
78    constructor() {
79        this.attempts = 0;
80        this.maxAttempts = 10;
81        this.delays = [2, 5, 10, 15, 20, 30]; // seconds
82        this.reconnectTimer = null;
83    }
84
85    getDelay() {
86        const index = Math.min(this.attempts, this.delays.length - 1);
87        return this.delays[index] * 1000; // convert to ms
88    }
89
90    scheduleReconnect(callback) {
91        this.clearReconnect();
92
93        if (this.attempts >= this.maxAttempts) {
94            console.log('[ReconnectionManager] Max attempts reached');
95            return false;
96        }
97
98        const delay = this.getDelay();
99        this.attempts++;
100
101        console.log(`[ReconnectionManager] Scheduling reconnect attempt ${this.attempts} in ${delay}ms`);
102
103        this.reconnectTimer = setTimeout(callback, delay);
104        return delay;
105    }
106
107    reset() {
108        console.log('[ReconnectionManager] Reset');
109        this.attempts = 0;
110        this.clearReconnect();
111    }
112
113    clearReconnect() {
114        if (this.reconnectTimer) {
115            clearTimeout(this.reconnectTimer);
116            this.reconnectTimer = null;
117        }
118    }
119
120    getAttempts() {
121        return this.attempts;
122    }
123}
124
125/**
126 * Buffer Manager
127 * Monitors audio buffer and ensures minimum buffer before playback
128 */
129class BufferManager {
130    constructor(audioElement) {
131        this.audio = audioElement;
132        this.minBuffer = 0.1; // Segundos (reduzido de 3s para iniciar imediatamente)
133    }
134
135    hasMinimumBuffer() {
136        if (this.audio.buffered.length === 0) return true; // Tenta tocar mesmo sem relatar buffer
137        const buffered = this.audio.buffered.end(0) - this.audio.currentTime;
138        return buffered >= this.minBuffer;
139    }
140
141    getBufferedSeconds() {
142        if (this.audio.buffered.length === 0) return 0;
143        return this.audio.buffered.end(0) - this.audio.currentTime;
144    }
145
146    isReady() {
147        return this.audio.readyState >= 3; // HAVE_FUTURE_DATA
148    }
149
150    canPlay() {
151        return this.isReady() || this.hasMinimumBuffer(); // Se tiver dados futuros ou buffer mínimo, pode tocar
152    }
153}
154
155/**
156 * Radio Player with Auto-Reconnection
157 */
158class RadioPlayer {
159    constructor() {
160        this.audio = new Audio();
161        this.state = PLAYER_STATES.IDLE;
162        this.volume = 0.9;
163        this.metadataInterval = null;
164        this.currentSong = '';
165        // Use a imagem padrão definida nas configurações
166        this.defaultArtwork = typeof PLAYER_DEFAULT_IMAGE !== 'undefined' ? PLAYER_DEFAULT_IMAGE : '';
167        this.lastDataTime = Date.now();
168        this.stallCheckInterval = null;
169        this.userIntendedPlay = false; // Track if user wants to play
170        // URL do streaming — usa constante global injetada pelo PHP como valor padrão
171        this.streamUrl = typeof STREAM_URL !== 'undefined' ? STREAM_URL : '';
172
173        // Multi-praça: id da praça ativa (para metadados por praça) + guardas
174        // contra respostas de metadados fora de ordem ao trocar de praça.
175        // Inicializa com a praça resolvida server-side (metadados corretos já no load).
176        this.activeCityId = (typeof ACTIVE_CITY_ID !== 'undefined') ? ACTIVE_CITY_ID : null;
177        this.metadataGen = 0;          // incrementa a cada troca de praça
178        this.metadataAbort = null;     // AbortController da busca de metadados em curso
179
180        // Helper components
181        this.connectionMonitor = new ConnectionMonitor((event) => this.onNetworkChange(event));
182        this.reconnectionManager = new ReconnectionManager();
183        this.bufferManager = new BufferManager(this.audio);
184
185        // DOM Elements
186        this.playBtn = document.getElementById('playBtn');
187        this.playIcon = document.getElementById('playIcon');
188        this.volumeBtn = document.getElementById('volumeBtn');
189        this.volumeIcon = document.getElementById('volumeIcon');
190        this.volumeSlider = document.getElementById('volumeSlider');
191        this.playerTitle = document.getElementById('playerTitle');
192        this.playerArtist = document.getElementById('playerArtist');
193        this.playerArtwork = document.getElementById('playerArtwork');
194        this.visualizer = document.getElementById('visualizer');
195        this.statusElement = document.getElementById('playerStatus');
196        this.statusBadge = document.querySelector('.player-status-badge');
197        this.liveText = document.querySelector('.neon-player__live-text, .live-text');
198
199        this.init();
200    }
201
202    init() {
203        // Configure audio
204        this.audio.preload = 'none';
205        this.audio.volume = this.volume;
206
207        // Bind events
208        this.bindEvents();
209
210        // Load saved volume
211        const savedVolume = localStorage.getItem('playerVolume');
212        if (savedVolume) {
213            this.volume = parseFloat(savedVolume);
214            this.audio.volume = this.volume;
215            this.volumeSlider.value = this.volume * 100;
216            this.updateVolumeIcon();
217        }
218
219        // Load default artwork
220        this.loadDefaultArtwork();
221
222        // Start metadata fetching
223        this.fetchMetadata();
224        this.metadataInterval = setInterval(() => this.fetchMetadata(), 10000);
225
226        // Start stall checker
227        this.startStallChecker();
228
229        // Initialize Media Session API
230        this.setupMediaSession();
231
232        // Update initial status
233        this.updateStatus();
234    }
235
236    setupMediaSession() {
237        if ('mediaSession' in navigator) {
238            navigator.mediaSession.setActionHandler('play', () => {
239                this.startPlayback();
240                this.updatePlayIcon();
241            });
242            navigator.mediaSession.setActionHandler('pause', () => {
243                this.stopPlayback();
243 // Or audio.pause() depending on desired behavior
244                this.updatePlayIcon();
245            });
246            navigator.mediaSession.setActionHandler('stop', () => {
247                this.stopPlayback();
248                this.updatePlayIcon();
249            });
250        }
251    }
252
253    updateMediaSession(data) {
254        if (!('mediaSession' in navigator)) return;
255
256        const title = data.song || data.title || SITE_NAME;
257        const artist = data.artist || (data.song ? SITE_NAME : 'Ao Vivo');
258        const artworkUrl = data.artwork || this.defaultArtwork;
259
260        const artwork = artworkUrl ? [
261            { src: artworkUrl, sizes: '96x96', type: 'image/png' },
262            { src: artworkUrl, sizes: '128x128', type: 'image/png' },
263            { src: artworkUrl, sizes: '192x192', type: 'image/png' },
264            { src: artworkUrl, sizes: '256x256', type: 'image/png' },
265            { src: artworkUrl, sizes: '384x384', type: 'image/png' },
266            { src: artworkUrl, sizes: '512x512', type: 'image/png' },
267        ] : [];
268
269        navigator.mediaSession.metadata = new MediaMetadata({
270            title: title,
271            artist: artist,
272            album: SITE_NAME,
273            artwork: artwork
274        });
275    }
276
277    async loadDefaultArtwork() {
278        // Só busca da API se a imagem padrão não foi definida via constante global
279        if (this.defaultArtwork) {
280            console.log('[Player] Default artwork already set:', this.defaultArtwork);
281            return;
282        }
283
284        try {
285            const response = await fetch(SITE_URL + '/api/streaming.php');
286            const data = await response.json();
287            if (data.success && data.default_image) {
288                this.defaultArtwork = data.default_image;
289                console.log('[Player] Default artwork loaded from API:', this.defaultArtwork);
290            }
291        } catch (error) {
292            console.log('[Player] Could not load default artwork from API');
293        }
294    }
295
296    bindEvents() {
297        // Play/Pause
298        this.playBtn.addEventListener('click', () => this.togglePlay());
299
300        // Volume
301        this.volumeBtn.addEventListener('click', () => this.toggleMute());
302        this.volumeSlider.addEventListener('input', (e) => this.setVolume(e.target.value / 100));
303
304        // Audio events
305        this.audio.addEventListener('loadstart', () => this.onLoadStart());
306        this.audio.addEventListener('playing', () => this.onPlaying());
307        this.audio.addEventListener('pause', () => this.onPause());
308        this.audio.addEventListener('ended', () => this.onEnded());
309        this.audio.addEventListener('error', (e) => this.onError(e));
310        this.audio.addEventListener('waiting', () => this.onWaiting());
311        this.audio.addEventListener('canplay', () => this.onCanPlay());
312        this.audio.addEventListener('stalled', () => this.onStalled());
313        this.audio.addEventListener('progress', () => this.onProgress());
314
315        // Keyboard shortcuts
316        document.addEventListener('keydown', (e) => {
317            if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
318
319            if (e.code === 'Space') {
320                e.preventDefault();
321                this.togglePlay();
322            } else if (e.code === 'KeyM') {
323                this.toggleMute();
324            }
325        });
326    }
327
328    // State Management
329    setState(newState) {
330        if (this.state === newState) return;
331
332        console.log(`[Player] State: ${this.state} → ${newState}`);
333        this.state = newState;
334        this.updateStatus();
335    }
336
337    updateStatus() {
338        const customMessage = STATUS_MESSAGES[this.state] || '● AO VIVO';
339        
340        let displayMessage = '● AO VIVO';
341        if (this.state === PLAYER_STATES.ERROR) displayMessage = '✕ ERRO DE CONEXÃO';
342        if (this.state === PLAYER_STATES.NO_CONNECTION) displayMessage = '✕ SEM CONEXÃO DE INTERNET';
343
344        // MODO PODCAST / MÚSICA (isStream === false)
345        // Se estiver tocando uma música, não exibimos o status "AO VIVO".
346        let htmlContent = displayMessage;
347        let isExt = false;
348        
349        if (this.isStream === false && (this.state === PLAYER_STATES.TRANSMITTING || this.state === PLAYER_STATES.IDLE || this.state === PLAYER_STATES.CONNECTING || this.state === PLAYER_STATES.BUFFERING)) {
350            htmlContent = '';
351            isExt = true;
352        }
353
354        document.querySelectorAll('[data-player-status]').forEach(el => {
355            el.innerHTML = htmlContent;
356            el.dataset.status = this.state;
357            
358            if (isExt) {
359                el.dataset.isStream = 'false';
360            } else {
361                delete el.dataset.isStream;
362            }
363        });
364        
365        // Ativação da visibilidade do botão Return To Live Global
366        const playerWrap = document.getElementById('player') || document.querySelector('.player-container');
367        if (playerWrap) {
368            if (isExt) {
369                playerWrap.classList.add('is-playing-ext');
370            } else {
371                playerWrap.classList.remove('is-playing-ext');
372            }
373        }
374
375        // Update play/pause icon based on state
376        this.updatePlayIcon();
377    }
378
379    updatePlayIcon() {
380        if (!this.playIcon) return;
381
382        try {
383            const isNativeSvg = this.playIcon.tagName.toLowerCase() === 'svg' && !this.playIcon.hasAttribute('data-prefix');
384
385            // Helper function to safely inject SVG paths cross-browser preventing namespace death
386            const injectSVG = (pathHTML) => {
387                const div = document.createElement('div');
388                div.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg">' + pathHTML + '</svg>';
389                while(this.playIcon.firstChild) {
390                    this.playIcon.removeChild(this.playIcon.firstChild);
391                }
392                Array.from(div.firstChild.childNodes).forEach(node => {
393                    this.playIcon.appendChild(node.cloneNode(true));
394                });
395            };
396
397            if (this.state === PLAYER_STATES.TRANSMITTING) {
398                // Modo PAUSE
399                if (isNativeSvg) {
400                    injectSVG('<path d="M7 4.5a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H6a1.5 1.5 0 0 1-1.5-1.5v-12A1.5 1.5 0 0 1 6 4.5h1zm11 0a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5h-1a1.5 1.5 0 0 1-1.5-1.5v-12A1.5 1.5 0 0 1 17 4.5h1z" fill="currentColor"/>');
401                    this.playIcon.classList.remove('animate-spin');
402                } else {
403                    this.playIcon.setAttribute('class', 'fas fa-pause ml-0.5 md:ml-1 text-base md:text-xl'); 
404                }
405            } else if (this.state === PLAYER_STATES.CONNECTING || this.state === PLAYER_STATES.BUFFERING || this.state === PLAYER_STATES.RECONNECTING) {
406                // Modo LOADING (Spinner)
407                if (isNativeSvg) {
408                    injectSVG('<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>');
409                    this.playIcon.classList.add('animate-spin');
410                } else {
411                    this.playIcon.setAttribute('class', 'fas fa-spinner fa-spin ml-0.5 md:ml-1 text-base md:text-xl');
412                }
413            } else {
414                // Modo PLAY
415                if (isNativeSvg) {
416                    injectSVG('<path d="M6 3.5a1.5 1.5 0 0 1 2.3-.8l11.5 8a1.5 1.5 0 0 1 0 2.6l-11.5 8A1.5 1.5 0 0 1 6 20.5v-17z" fill="currentColor"/>');
417                    this.playIcon.classList.remove('animate-spin');
418                } else {
419                    this.playIcon.setAttribute('class', 'fas fa-play ml-0.5 md:ml-1 text-base md:text-xl');
420                }
421            }
422        } catch (e) {
423            console.warn('[Player] Falha ao atualizar icone do player:', e);
424        }
425        
426        // Dispara um evento global de sincronização customizado
427        document.dispatchEvent(new CustomEvent('radioPlayerStateChange'));
428    }
429
430    // Network Events
431    onNetworkChange(event) {
432        console.log(`[Player] Network event: ${event}`);
433
434        if (event === 'offline') {
435            // Network lost
436            this.setState(PLAYER_STATES.NO_CONNECTION);
437            if (this.userIntendedPlay) {
438                this.audio.pause();
439                this.reconnectionManager.clearReconnect();
440            }
441            this.updatePlayIcon();
442        } else if (event === 'online') {
443            // Network restored
444            if (this.userIntendedPlay && this.state === PLAYER_STATES.NO_CONNECTION) {
445                console.log('[Player] Network restored, attempting reconnection');
446                this.reconnectionManager.reset();
447                this.attemptReconnect();
448            } else if (this.state === PLAYER_STATES.NO_CONNECTION) {
449                this.setState(PLAYER_STATES.IDLE);
450            }
451        }
452    }
453
454    // Playback Control
455    togglePlay() {
456        if (this.state === PLAYER_STATES.TRANSMITTING || 
457            this.state === PLAYER_STATES.CONNECTING || 
458            this.state === PLAYER_STATES.BUFFERING || 
459            this.state === PLAYER_STATES.RECONNECTING) {
460            this.stopPlayback();
461        } else {
462            this.startPlayback();
463        }
464    }
465
466    startPlayback() {
467        console.log('[Player] Start playback requested');
468        this.userIntendedPlay = true;
469
470        // Se isStream não estiver definido, assumimos que é stream (comportamento padrão)
471        if (this.isStream === undefined) this.isStream = true;
472
473        // Se estivermos em modo Track (Top Músicas) e não for um pedido de "Voltar ao Vivo"
474        if (this.isStream === false) {
475            if (this.audio.src && this.audio.paused) {
476                this.audio.play().catch(e => {
477                    console.error('[Player] Track play failed:', e);
478                });
479                this.setState(PLAYER_STATES.TRANSMITTING);
480            }
481            return;
482        }
483
484        // Check network first
485        if (!this.connectionMonitor.checkConnection()) {
486            this.setState(PLAYER_STATES.NO_CONNECTION);
487            return;
488        }
489
490        this.setState(PLAYER_STATES.CONNECTING);
491        this.reconnectionManager.reset();
492        this.loadStream();
493    }
494
495    returnToLive() {
496        console.log('[Player] Returning to live stream');
497        // Stop current track/stream
498        this.stopPlayback();
499
500        // Force stream mode
501        this.isStream = true;
502        this.currentSong = null; // Clear track metadata
503
504        // Update UI to "Loading..."
505        this.playerTitle.textContent = "Reconectando ao vivo...";
506        this.playerArtist.textContent = SITE_NAME;
507
508        // Reset artwork — remove capa do podcast e volta para logotipo do site
509        if (this.playerArtwork) {
510            const img = this.playerArtwork.querySelector('img');
511            if (img && this.defaultArtwork) {
512                img.src = this.defaultArtwork;
513            }
514            // Mantém has-image se tiver defaultArtwork, remove se não houver
515            if (!this.defaultArtwork) {
516                this.playerArtwork.classList.remove('has-image');
517            } else {
518                this.playerArtwork.classList.add('has-image');
519            }
520        }
521
522        // Start playback as stream
523        this.startPlayback();
524
525        // Force metadata fetch soon — atualiza capa/título com a música ao vivo
526        setTimeout(() => this.fetchMetadata(), 1000);
527    }
528
529    stopPlayback() {
530        console.log('[Player] Stop playback requested');
531        this.userIntendedPlay = false;
532        this.reconnectionManager.clearReconnect();
533        this.audio.pause();
534        this.audio.src = '';
535        this.setState(PLAYER_STATES.IDLE);
536        // Ensure play icon is shown when stopped
537        this.updatePlayIcon();
538    }
539
540    loadStream() {
541        const doLoad = () => {
542            if (!this.streamUrl) {
543                this.setState(PLAYER_STATES.ERROR);
544                return;
545            }
546            const streamUrl = this.streamUrl + '?nocache=' + Date.now();
547            this.audio.src = streamUrl;
548            this.audio.load();
549            this.lastDataTime = Date.now();
550            this.attemptPlay();
551
552            // Verifica em background se a URL mudou no painel admin
553            fetch(SITE_URL + '/api/stream-config.php', {
554                headers: { 'X-Requested-With': 'XMLHttpRequest' }
555            })
556                .then(res => res.json())
557                .then(data => {
558                    this.activeCityId = (data && data.city) ? data.city.id : null;
559                    const fresh = data.audio || '';
560                    if (fresh && fresh !== this.streamUrl) {
561                        this.streamUrl = fresh;
562                        if (this.userIntendedPlay && this.state === PLAYER_STATES.TRANSMITTING) {
563                            this.audio.src = this.streamUrl + '?nocache=' + Date.now();
564                            this.audio.load();
565                            this.attemptPlay();
566                        }
567                    }
568                })
569                .catch(() => { /* silencioso */ });
570        };
571
572        if (this.streamUrl) {
573            // URL já em memória: usar direto (sem nova requisição ao servidor)
574            doLoad();
575        } else {
576            // Buscar URL de forma segura — nunca exposta no HTML
577            fetch(SITE_URL + '/api/stream-config.php', {
578                headers: { 'X-Requested-With': 'XMLHttpRequest' }
579            })
580                .then(res => res.json())
581                .then(data => {
582                    this.activeCityId = (data && data.city) ? data.city.id : null;
583                    this.streamUrl = data.audio || '';
584                    doLoad();
585                })
586                .catch(() => {
587                    this.setState(PLAYER_STATES.ERROR);
588                });
589        }
590    }
591
592    /**
593     * Atualiza a URL do streaming em memória.
594     * Mantido para compatibilidade futura (ex: chamada interna na mesma aba).
595     * @param {string} newUrl
596     */
597    updateStreamUrl(newUrl) {
598        if (!newUrl) return;
599        console.log('[Player] Stream URL atualizada em memória:', newUrl);
600        this.streamUrl = newUrl;
601    }
602
603    attemptPlay() {
604        console.log('[Player] Attempting to play...');
605
606        this.audio.play()
607            .then(() => {
608                console.log('[Player] Play succeeded');
609            })
610            .catch(error => {
611                console.error('[Player] Play failed:', error);
612                this.handlePlaybackError();
613            });
614    }
615
616    // Audio Event Handlers
617    onLoadStart() {
618        console.log('[Player] Load start');
619        this.lastDataTime = Date.now();
620    }
621
622    onPlaying() {
623        console.log('[Player] Playing');
624        this.setState(PLAYER_STATES.TRANSMITTING);
625        this.visualizer?.classList.add('playing');
626        this.reconnectionManager.reset();
627        this.fetchMetadata(); // Fetch immediately on play
628
629        if ('mediaSession' in navigator) {
630            navigator.mediaSession.playbackState = 'playing';
631        }
632
633        // Icon updated by updateStatus via setState
634
635        // Pause video player if it's playing (prevent simultaneous playback)
636        this.pauseVideoPlayer();
637    }
638
639    // Pause video player to prevent simultaneous audio/video playback
640    pauseVideoPlayer() {
641        const videoEls = document.querySelectorAll('video, [data-vi
641deo-player] video, #video-player video, .video-player video, #videoPlayer');
642        videoEls.forEach(videoEl => {
643            if (videoEl && !videoEl.paused) {
644                console.log('[Player] Muting video player for audio playback');
645                window.audioInitiatedMute = true;
646                videoEl.muted = true;
647                setTimeout(() => window.audioInitiatedMute = false, 50);
648            }
649        });
650    }
651
652    // External method to pause audio (called by video player)
653    pauseForExternalMedia() {
654        if (this.state === PLAYER_STATES.TRANSMITTING || this.state === PLAYER_STATES.BUFFERING || this.state === PLAYER_STATES.CONNECTING) {
655            console.log('[Player] Pausing for external media playback');
656            this.stopPlayback();
657            this.setState(PLAYER_STATES.IDLE);
658            this.updatePlayIcon();
659            
660            // Dispatch event to sync UI if needed
661            document.dispatchEvent(new CustomEvent('radioPlayerStateChange'));
662        }
663    }
664
665    onPause() {
666        console.log('[Player] Paused');
667        this.visualizer?.classList.remove('playing');
668
669        if (this.state === PLAYER_STATES.TRANSMITTING && this.userIntendedPlay) {
670            // Unexpected pause, might be an error
671            console.log('[Player] Unexpected pause detected');
672        }
673
674        // Ensure play icon shows when paused (unless in reconnecting state)
675        if (this.state !== PLAYER_STATES.RECONNECTING && this.state !== PLAYER_STATES.CONNECTING) {
676            this.updatePlayIcon();
677        }
678
679        if ('mediaSession' in navigator) {
680            navigator.mediaSession.playbackState = 'paused';
681        }
682    }
683
684    onEnded() {
685        console.log('[Player] Stream ended');
686
687        // Podcast ou preview de música terminou — parar sem auto-iniciar live
688        if (!this.isStream) {
689            console.log('[Player] Podcast/Preview ended — resetting to idle, no auto-live');
690            this.resetAfterContent();
691            return;
692        }
693
694        if (this.userIntendedPlay) {
695            this.handleStreamFailure();
696        }
697    }
698
699    /**
700     * Chamado ao fim de podcast ou preview de música.
701     * Para o player, reseta artwork/meta para o estado inicial (logo + nome do rádio)
702     * e busca metadados da live SEM iniciar a reprodução automática.
703     */
704    resetAfterContent() {
705        // Parar reprodução
706        this.audio.pause();
707        this.audio.src = '';
708        this.userIntendedPlay = false;
709
710        // Voltar ao modo stream para que updateSongInfo funcione normalmente
711        this.isStream = true;
712        this.currentSong = null;
713
714        // Resetar ícone de play através do método global para prevenir quebras do SVG
715        this.updatePlayIcon();
716
717        // Resetar artwork para o logoícone do site
718        if (this.playerArtwork) {
719            const img = this.playerArtwork.querySelector('img');
720            if (this.defaultArtwork) {
721                if (img) {
722                    img.src = this.defaultArtwork;
723                    img.alt = '';
724                }
725                this.playerArtwork.classList.add('has-image');
726            } else {
727                this.playerArtwork.classList.remove('has-image');
728            }
729        }
730
731        // Resetar título e artista
732        if (this.playerTitle)  this.playerTitle.textContent  = typeof SITE_NAME !== 'undefined' ? SITE_NAME : '';
733        if (this.playerArtist) this.playerArtist.textContent = 'Clique em play para ouvir';
734
735        // Estado: IDLE
736        this.setState(PLAYER_STATES.IDLE);
737
738        // Busca metadados da live (sem tocar) — atualiza se houver música ao vivo
739        setTimeout(() => this.fetchMetadata(), 800);
740    }
741
742    onError(e) {
743        console.error('[Player] Audio error:', e, this.audio.error);
744
745        if (this.userIntendedPlay) {
746            this.handleStreamFailure();
747        }
748    }
749
750    onWaiting() {
751        console.log('[Player] Waiting for data...');
752
753        if (this.state === PLAYER_STATES.CONNECTING) {
754            this.setState(PLAYER_STATES.BUFFERING);
755        }
756
757        this.lastDataTime = Date.now();
758    }
759
760    onCanPlay() {
761        console.log('[Player] Can play, readyState:', this.audio.readyState);
762
763        if (this.state === PLAYER_STATES.BUFFERING || this.state === PLAYER_STATES.CONNECTING) {
764            // Check if we have minimum buffer
765            if (this.bufferManager.canPlay()) {
766                console.log('[Player] Buffer ready, starting playback');
767                this.attemptPlay();
768            } else {
769                console.log('[Player] Waiting for more buffer...');
770                // Wait a bit more
771                setTimeout(() => {
772                    if (this.bufferManager.isReady()) {
773                        this.attemptPlay();
774                    }
775                }, 1000);
776            }
777        }
778
779        // Restore song info if available
780        if (this.currentSong) {
781            this.updateSongInfo(this.currentSong);
782        }
783    }
784
785    onStalled() {
786        console.log('[Player] Stream stalled');
787        this.lastDataTime = Date.now();
788    }
789
790    onProgress() {
791        // Update last data time on progress
792        this.lastDataTime = Date.now();
793    }
794
795    // Error Handling & Reconnection
796    detectErrorType() {
797        // Check network first
798        if (!this.connectionMonitor.checkConnection()) {
799            return ERROR_TYPES.NETWORK_OFFLINE;
800        }
801
802        // Check audio error
803        if (this.audio.error) {
804            switch (this.audio.error.code) {
805                case MediaError.MEDIA_ERR_NETWORK:
806                    return ERROR_TYPES.STREAM_UNAVAILABLE;
807                case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
808                    return ERROR_TYPES.STREAM_ERROR;
809                case MediaError.MEDIA_ERR_DECODE:
810                    return ERROR_TYPES.STREAM_ERROR;
811                default:
812                    return ERROR_TYPES.UNKNOWN;
813            }
814        }
815
816        // Check for timeout (no data for >30s)
817        if (Date.now() - this.lastDataTime > 30000) {
818            return ERROR_TYPES.STREAM_TIMEOUT;
819        }
820
821        return ERROR_TYPES.UNKNOWN;
822    }
823
824    handleStreamFailure() {
825        const errorType = this.detectErrorType();
826        console.log('[Player] Stream failure detected:', errorType);
827
828        if (errorType === ERROR_TYPES.NETWORK_OFFLINE) {
829            this.setState(PLAYER_STATES.NO_CONNECTION);
830            this.audio.pause();
831            this.reconnectionManager.clearReconnect();
832        } else {
833            this.attemptReconnect();
834        }
835    }
836
837    handlePlaybackError() {
838        if (this.userIntendedPlay) {
839            this.handleStreamFailure();
840        }
841    }
842
843    attemptReconnect() {
844        if (!this.userIntendedPlay) {
845            console.log('[Player] User stopped playback, aborting reconnect');
846            return;
847        }
848
849        // Check network
850        if (!this.connectionMonitor.checkConnection()) {
851            this.setState(PLAYER_STATES.NO_CONNECTION);
852            return;
853        }
854
855        this.setState(PLAYER_STATES.RECONNECTING);
856
857        const delay = this.reconnectionManager.scheduleReconnect(() => {
858            console.log('[Player] Executing reconnect attempt');
859            this.setState(PLAYER_STATES.CONNECTING);
860            this.loadStream();
861        });
862
863        if (delay === false) {
864            // Max attempts reached
865            console.log('[Player] Max reconnection attempts reached');
866            this.setState(PLAYER_STATES.ERROR);
867            this.userIntendedPlay = false;
868        }
869    }
870
871    // Stall Detection
872    startStallChecker() {
873        this.stallCheckInterval = setInterval(() => {
874            if (this.state === PLAYER_STATES.TRANSMITTING) {
875                const timeSinceData = Date.now() - this.lastDataTime;
876
877                // If no data for 30 seconds while supposedly playing
878                if (timeSinceData > 30000) {
879                    console.log('[Player] Stream stall detected (30s no data)');
880                    this.handleStreamFailure();
881                }
882            }
883        }, 5000); // Check every 5 seconds
884    }
885
886    // Volume Control
887    setVolume(value) {
888        this.volume = Math.max(0, Math.min(1, value));
889        this.audio.volume = this.volume;
890        this.volumeSlider.value = this.volume * 100;
891        localStorage.setItem('playerVolume', this.volume);
892        this.updateVolumeIcon();
893    }
894
895    toggleMute() {
896        if (this.audio.volume > 0) {
897            this.previousVolume = this.audio.volume;
898            this.setVolume(0);
899        } else {
900            this.setVolume(this.previousVolume || 0.8);
901        }
902    }
903
904    updateVolumeIcon() {
905        const PATH_MUTE = 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z';
906        const PATH_DOWN = 'M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z';
907        const PATH_UP   = 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z';
908
909        const path = this.volumeIcon ? this.volumeIcon.querySelector('path') : null;
910        if (path) {
911            path.setAttribute('d', this.volume === 0 ? PATH_MUTE : this.volume < 0.5 ? PATH_DOWN : PATH_UP);
912        }
913    }
914
915    // Metadata
916    async fetchMetadata() {
917        // Don't auto-update metadata if we are playing a specific track
918        if (this.isStream === false) return;
919
920        // Geração atual: se trocar de praça durante o await, descartamos a
921        // resposta antiga para não sobrescrever os dados da praça nova.
922        const gen = this.metadataGen;
923
924        // Cancela a busca anterior ainda pendente (resposta atrasada da praça anterior).
925        if (this.metadataAbort) { try { this.metadataAbort.abort(); } catch (e) {} }
926        const ac = (typeof AbortController !== 'undefined') ? new AbortController() : null;
927        this.metadataAbort = ac;
928
929        // Metadados por praça: envia ?city quando há praça ativa.
930        const url = SITE_URL + '/api/metadata.php' +
931            (this.activeCityId ? ('?city=' + encodeURIComponent(this.activeCityId)) : '');
932
933        try {
934            const response = await fetch(url, ac ? { signal: ac.signal }
934 : undefined);
935            const data = await response.json();
936
937            // Resposta fora de ordem (troca de praça no meio) → descarta.
938            if (gen !== this.metadataGen) return;
939
940            if (data.success && data.playing) {
941                this.currentSong = data;
942                // Update song info with streaming data
943                this.updateSongInfo(data);
944            } else {
945                // No song playing - ensure default artwork is displayed
946                this.currentSong = null;
947                // Limpa qualquer título/artista residual (nunca deixar dado desatualizado).
948                if (this.playerTitle) this.playerTitle.textContent = '';
949                if (this.playerArtist) this.playerArtist.textContent = (typeof SITE_NAME !== 'undefined' ? SITE_NAME : '');
950                // Use artwork from API response (capa da praça/ícone) or local default
951                const artworkUrl = data.artwork || this.defaultArtwork;
952                if (artworkUrl) {
953                    this.showDefaultArtwork(artworkUrl);
954                }
955            }
956        } catch (error) {
957            if (error && error.name === 'AbortError') return; // cancelamento esperado
958            console.error('[Player] Metadata fetch error:', error);
959        }
960    }
961
962    /**
963     * Chamado ao trocar de praça (pelo seletor). Descarta a busca de metadados
964     * em curso e limpa o texto residual da praça anterior — o próximo fetch traz
965     * os dados da nova praça (this.activeCityId já atualizado pelo seletor).
966     */
967    onCityChanged() {
968        this.metadataGen++;
969        if (this.metadataAbort) { try { this.metadataAbort.abort(); } catch (e) {} }
970        this.currentSong = null;
971        if (this.playerTitle) this.playerTitle.textContent = 'Carregando...';
972        if (this.playerArtist) this.playerArtist.textContent = (typeof SITE_NAME !== 'undefined' ? SITE_NAME : '');
973        setTimeout(() => this.fetchMetadata(), 400);
974    }
975
976    // Show default artwork when no song is playing
977    showDefaultArtwork(artworkUrl) {
978        if (!artworkUrl) return;
979
980        this.playerArtwork.classList.add('has-image');
981
982        let img = this.playerArtwork.querySelector('img');
983        if (!img) {
984            img = document.createElement('img');
985            this.playerArtwork.appendChild(img);
986        }
987
988        if (img.src !== artworkUrl) {
989            img.src = artworkUrl;
990            img.alt = 'Capa do player';
991        }
992    }
993
994    updateSongInfo(data) {
995        // ── Modo vídeo ativo: não sobrescrever título/artista/artwork ──
996        // Flag setado por switchToVideo() em home.php via rp.videoModeActive = true
997        if (this.videoModeActive) return;
998
999        // Update title and artist
1000        if (data.song) {
1001            this.playerTitle.textContent = data.song;
1002            this.playerArtist.textContent = data.artist || SITE_NAME;
1003        } else if (data.title) {
1004            this.playerTitle.textContent = data.title;
1005            this.playerArtist.textContent = data.artist || SITE_NAME;
1006        }
1007
1008        // Determine which artwork to use
1009        const artworkUrl = data.artwork || this.defaultArtwork;
1010
1011        // Toggle mobile fallback player class
1012        const playerEl = document.getElementById('player');
1013        if (playerEl) {
1014            if (data.is_fallback) {
1015                playerEl.classList.add('is-fallback-meta');
1016            } else {
1017                playerEl.classList.remove('is-fallback-meta');
1018            }
1019        }
1020
1021        // Update artwork
1022        if (artworkUrl) {
1023            this.playerArtwork.classList.add('has-image');
1024
1025            let img = this.playerArtwork.querySelector('img');
1026            if (!img) {
1027                img = document.createElement('img');
1028                this.playerArtwork.appendChild(img);
1029            }
1030
1031            if (img.src !== artworkUrl) {
1032                img.src = artworkUrl;
1033                img.alt = data.song || 'Album art';
1034            }
1035        } else {
1036            this.playerArtwork.classList.remove('has-image');
1037            const img = this.playerArtwork.querySelector('img');
1038            if (img) img.remove();
1039        }
1040
1041        // Update Media Session Metadata
1042        this.updateMediaSession(data);
1043    }
1044
1045    // Track Playback (Top Musicas)
1046    playTrack(url, metadata) {
1047        console.log('[Player] Playing track:', metadata.title);
1048
1049        // Stop current playback if it's a stream
1050        if (this.state === PLAYER_STATES.TRANSMITTING || this.state === PLAYER_STATES.BUFFERING) {
1051            this.audio.pause();
1052        }
1053
1054        this.userIntendedPlay = true;
1055        this.isStream = false; // Flag to indicate we are playing a track, not the live stream
1056
1057        // Update Metadata
1058        this.currentSong = {
1059            title: metadata.title,
1060            artist: metadata.artist,
1061            artwork: metadata.artwork
1062        };
1063        this.updateSongInfo(this.currentSong);
1064
1065        // Play the track
1066        this.audio.src = url;
1067        this.audio.load();
1068
1069        this.setState(PLAYER_STATES.BUFFERING);
1070
1071        this.audio.play()
1072            .then(() => {
1073                console.log('[Player] Track playback started');
1074                this.setState(PLAYER_STATES.TRANSMITTING);
1075            })
1076            .catch(error => {
1077                console.error('[Player] Track playback failed:', error);
1078                this.setState(PLAYER_STATES.ERROR);
1079            });
1080    }
1081
1082
1083    // ==========================================
1084    // Podcast Playback
1085    // ==========================================
1086
1087    /**
1088     * Play a podcast episode in the player.
1089     * After the episode ends, the player automatically returns to the live stream.
1090     * @param {string} url       - Audio URL (local upload or external)
1091     * @param {string} title     - Episode title
1092     * @param {string} artist    - Series / show name
1093     * @param {string} [artwork] - Cover image URL
1094     */
1095    playPodcast(url, title, artist, artwork) {
1096        if (!url) {
1097            console.warn('[Player] playPodcast: no audio URL provided');
1098            return;
1099        }
1100        console.log('[Player] Playing podcast:', title);
1101        this.playTrack(url, { title, artist: artist || '', artwork: artwork || this.defaultArtwork });
1102    }
1103
1104    // Cleanup
1105    destroy() {
1106        this.reconnectionManager.clearReconnect();
1107        if (this.metadataInterval) {
1108            clearInterval(this.metadataInterval);
1109        }
1110        if (this.stallCheckInterval) {
1111            clearInterval(this.stallCheckInterval);
1112        }
1113        this.audio.pause();
1114        this.audio.src = '';
1115    }
1116}
1117
1118// Initialize player when DOM is ready
1119document.addEventListener('DOMContentLoaded', () => {
1120    window.radioPlayer = new RadioPlayer();
1121    console.log('[Player] Initialized');
1122
1123    // Global Exclusivity Rule: Audio vs Video
1124    window.audioInitiatedMute = false;
1125
1126    // Listen for 'play' events globally with capturing phase to catch all video elements
1127    document.addEventListener('play', (event) => {
1128        const target = event.target;
1129        // If the element starting to play is a video, pause the audio player ONLY if it has sound
1130        if (target && target.tagName && target.tagName.toLowerCase() === 'video') {
1131            if (!target.muted && target.volume > 0) {
1132                console.log('[Global] Video play detected with sound, pausing audio player if active.');
1133                if (window.radioPlayer && typeof window.radioPlayer.pauseForExternalMedia === 'function') {
1134                    window.radioPlayer.pauseForExternalMedia();
1135                }
1136            }
1137        }
1138    }, true); // true = Use Capture Phase to ensure it catches even if stopped propagating
1139
1140    // Listen for 'volumechange' events globally to restore exclusivity when unmuted
1141    document.addEventListener('volumechange', (event) => {
1142        const target = event.target;
1143        if (target && target.tagName && target.tagName.toLowerCase() === 'video') {
1144            if (window.audioInitiatedMute) return; // ignora mudanças causadas pelo próprio player de áudio
1145            if (!target.muted && target.volume > 0) {
1146                console.log('[Global] Video volume restored, pausing audio player if active.');
1147                if (window.radioPlayer && typeof window.radioPlayer.pauseForExternalMedia === 'function') {
1148                    window.radioPlayer.pauseForExternalMedia();
1149                }
1150            }
1151        }
1152    }, true);
1153
1154    // Global Initialization Rule: All videos must start muted until exact user interaction
1155    const forceMuteInitialVideos = () => {
1156        const videoEls = document.querySelectorAll('video, [data-video-player] video, #video-player video, .video-player video, #videoPlayer');
1157        videoEls.forEach(videoEl => {
1158            if (!videoEl.muted) {
1159                videoEl.muted = true;
1160                videoEl.volume = 1;
1161                
1162                const muteBtn = document.querySelector('[data-video-mute], .video-mute-btn, .player-volume-btn');
1163                if (muteBtn) {
1164                    muteBtn.classList.add('is-muted');
1165                }
1166            }
1167        });
1168    };
1169    // Exigimos a execução logo de cara (se os elementos já existirem) e após o carregamento
1170    forceMuteInitialVideos();
1171    setTimeout(forceMuteInitialVideos, 500); // Garante a captura logo após scripts assíncronos (ex: hls.js attachMedia)
1172});

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.