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.