1/** 2 * WXLI Player - Cognitive Audio Network 3 * Main player functionality and streaming logic 4 * 5 * @author WXLI Tech 6 * @version 2.0.0 7 * 8 * NOTA: Este archivo requiere wxli-api-cache.js cargado previamente 9 * para las funciones wxliToken() y wxliFetchNowPlaying() 10 */ 11 12// Función global para guardar estado de reproducción antes de navegar (cambio de idioma) 13function wxliSavePlaybackState() { 14 const audio = document.querySelector('#wxli-player audio'); 15 if (audio && !audio.paused) { 16 localStorage.setItem('wxli-resume-playback', JSON.stringify({ 17 wasPlaying: true, 18 timestamp: Date.now() 19 })); 20 } 21} 22 23// Anti-bot canvas text renderer (global, used by player and schedule modules) 24// Supports multiline text via '\n' â each line renders as a separate canvas block. 25// Optional maxWidth: if set, long lines are word-wrapped to avoid canvas deformation. 26function wxliProtectText(container, text, color, fontSize, fontFamily, maxWidth) { 27 var dpr = window.devicePixelRatio || 1; 28 var fs = fontSize || 14; 29 var ff = (fontFamily || 'Roboto') + ', sans-serif'; 30 var col = color || '#ffffff'; 31 var fontStr = '400 ' + fs + 'px ' + ff; 32 33 // Measure helper (single canvas reused for measuring) 34 var measurer = document.createElement('canvas'); 35 var mctx = measurer.getContext('2d'); 36 mctx.font = fontStr; 37 38 // Split by explicit newlines, then word-wrap each segment if maxWidth is set 39 var segments = String(text).split('\n'); 40 var allLines = []; 41 segments.forEach(function(segment) { 42 if (!maxWidth || mctx.measureText(segment).width <= maxWidth) { 43 allLines.push(segment); 44 return; 45 } 46 // Word-wrap: break at spaces when line exceeds maxWidth 47 var words = segment.split(' '); 48 var currentLine = ''; 49 words.forEach(function(word) { 50 var testLine = currentLine ? currentLine + ' ' + word : word; 51 if (mctx.measureText(testLine).width > maxWidth && currentLine) { 52 allLines.push(currentLine); 53 currentLine = word; 54 } else { 55 currentLine = testLine; 56 } 57 }); 58 if (currentLine) allLines.push(currentLine); 59 }); 60 61 container.style.display = 'block'; 62 allLines.forEach(function(line) { 63 var canvas = document.createElement('canvas'); 64 var ctx = canvas.getContext('2d'); 65 ctx.font = fontStr; 66 var w = Math.ceil(ctx.measureText(line).width) + 6; 67 var h = Math.ceil(fs * 1.6); 68 canvas.width = w * dpr; 69 canvas.height = h * dpr; 70 canvas.style.cssText = 'width:' + w + 'px;height:' + h + 'px;display:block;max-width:100%'; 71 ctx.scale(dpr, dpr); 72 ctx.font = fontStr; 73 ctx.fillStyle = col; 74 ctx.textBaseline = 'middle'; 75 ctx.fillText(line, 3, h / 2); 76 container.appendChild(canvas); 77 }); 78} 79 80 (function() { 81 'use strict'; 82 83 // ============================================================================ 84 // DETECCIÃN COMPLETA DE DISPOSITIVO, NAVEGADOR Y PLATAFORMA 85 // ============================================================================ 86 const ua = navigator.userAgent; 87 const uaLower = ua.toLowerCase(); 88 89 // Dispositivos 90 const isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua); 91 const isIOSDevice = /iPad|iPhone|iPod/.test(ua) && !window.MSStream; 92 const isAndroidDevice = /Android/i.test(ua); 93 // Navegadores 94 const isFirefox = uaLower.indexOf('firefox') > -1; 95 const isChrome = uaLower.indexOf('chrome') > -1 && uaLower.indexOf('edg') === -1 && uaLower.indexOf('opr') === -1; 96 const isEdge = uaLower.indexOf('edg') > -1; 97 const isOpera = uaLower.indexOf('opr') > -1 || uaLower.indexOf('opera') > -1; 98 const isSafari = uaLower.indexOf('safari') > -1 && uaLower.indexOf('chrome') === -1; 99 const isChromium = isChrome || isEdge || isOpera; // Navegadores basados en Chromium 100 101 // CaracterÃsticas del navegador 102 const browserConfig = { 103 // Delay antes de play() para estabilizar 104 playDelay: isFirefox ? 50 : (isIOSDevice ? 100 : 0), 105 // Timeout para esperar buffer 106 bufferTimeout: isFirefox ? 5000 : (isMobileDevice ? 4000 : 3000), 107 // Debounce entre llamadas play() 108 playDebounce: isMobileDevice ? 150 : 100, 109 // Pausar antes de cambiar src 110 pauseBeforeSrcChange: isFirefox || isSafari, 111 // Tiempo mÃnimo de buffer antes de play (segundos) 112 minBufferBeforePlay: isMobileDevice ? 1.5 : 1, 113 // Intervalo de heartbeat para detectar stalls (ms) 114 heartbeatInterval: isMobileDevice ? 3000 : 5000, 115 // Máximo tiempo sin progreso antes de reconectar (ms) 116 maxStallTime: isMobileDevice ? 8000 : 10000, 117 // Usar eventos de red especÃficos 118 useNetworkEvents: 'connection' in navigator, 119 // Nombre del navegador para logs 120 browserName: isFirefox ? 'Firefox' : (isChrome ? 'Chrome' : (isEdge ? 'Edge' : (isOpera ? 'Opera' : (isSafari ? 'Safari' : 'Unknown')))) 121 }; 122 123 124 // ============================================================================ 125 // SISTEMA DE REPRODUCCIÃN PREMIUM (estabilidad en todos los navegadores) 126 // ============================================================================
127 let playPromisePending = false; 128 let pendingPlayAborted = false; 129 let lastPlayAttempt = 0; 130 let lastAudioTime = 0; 131 let lastAudioTimeCheck = 0; 132 let stallDetectionInterval = null; 133 let consecutiveStalls = 0; 134 let isReconnecting = false; 135 136 // Función segura para reproducir (evita race conditions en todos los navegadores) 137 function safePlay(audioElement, source) { 138 const now = Date.now(); 139 140 // Evitar llamadas muy rápidas 141 if (now - lastPlayAttempt < browserConfig.playDebounce) { 142 return Promise.resolve(); 143 } 144 lastPlayAttempt = now; 145 146 // Si hay una promesa pendiente, marcarla como abortada 147 if (playPromisePending) { 148 pendingPlayAborted = true; 149 } 150 151 playPromisePending = true; 152 pendingPlayAborted = false; 153 154 return new Promise((resolve, reject) => { 155 setTimeout(() => { 156 // Verificar si fue abortado 157 if (pendingPlayAborted) { 158 playPromisePending = false; 159 resolve(); 160 return; 161 } 162 163 // iOS Safari: verificar que el elemento esté listo 164 if (isIOSDevice && audioElement.readyState < 2) { 165 const waitForReady = () => { 166 if (audioElement.readyState >= 2) { 167 audioElement.removeEventListener('canplay', waitForReady); 168 doPlay(); 169 } 170 }; 171 audioElement.addEventListener('canplay', waitForReady, { once: true }); 172 // Timeout de seguridad para iOS 173 setTimeout(() => { 174 audioElement.removeEventListener('canplay', waitForReady); 175 doPlay(); 176 }, 2000); 177 return; 178 } 179 180 doPlay(); 181 182 function doPlay() { 183 const playPromise = audioElement.play(); 184 185 if (playPromise !== undefined) { 186 playPromise 187 .then(() => { 188 playPromisePending = false; 189 consecutiveStalls = 0; // Reset en play exitoso 190 startStallDetection(audioElement); 191 resolve(); 192 }) 193 .catch((error) => { 194 playPromisePending = false; 195 if (error.name === 'AbortError') { 196 resolve(); 197 } else if (error.name === 'NotAllowedError') { 198 // En móvil, mostrar indicador visual si está disponible 199 if (isMobileDevice) { 200 showPlayPrompt(); 201 } 202 resolve(); 203 } else if (error.name === 'NotSupportedError') { 204 scheduleReconnect(audioElement, 'format-error'); 205 resolve(); 206 } else { 207 reject(error); 208 } 209 }); 210 } else { 211 playPromisePending = false; 212 resolve(); 213 } 214 } 215 }, browserConfig.playDelay); 216 }); 217 } 218 219 // Función para mostrar prompt de play (móvil) 220 function showPlayPrompt() { 221 // Dispatch evento personalizado para que la UI pueda reaccionar 222 window.dispatchEvent(new CustomEvent('wxli-play-required')); 223 } 224 225 // Función segura para cargar y reproducir (cambios de src) 226 function safeLoadAndPlay(audioElement, newSrc, source) { 227 // No cargar si estamos cambiando al fallback 228 if (isFallingBack) { 229 return Promise.resolve(); 230 } 231 232 233 // Abortar cualquier play pendiente 234 if (playPromisePending) { 235 pendingPlayAborted = true; 236 } 237 238 // Detener detección de stalls durante la carga 239 stopStallDetection(); 240 241 return new Promise((resolve) => { 242 // Pausar primero si el navegador lo requiere 243 if (browserConfig.pauseBeforeSrcChange && !audioElement.paused) { 244 audioElement.pause(); 245 } 246 247 // Chrome/Edge: limpiar src antes de asignar nuevo (evita buffering issues) 248 if (isChromium) { 249 audioElement.removeAttribute('src'); 250 audioElement.load(); 251 } 252 253 audioElement.src = newSrc; 254 audioElement.load(); 255 256 let resolved = false;
257 258 const cleanup = () => { 259 audioElement.removeEventListener('canplaythrough', onCanPlayThrough); 260 audioElement.removeEventListener('canplay', onCanPlay); 261 audioElement.removeEventListener('error', onError); 262 audioElement.removeEventListener('loadeddata', onLoadedData); 263 clearTimeout(timeout); 264 }; 265 266 const doResolve = () => { 267 if (resolved) return; 268 resolved = true; 269 cleanup(); 270 safePlay(audioElement, source + '-afterLoad') 271 .then(resolve) 272 .catch(() => resolve()); 273 }; 274 275 // Evento preferido: suficiente buffer para reproducir sin interrupciones 276 const onCanPlayThrough = () => { 277 doResolve(); 278 }; 279 280 // Fallback: puede comenzar a reproducir 281 const onCanPlay = () => { 282 // En móvil, canplay es suficiente 283 if (isMobileDevice) { 284 doResolve(); 285 } 286 }; 287 288 // iOS especÃfico: loadeddata a veces es más confiable 289 const onLoadedData = () => { 290 if (isIOSDevice) { 291 // Esperar un poco más en iOS para asegurar buffer 292 setTimeout(doResolve, 200); 293 } 294 }; 295 296 const onError = (e) => { 297 if (resolved || isFallingBack) { resolve(); return; } 298 resolved = true; 299 cleanup(); 300 // Programar reconexión automática 301 scheduleReconnect(audioElement, 'load-error'); 302 resolve(); 303 }; 304 305 audioElement.addEventListener('canplaythrough', onCanPlayThrough, { once: true }); 306 audioElement.addEventListener('canplay', onCanPlay, { once: true }); 307 audioElement.addEventListener('loadeddata', onLoadedData, { once: true }); 308 audioElement.addEventListener('error', onError, { once: true }); 309 310 // Timeout de seguridad â si no carga, ir al fallback 311 const timeout = setTimeout(() => { 312 if (resolved) return; 313 resolved = true; 314 cleanup(); 315 if (!tryNextFallback()) { 316 setTimeout(() => setupStream(), 5000); 317 } 318 resolve(); 319 }, browserConfig.bufferTimeout); 320 }); 321 } 322 323 // ============================================================================ 324 // SISTEMA DE DETECCIÃN DE STALLS Y RECONEXIÃN AUTOMÃTICA 325 // ============================================================================ 326 327 function startStallDetection(audioElement) { 328 stopStallDetection(); // Limpiar anterior si existe 329 330 lastAudioTime = audioElement.currentTime; 331 lastAudioTimeCheck = Date.now(); 332 333 stallDetectionInterval = setInterval(() => { 334 if (audioElement.paused || (document.hidden && !isAndroidDevice)) { 335 return; // No verificar si está pausado u oculto 336 } 337 338 const currentTime = audioElement.currentTime; 339 const now = Date.now(); 340 const timeSinceCheck = now - lastAudioTimeCheck; 341 342 // Verificar si el tiempo de audio avanzó 343 if (currentTime === lastAudioTime && timeSinceCheck > browserConfig.maxStallTime) { 344 consecutiveStalls++; 345 346 if (consecutiveStalls >= 2) { 347 scheduleReconnect(audioElement, 'stall-detected'); 348 } else { 349 // Primer stall: intentar recuperar suavemente 350 if (hls) { 351 hls.startLoad(-1); 352 } else { 353 // Direct stream: evitar seeks (pueden causar microcortes en móviles) 354 safePlay(audioElement, 'soft-stall-recover').catch(() => {}); 355 } 356} 357 } else if (currentTime !== lastAudioTime) { 358 // Audio progresando normalmente 359 consecutiveStalls = 0; 360 } 361 362 lastAudioTime = currentTime; 363 lastAudioTimeCheck = now; 364 }, browserConfig.heartbeatInterval); 365 } 366 367 function stopStallDetection() { 368 if (stallDetectionInterval) { 369 clearInterval(stallDetectionInterval); 370 stallDetectionInterval = null; 371 } 372 } 373 374 // Reconexión automática con backoff exponencial 375 let reconnectTimeout = null; 376 377 function scheduleReconnect(_audioElement, reason) { 378 if (isReconnecting || isFallingBack) return; 379 380 // Fallback inmediato al primer fallo de reconexión 381 if (!tryNextFallback()) { 382 setTimeout(() => setupStream(), 5000); 383 } 384 } 385 386 function cancelReconnect() { 387 clearTimeout(reconnectTimeout); 388 isReconnecting = false;
389 } 390 391 // ============================================================================ 392 // CLEANUP GLOBAL (evita leaks en iOS/Android con BFCache / pagehide) 393 // ============================================================================ 394 395 function wxliCleanupAll() { 396 try { stopStallDetection(); } catch (e) {} 397 398 try { if (healthCheckInterval) { clearInterval(healthCheckInterval); healthCheckInterval = null; } } catch (e) {} 399 try { if (wifiKeepAliveInterval) { clearInterval(wifiKeepAliveInterval); wifiKeepAliveInterval = null; } } catch (e) {} 400 401 try { 402 if (apiUpdateInterval) { 403 clearTimeout(apiUpdateInterval); 404 apiUpdateInterval = null; 405 } 406 } catch (e) {} 407 408 try { cancelReconnect(); } catch (e) {} 409 try { stopPrimaryRecovery(); } catch (e) {} 410 try { if (interruptRecoveryTimeout) { clearTimeout(interruptRecoveryTimeout); interruptRecoveryTimeout = null; } } catch (e) {} 411 412 // Transición / animaciones 413 try { if (transitionApiIntervalId) { clearInterval(transitionApiIntervalId); transitionApiIntervalId = null; } } catch (e) {} 414 try { if (transitionAnimationId) { cancelAnimationFrame(transitionAnimationId); transitionAnimationId = null; } } catch (e) {} 415 416 // HLS (si aplica) 417 try { 418 if (window.hlsInstance) { 419 window.hlsInstance.stopLoad(); 420 window.hlsInstance.detachMedia(); 421 window.hlsInstance.destroy(); 422 window.hlsInstance = null; 423 } 424 } catch (e) {} 425 426 // Audio: liberar recursos (pause/stop manejado por HLS destroy arriba) 427 } 428 429 // pagehide es el evento correcto para Safari/iOS (BFCache) 430 window.addEventListener('pagehide', wxliCleanupAll, { capture: true }); 431 window.addEventListener('beforeunload', wxliCleanupAll, { capture: true }); 432 433 434 // ============================================================================ 435 // MANEJO DE INTERRUPCIONES MÃVILES (llamadas, notificaciones, etc.) 436 // ============================================================================ 437 438 let wasPlayingBeforeInterrupt = false; 439 let interruptRecoveryTimeout = null; 440 441 function setupMobileInterruptHandlers(audioElement) { 442 if (!isMobileDevice) return; 443 444 // Evento de interrupción de audio (iOS) 445 if (isIOSDevice) { 446 audioElement.addEventListener('pause', function() { 447 // Detectar si fue una interrupción del sistema 448 if (!isManualPause && document.visibilityState === 'visible' && !audioElement.ended) { 449 wasPlayingBeforeInterrupt = true; 450 451 // Intentar recuperar después de un momento 452 clearTimeout(interruptRecoveryTimeout); 453 interruptRecoveryTimeout = setTimeout(() => { 454 if (wasPlayingBeforeInterrupt && audioElement.paused && !isManualPause) { 455 safePlay(audioElement, 'interrupt-recovery'); 456 } 457 wasPlayingBeforeInterrupt = false; 458 }, 1500); 459 } 460 }); 461 } 462 463 // Android: usar Audio Focus API si está disponible 464 if (isAndroidDevice && 'mediaSession' in navigator) { 465 // El mediaSession ya maneja esto, pero agregamos listeners adicionales 466 audioElement.addEventListener('waiting', function() { 467 }); 468 469 audioElement.addEventListener('stalled', function() { 470 // En Android, stalled a menudo se recupera solo, esperar un poco 471 setTimeout(() => { 472 if (audioElement.paused && !isManualPause) { 473 safePlay(audioElement, 'android-stall-recovery'); 474 } 475 }, 2000); 476 }); 477 } 478 479 // Evento de cambio de red (todos los móviles) 480 if (browserConfig.useNetworkEvents) { 481 const conn = navigator.connection; 482 conn.addEventListener('change', function() { 483 const type = conn.effectiveType || conn.type; 484 485 // Si cambió a una conexión más lenta, ajustar 486 if (type === 'slow-2g' || type === '2g') { 487 // PodrÃa ajustar calidad aquà si el sistema lo soporta 488 } 489 }); 490 } 491 } 492 493 // Configuración de streaming 494 const STREAM_CONFIG = { 495 primary: 'https://streaming.hls.core.wxli.ai/live.m3u8', 496 type: 'hls', // changed from 'direct' 497 fallbacks: [ 498 { url: 'https://streaming.live365.com/a97634/playlist.m3u8', type: 'hls' } 499 ] 500 }; 501 502 // Estado del streaming con fallbacks 503 let currentStreamIndex = -1; // -1 = primary, 0+ = Ãndice de fallback 504 505 506 let hls = null; 507 let wxliAudioElement = null; 508 let maudioContainer = null; 509 510 // Variables para tracking de tiempo 511 let currentElapsed = 0; 512 let currentDuration = 0; 513 let lastUpdateTime = Date.now(); 514 515 // Variables para efecto de transición de track 516 let isInTransition = false;
517 let transitionLocked = false; // Evita parpadeo de datos durante transición 518 let transitionAnimationId = null; 519 let transitionApiIntervalId = null; // Intervalo para actualizar API durante transición 520 521 522 const BOOT_PHRASES = [ 523 { artist: 'Live Intelligence', title: 'Initializing sessionâ¦' }, 524 { artist: 'Signal Flow', title: 'Aligning signalâ¦' }, 525 { artist: 'Context Engine', title: 'Reading the momentâ¦' } 526 ]; 527 528 const TRANSITION_PHRASES = [ 529 // Core signal continuity 530 { artist: 'Live Intelligence', title: 'Session evolvingâ¦' }, 531 { artist: 'Signal Flow', title: 'Realigning phaseâ¦' }, 532 { artist: 'Adaptive Layer', title: 'Continuity maintainedâ¦' }, 533 { artist: 'Session State', title: 'New phase initiatedâ¦' }, 534 { artist: 'Context Engine', title: 'Energy recalibratingâ¦' }, 535 536 // Preparation (voice/text may appear) 537 { artist: 'Cognitive Layer', title: 'Expression formingâ¦' }, 538 { artist: 'Signal Focus', title: 'Intent crystallizingâ¦' }, 539 { artist: 'Live Intelligence', title: 'Output pendingâ¦' }, 540 541 // Hybrid transitions (music â voice/text) 542 { artist: 'Signal Flow', title: 'Mode shiftingâ¦' }, 543 { artist: 'Session State', title: 'Rhythm to intentâ¦' }, 544 { artist: 'Live Intelligence', title: 'Voice layer activeâ¦' }, 545 546 // Identity reinforcement 547 { artist: 'Live Intelligence', title: 'System awareâ¦' }, 548 549 // Musical/orgánico 550 { artist: 'Sensing', title: 'Reading the atmosphereâ¦' }, 551 { artist: 'Harmonizing', title: 'Finding the flowâ¦' }, 552 { artist: 'Resonating', title: 'Channeling energyâ¦' }, 553 { artist: 'Evolving', title: 'Transforming the sessionâ¦' }, 554 555 // Sistema/inteligencia 556 { artist: 'Signal Flow', title: 'Mode shiftingâ¦' }, 557 558 // IA generativa (usar poco) 559 { artist: 'Learning', title: 'Creating as it unfoldsâ¦' }, 560 { artist: 'Adapting', title: 'Rethinking dynamicallyâ¦' } 561 ]; 562 563 // Cache para optimizar API 564 let lastSongId = null; 565 let apiUpdateInterval = null; 566 let updateNowPlayingRef = null; // Referencia a updateNowPlaying para acceso desde transiciones 567 let scheduleNextUpdateRef = null; // Referencia a scheduleNextUpdate para acceso desde resume 568 569 // Variable para nombre del playlist 570 let currentPlaylistName = 'WXLI Live'; 571 let lastPlaylistName = 'WXLI Live'; // Para detectar cambios de playlist 572 window.currentPlaylistName = currentPlaylistName; // Exponer globalmente para schedule IIFE 573 574 // Variable para tracking de la canción actual en el reproductor principal 575 let currentPlayerSongId = null; 576 let currentPlayerSongData = null; // Metadata completa de la canción actual 577 578 // Variables para monitoreo de salud del stream 579 let lastPlayingTime = Date.now(); 580 let healthCheckInterval = null; 581 582 // Evitar duplicación de listeners (si el player se re-inicializa) 583 let healthTimeupdateAdded = false; 584 let healthTimeupdateHandler = null; 585// Variable para detectar pausas manuales 586 let isManualPause = false; 587 let lastPauseTime = 0; // Timestamp de última pausa para refrescar stream 588 let pageHiddenTime = 0; // Timestamp de cuando la página se ocultó (para detectar idle) 589 590 // ============================================================================ 591 // SISTEMA CENTRALIZADO DE EVENTOS (evita race conditions por listeners duplicados) 592 // ============================================================================ 593 const eventManager = { 594 visibilityHandlers: [], 595 pauseHandlers: [], 596 networkChangeTimeout: null, 597 lastNetworkChange: 0, 598 599 // Registrar handler de visibility (en vez de addEventListener directo) 600 onVisibilityChange: function(handler, priority = 10) { 601 this.visibilityHandlers.push({ handler, priority }); 602 this.visibilityHandlers.sort((a, b) => a.priority - b.priority); 603 }, 604 605 // Registrar handler de pause 606 onPause: function(handler, priority = 10) { 607 this.pauseHandlers.push({ handler, priority }); 608 this.pauseHandlers.sort((a, b) => a.priority - b.priority); 609 }, 610 611 // Ejecutar todos los handlers de visibility en orden
612 triggerVisibility: function(isHidden) { 613 const now = Date.now(); 614 const idleDuration = pageHiddenTime > 0 ? now - pageHiddenTime : 0; 615 const wasLongIdle = idleDuration > 300000; // 5 minutos 616 617 this.visibilityHandlers.forEach(({ handler }) => { 618 try { 619 handler(isHidden, { idleDuration, wasLongIdle, timestamp: now }); 620 } catch (e) { 621 } 622 }); 623 }, 624 625 // Ejecutar todos los handlers de pause en orden 626 triggerPause: function(isPaused) { 627 const context = { isManual: isManualPause, timestamp: Date.now() }; 628 this.pauseHandlers.forEach(({ handler }) => { 629 try { 630 handler(isPaused, context); 631 } catch (e) { 632 } 633 }); 634 }, 635 636 // Manejar cambio de red con debounce (500ms) 637 handleNetworkChange: function(callback) { 638 const now = Date.now(); 639 if (now - this.lastNetworkChange < 2000) { 640 return; 641 } 642 643 clearTimeout(this.networkChangeTimeout); 644 this.networkChangeTimeout = setTimeout(() => { 645 this.lastNetworkChange = Date.now(); 646 callback(); 647 }, 500); 648 } 649 }; 650 651 // Listener ÃNICO de visibilitychange (reemplaza los 11 duplicados) 652 document.addEventListener('visibilitychange', function() { 653 if (document.hidden) { 654 pageHiddenTime = Date.now(); 655 } 656 eventManager.triggerVisibility(document.hidden); 657 }); 658 659 function initializeWXLI() { 660 maudioContainer = document.querySelector('#wxli-player .maudio'); 661 662 if (maudioContainer) { 663 wxliAudioElement = maudioContainer.querySelector('audio'); 664 665 if (wxliAudioElement) { 666 setupStream(); 667 setupMetadata(); 668 startBootAnimation(); 669 startProgressAnimation(); 670 setupVolumeControls(); 671 setupPopupPlayer(); 672 setupPlaylistToggle(); 673 setupPlayerLikeButton(); 674 setupHealthMonitor(); 675 setupPlayPauseInterception(); 676 setupSystemEventListeners(); 677 setupBatteryOptimization(); // Optimizaciones de baterÃa para móviles 678 setupMobileInterruptHandlers(wxliAudioElement); // Manejo de interrupciones móviles 679 680 // Sincronizar estado inicial desde popup si existe 681 readLastPopupProgress(); 682 683 // Auto-resume si venimos de cambio de idioma 684 checkAutoResume(); 685 686 } 687 } else { 688 setTimeout(initializeWXLI, 100); 689 } 690 } 691 692 function checkAutoResume() { 693 const resumeData = localStorage.getItem('wxli-resume-playback'); 694 if (resumeData) { 695 try { 696 const data = JSON.parse(resumeData); 697 const timeSinceNav = Date.now() - data.timestamp; 698 699 // Solo auto-resume si fue hace menos de 10 segundos (navegación reciente) 700 if (data.wasPlaying && timeSinceNav < 10000) { 701 // Marcar isRefreshingStream=true para que handlePlayResync no detenga HLS 702 // antes de que se cargue el manifest (la página recién fue cargada). 703 // Si se llama play() sin este flag, handlePlayResync harÃa hls.stopLoad() 704 // en el momento en que el manifest aún no terminó de cargarse, obligando 705 // a re-fetcharlo desde cero y causando latencia o fallo silencioso. 706 isRefreshingStream = true; 707 safePlay(wxliAudioElement, 'auto-resume') 708 .catch(function(err) { 709 }) 710 .finally(function() { 711 isRefreshingStream = false; 712 }); 713 } 714 } catch (e) { 715 } 716 717 // Limpiar el estado guardado 718 localStorage.removeItem('wxli-resume-playback'); 719 } 720 } 721 722 function setupHealthMonitor() { 723 // Actualizar timestamp cuando el audio está reproduciendo 724 // Importante: evitar añadir este listener múltiples veces si se re-inicializa el player. 725 if (!healthTimeupdateHandler) { 726 healthTimeupdateHandler = function() { 727 if (!wxliAudioElement.paused && !wxliAudioElement.ended) { 728 lastPlayingTime = Date.now(); 729 } 730 }; 731 } 732 if (!healthTimeupdateAdded) { 733 wxliAudioElement.addEventListener('timeupdate', healthTimeupdateHandler); 734 healthTimeupdateAdded = true; 735 } 736 737// Limpiar intervalo anterior si existe (evita duplicados) 738 if (healthCheckInterval) { 739 clearInterval(healthCheckInterval); 740 healthCheckInterval = null; 741 } 742 743 function startHealthCheck() { 744 if (healthCheckInterval) return; // Ya está corriendo 745 746 healthCheckInterval = setInterval(function() { 747 // No verificar si la página está oculta 748 if (document.hidden && !isAndroidDevice) return; 749 750 const timeSinceLastPlay = Date.now() - lastPlayingTime; 751 const isPlaying = !wxliAudioElement.paused && !wxliAudioElement.ended; 752 753 // Si deberÃa estar reproduciendo pero no ha actualizado en 20 segundos 754 if (isPlaying && timeSinceLastPlay > 40000 && wxliAudioElement.readyState < 2) { 755 756 // Intentar recuperar suavemente 757 if (hls) { 758 hls.startLoad(); 759 } else { 760 // DIRECT: recuperación suave primero (evita microcortes por reloads) 761 safePlay(wxliAudioElement, 'health-soft-recover').catch(() => {}); 762 } 763 764 lastPlayingTime = Date.now(); 765 } 766 }, 30000); 767 } 768 769 function stopHealthCheck() { 770 if (healthCheckInterval) { 771 clearInterval(healthCheckInterval); 772 healthCheckInterval = null; 773 } 774 } 775 776 // Iniciar el monitor 777 startHealthCheck(); 778 779 // Usar eventManager en vez de listener directo (evita duplicados)
780 eventManager.onVisibilityChange(function(isHidden) { 781 if (isHidden) { 782 // En Android NO paramos el health check en background (queremos recovery con pantalla apagada) 783 if (!isAndroidDevice) { 784 stopHealthCheck(); 785 } 786 } else { 787 startHealthCheck(); 788 } 789}, 1); // Prioridad 1 (alta) 790 } 791 792 function setupPlayPauseInterception() { 793 // Interceptar clicks en el botón de play/pause para marcar pausas manuales 794 const playButton = maudioContainer.querySelector('.audio-control .play'); 795 796 if (playButton) { 797 playButton.addEventListener('click', function() { 798 const audioBox = maudioContainer; 799 800 if (audioBox.classList.contains('playing')) { 801 // El usuario está pausando manualmente 802 isManualPause = true; 803 } else { 804 // El usuario está reproduciendo 805 isManualPause = false; 806 // NO recargar el stream - causa cortes innecesarios 807 // El audio continuará desde donde estaba o reconectará automáticamente si es necesario 808 } 809 }, true); // Usar capture phase para ejecutar antes que maudio 810 } 811 } 812 813 function setupSystemEventListeners() { 814 let wasPlayingBeforeHidden = false; 815 816 // Usar eventManager en vez de listener directo (evita duplicados) 817 eventManager.onVisibilityChange(function(isHidden) { 818 if (isHidden) { 819 // Pestaña oculta - guardar estado 820 wasPlayingBeforeHidden = !wxliAudioElement.paused; 821 } else { 822 // Pestaña visible de nuevo - reanudar si estaba reproduciendo 823 if (wasPlayingBeforeHidden && wxliAudioElement.paused) { 824 wxliAudioElement.play().catch(err => { 825 }); 826 } 827 // NOTA: El refresh del stream lo maneja handlePageResume 828 // NO hacer hls.startLoad() aquà para evitar cortes innecesarios 829 } 830 }, 5); // Prioridad 5 (media) 831 832 // Manejar pérdida y recuperación de conexión 833 window.addEventListener('online', function() { 834 if (hls) { 835 hls.startLoad(); 836 } else if (!wxliAudioElement.paused) { 837 // DIRECT: intentar reanudar suavemente sin recargar (evita microcortes) 838 safePlay(wxliAudioElement, 'online-soft-resume').catch(() => {}); 839 } 840 }); 841 842 window.addEventListener('offline', function() { 843 }); 844 845 // Listener para cambios de conexión (WiFi <-> Celular) CON DEBOUNCE 846 const networkConn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; 847 // Guardar "firma" de conexión para evitar falsos positivos al volver a foco 848 let lastConnSignature = null; 849 try { 850 if (networkConn) { 851 lastConnSignature = JSON.stringify({ 852 type: networkConn.type || null, 853 effectiveType: networkConn.effectiveType || null, 854 downlink: networkConn.downlink || null, 855 rtt: networkConn.rtt || null, 856 saveData: networkConn.saveData || null 857 }); 858 } 859 } catch (e) {} 860 if (networkConn) { 861 networkConn.addEventListener('change', function() { 862 const newType = networkConn.type || networkConn.effectiveType; 863 864 // Evitar microcortes: algunos browsers disparan "change" al volver a foco 865 // aunque no haya cambios reales de red. 866 let newSignature = null; 867 try { 868 newSignature = JSON.stringify({ 869 type: networkConn.type || null, 870 effectiveType: networkConn.effectiveType || null, 871 downlink: networkConn.downlink || null, 872 rtt: networkConn.rtt || null, 873 saveData: networkConn.saveData || null 874 }); 875 } catch (e) {} 876 877 // Si la firma es idéntica, no hacemos refresh del stream. 878 if (newSignature && lastConnSignature && newSignature === lastConnSignature) { 879 return; 880 } 881 882 lastConnSignature = newSignature; 883 884 // Usar debounce del eventManager para evitar múltiples recargas 885 eventManager.handleNetworkChange(function() { 886 // Si está reproduciendo, refrescar el stream para adaptarse 887 if (!wxliAudioElement.paused) { 888 889 if (hls) { 890 hls.stopLoad(); 891 setTimeout(function() { 892 if (hls) hls.startLoad(-1); 893 }, 300); // Reducido de 500ms 894 } else { 895 // DIRECT: NO refrescar el src por cambio de red (causa microcortes).
896 // Dejar que stallDetection/healthMonitor actúen solo si hay freeze real. 897 } 898} 899 }); 900 }); 901 } 902 903 // Prevenir que el navegador pause el audio en segundo plano 904 if ('mediaSession' in navigator) { 905 navigator.mediaSession.setActionHandler('play', function() { 906 wxliAudioElement.play().catch(err => { 907 }); 908 }); 909 910 navigator.mediaSession.setActionHandler('pause', function() { 911 wxliAudioElement.pause(); 912 }); 913 914 // Live stream: deshabilitar controles de navegación en Android Auto / CarPlay 915 // para que no aparezcan botones de skip/seek rotos en el head unit 916 try { navigator.mediaSession.setActionHandler('previoustrack', null); } catch(e) {} 917 try { navigator.mediaSession.setActionHandler('nexttrack', null); } catch(e) {} 918 try { navigator.mediaSession.setActionHandler('seekbackward', null); } catch(e) {} 919 try { navigator.mediaSession.setActionHandler('seekforward', null); } catch(e) {} 920 try { navigator.mediaSession.setActionHandler('stop', null); } catch(e) {} 921 } 922 923 // Update Media Session position state â feeds elapsed/duration to lock screen, 924 // CarPlay, Android Auto, etc. Must be called after metadata and on every resync. 925 function updateMediaSessionPosition(elapsed, duration) { 926 if (!('mediaSession' in navigator) || typeof navigator.mediaSession.setPositionState !== 'function') return; 927 if (!duration || duration <= 0 || elapsed < 0 || elapsed > duration) return; 928 try { 929 navigator.mediaSession.setPositionState({ 930 duration: duration, 931 playbackRate: 1.0, 932 position: elapsed 933 }); 934 } catch(e) {} 935 } 936 937 // Wake Lock API - Evita que Android entre en Doze mode mientras reproduce 938 let wakeLock = null; 939 940 async function requestWakeLock() { 941 if ('wakeLock' in navigator) { 942 try { 943 wakeLock = await navigator.wakeLock.request('screen'); 944 wakeLock.addEventListener('release', () => { 945 }); 946 } catch (err) { 947 } 948 } 949 } 950 951 function releaseWakeLock() { 952 if (wakeLock) { 953 wakeLock.release(); 954 wakeLock = null; 955 } 956 } 957 958 // WiFi Keepalive - mantiene la conexión WiFi activa durante reproducción 959 // WiFi Keepalive - CRÃTICO para Android con pantalla apagada 960 // Combate WiFi PSM (Power Save Mode) que causa microcortes 961 let wifiKeepAliveInterval = null; 962 let wifiKeepAliveEnabled = false; 963 964 function startWiFiKeepalive() { 965 const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; 966 const isCellular = conn && (conn.type === 'cellular' || conn.type === 'wimax'); 967 968 // Solo activar keepalive en móvil con WiFi 969 if (!isMobileDevice || isCellular) { 970 return; 971 } 972 973 wifiKeepAliveEnabled = true; 974 975 if (wifiKeepAliveInterval) { 976 clearInterval(wifiKeepAliveInterval); 977 } 978 979 980 // IMPORTANTE: En Android, el keepalive DEBE seguir funcionando con pantalla apagada 981 // Eso es exactamente cuando WiFi PSM causa problemas 982 // Intervalo más agresivo: 5 segundos (WiFi PSM se activa después de ~7s de inactividad) 983 wifiKeepAliveInterval = setInterval(function() { 984 // Solo verificar si el audio está pausado (NO verificar document.hidden en Android) 985 if (wxliAudioElement.paused) { 986 return; 987 } 988 989 // En iOS con pantalla apagada, el sistema maneja esto diferente - skip 990 if (isIOSDevice && document.hidden) { 991 return; 992 } 993 994 // Solicitud HEAD mÃnima - mantiene la conexión WiFi activa 995 fetch('https://streaming.hls.core.wxli.ai/live.m3u8', { 996 method: 'HEAD', 997 mode: 'no-cors', 998 cache: 'no-store' 999 }).catch(function() { 1000 // Ignorar errores 1001 }); 1002 }, 15000); // Menos agresivo: cada 15 segundos (reduce jitter/microcortes) 1003 } 1004 1005 function stopWiFiKeepalive() { 1006 wifiKeepAliveEnabled = false;
1007 if (wifiKeepAliveInterval) { 1008 clearInterval(wifiKeepAliveInterval); 1009 wifiKeepAliveInterval = null; 1010 } 1011 } 1012 1013 // En Android: NO pausar el keepalive cuando la pantalla se apaga 1014 // En iOS: Pausar porque iOS maneja el audio en background diferente 1015 eventManager.onVisibilityChange(function(isHidden) { 1016 if (isIOSDevice && isHidden) { 1017 // Solo pausar en iOS 1018 if (wifiKeepAliveInterval) { 1019 clearInterval(wifiKeepAliveInterval); 1020 wifiKeepAliveInterval = null; 1021 } 1022 } else if (isIOSDevice && !isHidden) { 1023 // Reanudar en iOS cuando vuelve 1024 if (wifiKeepAliveEnabled && !wifiKeepAliveInterval && !wxliAudioElement.paused) { 1025 startWiFiKeepalive(); 1026 } 1027 } 1028 // En Android: NO hacer nada - el keepalive sigue corriendo 1029 }, 2); 1030 1031 // Activar/desactivar Wake Lock según estado de reproducción 1032 wxliAudioElement.addEventListener('play', function() { 1033 requestWakeLock(); 1034 startWiFiKeepalive(); 1035 if ('mediaSession' in navigator) { 1036 navigator.mediaSession.playbackState = 'playing'; 1037 } 1038 1039 // OPTIMIZACIÃN MÃVIL: Pausar videos de fondo cuando el audio está reproduciendo 1040 // Esto ahorra baterÃa significativamente en dispositivos móviles 1041 if (isMobileDevice) { 1042 document.querySelectorAll('video.lazy-video.loaded, #banner-video.loaded').forEach(function(video) { 1043 if (!video.paused) { 1044 video.pause(); 1045 video.dataset.pausedByAudio = 'true'; 1046 } 1047 }); 1048 } 1049 }); 1050 1051 // Sincronización con popup player 1052 const mainPlayerInstanceId = 'main-' + Date.now(); // ID único para el player principal 1053 let ignoreMainSyncOnce = false; // Flag para evitar loops de sincronización 1054 1055 // Función para sincronizar estado del player con popup 1056 function syncPlayerState(playing) { 1057 if (!ignoreMainSyncOnce) { 1058 const playerState = { 1059 isPlaying: playing, 1060 timestamp: Date.now(), 1061 instanceId: mainPlayerInstanceId 1062 }; 1063 localStorage.setItem('wxli-player-state', JSON.stringify(playerState)); 1064 } else { 1065 ignoreMainSyncOnce = false; 1066 } 1067 } 1068 1069 // LISTENER ÃNICO DE PAUSE (consolida los 3 listeners anteriores para evitar race conditions) 1070 wxliAudioElement.addEventListener('pause', function() { 1071 // 1. PRIMERO: Guardar timestamp (necesario para handlePlayResync) 1072 lastPauseTime = Date.now(); 1073 1074 // 2. Release resources 1075 releaseWakeLock(); 1076 stopWiFiKeepalive(); 1077 1078 // 3. Actualizar mediaSession 1079 if ('mediaSession' in navigator) { 1080 navigator.mediaSession.playbackState = 'paused'; 1081 } 1082 1083 // 4. Sincronizar con popup 1084 syncPlayerState(false); 1085 1086 // 5. Auto-resume si fue pausa inesperada (no manual) 1087 if (!isManualPause && !wxliAudioElement.ended && document.visibilityState === 'visible') { 1088 setTimeout(() => { 1089 if (wxliAudioElement.paused && !wxliAudioElement.ended && !isManualPause) { 1090 wxliAudioElement.play().catch(err => { 1091 }); 1092 } 1093 }, 1000); 1094 } 1095 1096 // 6. OPTIMIZACIÃN MÃVIL: Reanudar videos de fondo 1097 if (isMobileDevice && document.visibilityState === 'visible') { 1098 document.querySelectorAll('video[data-paused-by-audio="true"]').forEach(function(video) { 1099 video.play().catch(function() {}); 1100 delete video.dataset.pausedByAudio; 1101 }); 1102 } 1103 }); 1104 1105 // LISTENER ÃNICO DE PLAY 1106 wxliAudioElement.addEventListener('play', function() { 1107 isManualPause = false; 1108 syncPlayerState(true); 1109 }); 1110 1111 // Re-adquirir Wake Lock si la página vuelve a ser visible (via eventManager)
1112 eventManager.onVisibilityChange(function(isHidden) { 1113 if (!isHidden && !wxliAudioElement.paused && !wakeLock) { 1114 requestWakeLock(); 1115 } 1116 }, 3); // Prioridad 3 1117 1118 // Escuchar cambios desde el popup 1119 window.addEventListener('storage', function(e) { 1120 if (e.key === 'wxli-player-state' && e.newValue) { 1121 try { 1122 const state = JSON.parse(e.newValue); 1123 1124 // Ignorar cambios de esta misma ventana 1125 if (state.instanceId === mainPlayerInstanceId) { 1126 return; 1127 } 1128 1129 const timeDiff = Date.now() - state.timestamp; 1130 1131 // Solo sincronizar si el cambio es reciente (menos de 2 segundos) 1132 if (timeDiff < 2000) { 1133 const isCurrentlyPlaying = !wxliAudioElement.paused; 1134 const isFromPopup = state.instanceId && state.instanceId.startsWith('popup-'); 1135 1136 if (state.isPlaying && isCurrentlyPlaying) { 1137 // Si es del popup, esperar señal 'wxli-popup-ready' para crossfade 1138 if (isFromPopup) { 1139 // No pausar aquà - el crossfade se hará cuando llegue 'wxli-popup-ready' 1140 } else { 1141 // Otro player (no popup) - pausar directamente 1142 ignoreMainSyncOnce = true; 1143 wxliAudioElement.pause(); 1144 isManualPause = true; 1145 } 1146 } else if (state.isPlaying && !isCurrentlyPlaying) { 1147 // El popup está reproduciendo y este está pausado - mantener pausado 1148 isManualPause = true; // Marcar como pausa manual 1149 } else if (!state.isPlaying && isCurrentlyPlaying) { 1150 // El popup pausó pero este sigue reproduciendo - no hacer nada 1151 } else if (!state.isPlaying && !isCurrentlyPlaying) { 1152 // Ambos están pausados - verificar si el popup se cerró para retomar reproducción 1153 } 1154 } 1155 } catch (err) { 1156 } 1157 } 1158 1159 // Escuchar cuando el popup se cierra 1160 if (e.key === 'wxli-popup-close-state' && e.newValue) { 1161 try { 1162 const closeState = JSON.parse(e.newValue); 1163 const timeDiff = Date.now() - closeState.timestamp; 1164 1165 // Solo actuar si el cierre fue reciente (menos de 2 segundos) 1166 if (timeDiff < 2000 && closeState.popupClosed && closeState.wasPlaying) { 1167 1168 // Limpiar el estado de cierre 1169 localStorage.removeItem('wxli-popup-close-state'); 1170 1171 // Retomar reproducción en el player principal después de un pequeño delay 1172 // Refrescar stream para sincronizar con live 1173 setTimeout(function() { 1174 if (wxliAudioElement.paused) { 1175 isManualPause = false; // Permitir reproducción 1176 refreshStreamAndPlay(); // Refrescar para estar en vivo 1177 } 1178 }, 300); 1179 } 1180 } catch (err) { 1181 } 1182 } 1183 1184 // Escuchar señal de popup listo para crossfade 1185 if (e.key === 'wxli-popup-ready' && e.newValue) { 1186 try { 1187 const popupTimestamp = parseInt(e.newValue, 10); 1188 const timeDiff = Date.now() - popupTimestamp; 1189 1190 1191 // Solo actuar si la señal es reciente (menos de 5 segundos) y main está reproduciendo 1192 if (timeDiff < 5000 && !wxliAudioElement.paused) { 1193 1194 // Limpiar la señal 1195 localStorage.removeItem('wxli-popup-ready'); 1196 1197 // Guardar volumen original para restaurarlo después 1198 const originalVolume = wxliAudioElement.volume; 1199 1200 // Detectar si el dispositivo soporta control de volumen programático 1201 // iOS no lo soporta - el volumen siempre es 1 1202 const testVolume = wxliAudioElement.volume; 1203 wxliAudioElement.volume = 0.5; 1204 const supportsVolumeControl = wxliAudioElement.volume === 0.5; 1205 wxliAudioElement.volume = testVolume; 1206 1207 if (supportsVolumeControl) { 1208 // Crossfade: bajar volumen gradualmente en 400ms 1209 const fadeDuration = 400; 1210 const fadeSteps = 20; 1211 const fadeInterval = fadeDuration / fadeSteps; 1212 const volumeStep = originalVolume / fadeSteps; 1213 let currentStep = 0; 1214 1215 const fadeOut = setInterval(function() { 1216 currentStep++; 1217 const newVolume = Math.max(0, originalVolume - (volumeStep * currentStep)); 1218 wxliAudioElement.volume = newVolume; 1219 1220 if (currentStep >= fadeSteps) { 1221 clearInterval(fadeOut); 1222 // Pausar y restaurar volumen 1223 ignoreMainSyncOnce = true; 1224 wxliAudioElement.pause(); 1225 wxliAudioElement.volume = originalVolume; 1226 isManualPause = true; 1227 } 1228 }, fadeInterval); 1229 } else { 1230 // Fallback para iOS: pausa directa (el popup ya está sonando) 1231 ignoreMainSyncOnce = true; 1232 wxliAudioElement.pause(); 1233 isManualPause = true; 1234 } 1235 } 1236 } catch (err) { 1237 } 1238 } 1239 }); 1240
1241 // Manejar canplaythrough para asegurar buffer suficiente 1242 wxliAudioElement.addEventListener('canplaythrough', function() { 1243 }); 1244 1245 // Agregar preload al elemento de audio 1246 wxliAudioElement.preload = 'none'; 1247 } 1248 1249 // ============================================================================ 1250 // OPTIMIZACIÃN DE BATERÃA PARA MÃVILES 1251 // ============================================================================ 1252 function setupBatteryOptimization() { 1253 if (!isMobileDevice) return; 1254 1255 // Usar Battery API si está disponible 1256 if ('getBattery' in navigator) { 1257 navigator.getBattery().then(function(battery) { 1258 let lowBatteryMode = false; 1259 1260 function checkBatteryStatus() { 1261 const isLowBattery = battery.level < 0.20 && !battery.charging; 1262 1263 if (isLowBattery && !lowBatteryMode) { 1264 lowBatteryMode = true; 1265 1266 // Pausar videos de fondo para ahorrar baterÃa 1267 document.querySelectorAll('video.lazy-video, #banner-video').forEach(function(video) { 1268 if (!video.paused) { 1269 video.pause(); 1270 video.dataset.pausedByBattery = 'true'; 1271 } 1272 }); 1273 1274 } else if (!isLowBattery && lowBatteryMode) { 1275 lowBatteryMode = false; 1276 1277 // Reanudar videos solo si el audio no está reproduciendo 1278 if (document.visibilityState === 'visible' && wxliAudioElement.paused) { 1279 document.querySelectorAll('video[data-paused-by-battery="true"]').forEach(function(video) { 1280 video.play().catch(function() {}); 1281 delete video.dataset.pausedByBattery; 1282 }); 1283 } 1284 } 1285 } 1286 1287 checkBatteryStatus(); 1288 battery.addEventListener('levelchange', checkBatteryStatus); 1289 battery.addEventListener('chargingchange', checkBatteryStatus); 1290 }).catch(function() {}); 1291 } 1292 1293 // Detectar modo de ahorro de datos 1294 if ('connection' in navigator && navigator.connection) { 1295 var connection = navigator.connection; 1296 if (connection.saveData) { 1297 document.querySelectorAll('video.lazy-video').forEach(function(video) { 1298 if (!video.paused) video.pause(); 1299 }); 1300 } 1301 } 1302 } 1303 1304 // Funciones compartidas para manejar likes (usadas tanto en player como en historial) 1305 function getLikedSongs() { 1306 const liked = localStorage.getItem('wxli-liked-songs'); 1307 return liked ? JSON.parse(liked) : []; 1308 } 1309 1310 function saveLikedSongs(likedSongs) { 1311 localStorage.setItem('wxli-liked-songs', JSON.stringify(likedSongs)); 1312 1313 // Disparar evento personalizado para sincronización en la misma pestaña 1314 window.dispatchEvent(new CustomEvent('wxli-likes-changed', { 1315 detail: { likedSongs: likedSongs } 1316 })); 1317 } 1318 1319 function isSongLiked(songId) { 1320 const likedSongs = getLikedSongs(); 1321 return likedSongs.includes(songId); 1322 } 1323 1324 function toggleLike(songId, songData = null) { 1325 let likedSongs = getLikedSongs(); 1326 const index = likedSongs.indexOf(songId); 1327 1328 if (index > -1) { 1329 // Remover like 1330 likedSongs.splice(index, 1); 1331 saveLikedSongs(likedSongs); 1332 1333 // PREPARACIÃN BACKEND: Si tienes una función para sync, descomentala: 1334 // sendLikeToBackend(songId, false); 1335 1336 return false; 1337 } else { 1338 // Agregar like 1339 likedSongs.push(songId); 1340 saveLikedSongs(likedSongs); 1341 1342 // PREPARACIÃN BACKEND: Guardar metadata adicional para futura sincronización 1343 if (songData) { 1344 saveSongMetadata(songId, songData); 1345 } 1346 1347 // PREPARACIÃN BACKEND: Si tienes una función para sync, descomentala: 1348 // sendLikeToBackend(songId, true, songData); 1349 1350 return true; 1351 } 1352 } 1353 1354 // PREPARACIÃN BACKEND: Guardar metadata de canciones likeadas 1355 function saveSongMetadata(songId, songData) { 1356 const metadata = localStorage.getItem('wxli-songs-metadata'); 1357 const metadataObj = metadata ? JSON.parse(metadata) : {}; 1358 1359 metadataObj[songId] = { 1360 title: songData.title || 'Unknown', 1361 artist: songData.artist || 'Unknown', 1362 art: songData.art || '', 1363 likedAt: Date.now() 1364 }; 1365 1366 localStorage.setItem('wxli-songs-metadata', JSON.stringify(metadataObj)); 1367 } 1368 1369 // PREPARACIÃN BACKEND: Función para enviar likes al servidor (implementar cuando tengas API) 1370 /* 1371 async function sendLikeToBackend(songId, isLiked, songData = null) { 1372 try { 1373 const response = await fetch('/api/likes', { 1374 method: 'POST', 1375 headers: { 'Content-Type': 'application/json' }, 1376 body: JSON.stringify({ 1377 songId: songId, 1378 action: isLiked ? 'like' : 'unlike', 1379 metadata: songData ? { 1380 title: songData.title, 1381 artist: songData.artist, 1382 art: songData.art 1383 } : null, 1384 timestamp: Date.now() 1385 }) 1386 }); 1387 1388 if (response.ok) { 1389 } 1390 } catch (error) { 1391 } 1392 } 1393 */ 1394 1395 // ============================================================================ 1396 // SINCRONIZACIÃN DE LIKES ENTRE PESTAÃAS Y ARCHIVOS 1397 // ============================================================================ 1398 1399 // Listener para cambios en otras pestañas/ventanas (via storage event) 1400 window.addEventListener('storage', function(e) {
1401 if (e.key === 'wxli-liked-songs' && e.newValue) { 1402 syncLikesUI(); 1403 } else if (e.key === 'wxli-songs-metadata' && e.newValue) { 1404 } else if (e.key === 'wxli-player-progress' && e.newValue) { 1405 // Sincronizar progreso desde el popup player 1406 try { 1407 const progressData = JSON.parse(e.newValue); 1408 // Solo aplicar si es reciente (menos de 2 segundos) 1409 const timeDiff = Date.now() - progressData.timestamp; 1410 if (timeDiff < 2000 && progressData.instanceId && progressData.instanceId.startsWith('popup-')) { 1411 syncProgressFromPopup(progressData); 1412 } 1413 } catch (err) { 1414 } 1415 } 1416 }); 1417 1418 // Listener para cambios en la misma pestaña (via custom event) 1419 window.addEventListener('wxli-likes-changed', function() { 1420 syncLikesUI(); 1421 }); 1422 1423 // Función para sincronizar la UI de todos los botones de like 1424 function syncLikesUI() { 1425 // Actualizar botón del reproductor principal 1426 const playerLikeBtn = document.getElementById('player-like-btn'); 1427 if (playerLikeBtn) { 1428 const songId = playerLikeBtn.getAttribute('data-song-id'); 1429 if (songId) { 1430 const isLiked = isSongLiked(songId); 1431 const icon = playerLikeBtn.querySelector('i'); 1432 1433 if (isLiked) { 1434 icon.classList.remove('far'); 1435 icon.classList.add('fas'); 1436 playerLikeBtn.classList.add('liked'); 1437 } else { 1438 icon.classList.remove('fas'); 1439 icon.classList.add('far'); 1440 playerLikeBtn.classList.remove('liked'); 1441 } 1442 } 1443 } 1444 1445 // Actualizar todos los botones del historial 1446 document.querySelectorAll('.bookmark-icon[data-song-id]').forEach(btn => { 1447 const songId = btn.getAttribute('data-song-id'); 1448 const isLiked = isSongLiked(songId); 1449 const icon = btn.querySelector('i'); 1450 1451 if (isLiked) { 1452 icon.classList.remove('far'); 1453 icon.classList.add('fas'); 1454 btn.classList.add('liked'); 1455 } else { 1456 icon.classList.remove('fas'); 1457 icon.classList.add('far'); 1458 btn.classList.remove('liked'); 1459 } 1460 }); 1461 1462 } 1463 1464 function setupPlayerLikeButton() { 1465 const playerLikeBtn = document.getElementById('player-like-btn'); 1466 1467 if (!playerLikeBtn) return; 1468 1469 playerLikeBtn.addEventListener('click', function() { 1470 const songId = this.getAttribute('data-song-id'); 1471 1472 if (!songId || songId === '') { 1473 // No hay canción para dar like 1474 return; 1475 } 1476 1477 // Pasar metadata de la canción actual al toggle 1478 const isLiked = toggleLike(songId, currentPlayerSongData); 1479 const icon = this.querySelector('i'); 1480 1481 // Actualizar icono del reproductor principal 1482 if (isLiked) { 1483 icon.classList.remove('far'); 1484 icon.classList.add('fas'); 1485 this.classList.add('liked'); 1486 } else { 1487 icon.classList.remove('fas'); 1488 icon.classList.add('far'); 1489 this.classList.remove('liked'); 1490 } 1491 1492 // Animación de pulso 1493 this.style.transform = 'scale(1.3)'; 1494 setTimeout(() => { 1495 this.style.transform = 'scale(1)'; 1496 }, 200); 1497 1498 // Actualizar el botón correspondiente en el historial si existe 1499 updateHistoryLikeButton(songId, isLiked); 1500 }); 1501 } 1502 1503 function updateHistoryLikeButton(songId, isLiked) { 1504 // Buscar el botón en el historial y actualizarlo 1505 const historyBtn = document.querySelector(`.bookmark-icon[data-song-id="${songId}"]`); 1506 if (historyBtn) { 1507 const icon = historyBtn.querySelector('i'); 1508 if (isLiked) { 1509 icon.classList.remove('far'); 1510 icon.classList.add('fas'); 1511 historyBtn.classList.add('liked'); 1512 } else { 1513 icon.classList.remove('fas'); 1514 icon.classList.add('far'); 1515 historyBtn.classList.remove('liked'); 1516 } 1517 } 1518 } 1519 1520 function updatePlayerLikeButton(songId) { 1521 const playerLikeBtn = document.getElementById('player-like-btn'); 1522 if (!playerLikeBtn) return; 1523 1524 // Crear ID único para la canción (compatibilidad con formato del historial) 1525 const cleanSongId = songId || `unknown-${Date.now()}`; 1526 1527 // Actualizar el data-song-id del botón 1528 playerLikeBtn.setAttribute('data-song-id', cleanSongId); 1529 1530 // Verificar si la canción ya tiene like 1531 const isLiked = isSongLiked(cleanSongId); 1532 const icon = playerLikeBtn.querySelector('i'); 1533 1534 if (isLiked) { 1535 icon.classList.remove('far'); 1536 icon.classList.add('fas'); 1537 playerLikeBtn.classList.add('liked'); 1538 } else { 1539 icon.classList.remove('fas'); 1540 icon.classList.add('far'); 1541 playerLikeBtn.classList.remove('liked'); 1542 } 1543 } 1544 1545 function setupVolumeControls() { 1546 const muteBtn = document.getElementById('mute-btn'); 1547 const volumeSlider = document.getElementById('volume-slider'); 1548 1549 if (!wxliAudioElement || !muteBtn || !volumeSlider) return; 1550 1551 // Restaurar volumen guardado o usar 100% 1552 const savedVolume = localStorage.getItem('wxli-volume'); 1553 if (savedVolume) { 1554 wxliAudioElement.volume = parseFloat(savedVolume); 1555 volumeSlider.value = parseFloat(savedVolume) * 100; 1556 } 1557 1558 // Control del slider de volumen 1559 volumeSlider.addEventListener('input', function() { 1560 const volume = this.value / 100; 1561 wxliAudioElement.volume = volume; 1562 localStorage.setItem('wxli-volume', volume); 1563 updateVolumeIcon(volume); 1564 }); 1565 1566 // Botón de mute/unmute 1567 muteBtn.addEventListener('click', function() { 1568 if (wxliAudioElement.muted) { 1569 wxliAudioElement.muted = false;
1570 updateVolumeIcon(wxliAudioElement.volume); 1571 } else { 1572 wxliAudioElement.muted = true; 1573 updateVolumeIcon(0); 1574 } 1575 }); 1576 1577 // Actualizar icono según volumen 1578 function updateVolumeIcon(volume) { 1579 const icon = muteBtn.querySelector('i'); 1580 if (volume === 0 || wxliAudioElement.muted) { 1581 icon.className = 'fas fa-volume-mute'; 1582 } else if (volume < 0.5) { 1583 icon.className = 'fas fa-volume-down'; 1584 } else { 1585 icon.className = 'fas fa-volume-up'; 1586 } 1587 } 1588 1589 // Inicializar icono 1590 updateVolumeIcon(wxliAudioElement.volume); 1591 } 1592 1593 function setupPopupPlayer() { 1594 const popupBtn = document.getElementById('popup-player-btn'); 1595 1596 if (!popupBtn) return; 1597 1598 popupBtn.addEventListener('click', function() { 1599 // Tamaño equivalente a la pantalla lógica del iPhone SE (375 à 667 px) 1600 const popupWidth = 335; 1601 const popupHeight = 617; 1602 1603 // Calcular posición centrada 1604 const left = (screen.width / 2) - (popupWidth / 2); 1605 const top = (screen.height / 2) - (popupHeight / 2); 1606 1607 const popupFeatures = `width=${popupWidth},height=${popupHeight},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=no,scrollbars=no`; 1608 1609 const popup = window.open('https://live.wxli.ai', 'WXLI Live', popupFeatures); 1610 1611 if (popup) { 1612 popup.focus(); 1613 } else { 1614 alert('Por favor, permite las ventanas emergentes para usar el popup player.'); 1615 } 1616 }); 1617 } 1618 1619 // Función global para abrir popup y navegar (usado por enlaces que quieren mantener el audio) 1620 window.wxliNavigateWithPopup = function(destinationUrl) { 1621 // Si el audio está reproduciéndose, abrir popup primero 1622 if (wxliAudioElement && !wxliAudioElement.paused) { 1623 1624 // Definir tamaño del popup 1625 const popupWidth = 400; 1626 const popupHeight = 370; 1627 const left = (screen.width / 2) - (popupWidth / 2); 1628 const top = (screen.height / 2) - (popupHeight / 2); 1629 1630 const popupFeatures = `width=${popupWidth},height=${popupHeight},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=no,scrollbars=no`; 1631 1632 // Abrir popup 1633 const popup = window.open('/player', 'WXLI Player', popupFeatures); 1634 1635 if (popup) { 1636 popup.focus(); 1637 // Pequeño delay para que el popup inicie antes de navegar 1638 setTimeout(function() { 1639 window.location.href = destinationUrl; 1640 }, 300); 1641 } else { 1642 // Si el popup fue bloqueado, guardar estado y navegar de todas formas 1643 if (typeof wxliSavePlaybackState === 'function') wxliSavePlaybackState(); 1644 window.location.href = destinationUrl; 1645 } 1646 } else { 1647 // Si no está reproduciendo, navegar directamente 1648 window.location.href = destinationUrl; 1649 } 1650 }; 1651 1652 // Obtener URL del stream actual (primary o fallback) 1653 function getCurrentStreamUrl() { 1654 if (currentStreamIndex === -1) { 1655 return { url: STREAM_CONFIG.primary, type: STREAM_CONFIG.type }; 1656 } 1657 const fallback = STREAM_CONFIG.fallbacks[currentStreamIndex]; 1658 return fallback || { url: STREAM_CONFIG.primary, type: STREAM_CONFIG.type }; 1659 } 1660 1661 // Guard para evitar múltiples fallbacks simultáneos 1662 let isFallingBack = false; 1663 let primaryRecoveryInterval = null; 1664 let primaryRetryBackoff = 5000; // Backoff inicial 5s, dobla hasta 60s 1665 1666 // Probe periódico del primary mientras estamos en fallback 1667 function startPrimaryRecovery() { 1668 if (primaryRecoveryInterval) return; 1669 primaryRetryBackoff = 5000; 1670 primaryRecoveryInterval = setInterval(async () => { 1671 if (currentStreamIndex === -1) { stopPrimaryRecovery(); return; } 1672 try { 1673 const ctrl = new AbortController(); 1674 const tid = setTimeout(() => ctrl.abort(), 5000); 1675 const res = await fetch(STREAM_CONFIG.primary, { method: 'HEAD', signal: ctrl.signal }); 1676 clearTimeout(tid); 1677 if (res.ok) { 1678 stopPrimaryRecovery(); 1679 if (isFallingBack) return; 1680 currentStreamIndex = -1; 1681 setupStream(); 1682 setTimeout(() => safePlay(wxliAudioElement, 'primary-recovery').catch(() => {}), 1500); 1683 } 1684 } catch (e) { /* primary sigue caÃdo */ } 1685 }, 60000); 1686 } 1687 1688 function stopPrimaryRecovery() { 1689 if (primaryRecoveryInterval) { 1690 clearInterval(primaryRecoveryInterval); 1691 primaryRecoveryInterval = null; 1692 } 1693 } 1694 1695 // Intentar siguiente fallback 1696 function tryNextFallback() {
1697 // Evitar que múltiples handlers disparen fallback simultáneamente 1698 if (isFallingBack) return true; // true = ya se está manejando 1699 1700 if (currentStreamIndex < STREAM_CONFIG.fallbacks.length - 1) { 1701 isFallingBack = true; 1702 currentStreamIndex++; 1703 setupStream(); 1704 startPrimaryRecovery(); 1705 // Liberar guard cuando el manifest confirma carga (+ safety timeout) 1706 const manifestTimeout = setTimeout(() => { isFallingBack = false; }, 10000); 1707 if (hls) { 1708 hls.once(Hls.Events.MANIFEST_PARSED, () => { 1709 clearTimeout(manifestTimeout); 1710 safePlay(wxliAudioElement, 'fallback-autoplay').then(() => { 1711 }).catch(err => { 1712 }); 1713 isFallingBack = false; 1714 }); 1715 } else { 1716 // Safari HLS nativo 1717 clearTimeout(manifestTimeout); 1718 setTimeout(() => { isFallingBack = false; }, 2000); 1719 } 1720 return true; 1721 } 1722 // Sin más fallbacks: probe al primary con backoff exponencial antes de reintentar 1723 isFallingBack = true; 1724 const delay = primaryRetryBackoff; 1725 primaryRetryBackoff = Math.min(primaryRetryBackoff * 2, 60000); 1726 setTimeout(async () => { 1727 let primaryAvailable = false; 1728 try { 1729 const ctrl = new AbortController(); 1730 const tid = setTimeout(() => ctrl.abort(), 5000); 1731 const res = await fetch(STREAM_CONFIG.primary, { method: 'HEAD', signal: ctrl.signal }); 1732 clearTimeout(tid); 1733 primaryAvailable = res.ok; 1734 } catch (e) {} 1735 1736 if (primaryAvailable) { 1737 stopPrimaryRecovery(); 1738 currentStreamIndex = -1; 1739 primaryRetryBackoff = 5000; 1740 setupStream(); 1741 setTimeout(() => { 1742 safePlay(wxliAudioElement, 'primary-retry').catch(() => {}); 1743 isFallingBack = false; 1744 }, 1500); 1745 } else { 1746 setupStream(); 1747 const manifestTimeout = setTimeout(() => { isFallingBack = false; }, 10000); 1748 if (hls) { 1749 hls.once(Hls.Events.MANIFEST_PARSED, () => { 1750 clearTimeout(manifestTimeout); 1751 safePlay(wxliAudioElement, 'fallback-retry').catch(() => {}); 1752 isFallingBack = false; 1753 }); 1754 } else { 1755 clearTimeout(manifestTimeout); 1756 setTimeout(() => { isFallingBack = false; }, 2000); 1757 } 1758 } 1759 }, delay); 1760 return true; 1761 } 1762 1763 // Fetches an HLS master playlist and invokes callback with the URL of the 1764 // highest-bitrate variant. Falls back to the original URL on any error. 1765 // Only called from the iOS native-HLS path (Hls.isSupported() === false), 1766 // so it never runs on Chrome, Firefox, Edge, or Android. 1767 var _variantResolveGen = 0; 1768 function resolveHighestHLSVariant(masterUrl, callback) { 1769 var myGen = ++_variantResolveGen; 1770 1771 // Safety: fetch is undefined on iOS < 10.3 (extremely rare today) 1772 if (typeof fetch === 'undefined') { 1773 callback(masterUrl); 1774 return; 1775 } 1776 1777 fetch(masterUrl) 1778 .then(function(r) { return r.text(); }) 1779 .then(function(text) { 1780 if (myGen !== _variantResolveGen) return; // stale â a newer call already ran 1781 var lines = text.split('\n'); 1782 var highestBw = 0; 1783 var highestUrl = null; 1784 for (var i = 0; i < lines.length; i++) { 1785 if (lines[i].indexOf('#EXT-X-STREAM-INF') === 0) { 1786 var bwMatch = lines[i].match(/BANDWIDTH=(\d+)/); 1787 if (bwMatch) { 1788 var bw = parseInt(bwMatch[1], 10); 1789 var next = (lines[i + 1] || '').trim(); 1790 if (bw > highestBw && next && next.charAt(0) !== '#') { 1791 highestBw = bw; 1792 highestUrl = next.indexOf('http') === 0 1793 ? next 1794 : new URL(next, masterUrl).href; 1795 } 1796 } 1797 } 1798 } 1799 callback(highestUrl || masterUrl); 1800 }) 1801 .catch(function(err) { 1802 if (myGen !== _variantResolveGen) return; 1803 callback(masterUrl); 1804 }); 1805 } 1806 1807 // Configurar stream (directo o HLS) 1808 function setupStream() { 1809 const { url: streamUrl, type: streamType } = getCurrentStreamUrl(); 1810 1811 // Destruir instancia HLS previa si existe 1812 if (hls) { 1813 hls.destroy(); 1814 hls = null; 1815 } 1816 1817 // Limpiar listeners del stream anterior para evitar interferencias 1818 if (playResyncListenerAdded) { 1819 wxliAudioElement.removeEventListener('play', handlePlayResync); 1820 playResyncListenerAdded = false;
1821 } 1822 wxliAudioElement.onerror = null; 1823 wxliAudioElement.oncanplay = null; 1824 isRefreshingStream = false; 1825 1826 // Stream directo (MP3/AAC) - no requiere HLS.js 1827 if (streamType === 'direct') { 1828 setupDirectStream(streamUrl); 1829 } else { 1830 // Stream HLS 1831 setupHLSStream(streamUrl); 1832 } 1833 } 1834 1835 // Refrescar stream y reproducir (para sincronización entre players) 1836 function refreshStreamAndPlay() { 1837 1838 if (hls) { 1839 // HLS.js activo: reiniciar desde live edge sin asignar audio.src directamente 1840 // (asignar m3u8 a audio.src causa error "Content-Type not supported" en Firefox) 1841 hls.stopLoad(); 1842 setTimeout(function() { 1843 if (hls) { 1844 hls.startLoad(-1); 1845 } 1846 }, 100); 1847 } else { 1848 // Stream directo (MP3/AAC): cache buster para forzar stream fresco 1849 const { url: streamUrl } = getCurrentStreamUrl(); 1850 var freshUrl = streamUrl + '?_t=' + Date.now(); 1851 isRefreshingStream = true; 1852 safeLoadAndPlay(wxliAudioElement, freshUrl, 'refreshStreamAndPlay') 1853 .finally(function() { 1854 isRefreshingStream = false; 1855 }); 1856 } 1857 } 1858 1859 // Configurar stream directo (MP3/AAC) 1860 function setupDirectStream(streamUrl) { 1861 // SIEMPRE usar cache buster para evitar buffers antiguos 1862 const freshUrl = streamUrl + '?_t=' + Date.now(); 1863 wxliAudioElement.src = freshUrl; 1864 wxliAudioElement.preload = 'none'; 1865 1866 // Usar handlePlayResync unificado para refresh después de pausa 1867 // (maneja tanto HLS como stream directo) 1868 if (!playResyncListenerAdded) { 1869 wxliAudioElement.addEventListener('play', handlePlayResync); 1870 playResyncListenerAdded = true; 1871 } 1872 1873 // Manejar errores del stream directo â fallback inmediato 1874 const handleStreamError = function() { 1875 if (!tryNextFallback()) { 1876 setTimeout(() => setupStream(), 5000); 1877 } 1878 }; 1879 1880 // Configurar event listeners 1881 wxliAudioElement.onerror = handleStreamError; 1882 1883 } 1884 1885 // Variables para resync de live edge (fuera de setupHLSStream para evitar duplicados) 1886 let lastResyncTime = 0; 1887 let playResyncListenerAdded = false; 1888 let isRefreshingStream = false; // Flag para evitar loop infinito durante refresh 1889 1890 // Flag para evitar listeners duplicados en setupStream 1891 let streamListenersAdded = false; 1892 1893 // Handler nombrado para resync en play (evita listeners duplicados) 1894 // MUY AGRESIVO: garantiza stream fresco después de cualquier pausa 1895 // Funciona tanto para HLS como para stream directo 1896 let hasEverPlayed = false; // Flag para detectar primer play 1897 1898 function handlePlayResync() { 1899 // Evitar ejecución durante fallback o refresh 1900 if (isRefreshingStream || isFallingBack) return; 1901 1902 const now = Date.now(); 1903 if (now - lastResyncTime < 2000) return; // Debounce de 2s 1904 1905 const timeSincePause = lastPauseTime > 0 ? now - lastPauseTime : Infinity; 1906 const isFirstPlay = !hasEverPlayed; 1907 // DIRECT: ser conservador (refrescar solo tras idle realmente largo) 1908 const pauseWasLong = timeSincePause > 240000; // 4 minutos 1909 1910 // Marcar que ya se ha reproducido al menos una vez 1911 hasEverPlayed = true; 1912 1913 // SIEMPRE refrescar si: 1914 // 1. Es el primer play (stream puede tener buffer viejo del servidor) 1915 // 2. La pausa fue mayor a 5 segundos 1916 // 3. timeSincePause es Infinity (nunca se pausó pero puede tener buffer viejo) 1917 // Detectar si venimos de un cambio de pestaña (resume reciente) 1918 const cameFromHidden = pageHiddenTime > 0 && (now - pageHiddenTime < 10000); 1919 1920 // Refrescar si: 1921 // 1) primer play 1922 // 2) pausa muy larga (pero no solo por volver de pestaña) 1923 // 3) timeSincePause inválido 1924 const shouldRefresh = isFirstPlay || (pauseWasLong && !cameFromHidden) || timeSincePause === Infinity; 1925 1926 if (shouldRefresh) { 1927 lastResyncTime = now; 1928 1929 if (hls) { 1930 // HLS: reiniciar desde live edge 1931 hls.stopLoad(); 1932 setTimeout(function() { 1933 if (hls) { 1934 hls.startLoad(-1); 1935 } 1936 }, 100); 1937 } else { 1938 // Stream directo: usar safeLoadAndPlay para manejar Firefox correctamente 1939 isRefreshingStream = true; 1940 const { url: streamUrl } = getCurrentStreamUrl(); 1941 const freshUrl = streamUrl + '?_t=' + now; 1942 1943 safeLoadAndPlay(wxliAudioElement, freshUrl, 'handlePlayResync') 1944 .finally(function() { 1945 isRefreshingStream = false;
1946 }); 1947 } 1948 1949 // Forzar actualización de metadata 1950 if (updateNowPlayingRef) { 1951 setTimeout(updateNowPlayingRef, 3000); 1952 } 1953 return; 1954 } 1955 1956 // Resync normal: saltar al live edge SOLO si estamos en HLS (o HLS nativo) 1957 // En streams directos (MP3/AAC) esto puede causar microcortes al volver a foco. 1958 const isLikelyHls = 1959 !!hls || 1960 (typeof wxliAudioElement.currentSrc === 'string' && wxliAudioElement.currentSrc.includes('.m3u8')); 1961 1962 if (isLikelyHls && wxliAudioElement.buffered.length > 0) { 1963 const bufferEnd = wxliAudioElement.buffered.end(wxliAudioElement.buffered.length - 1); 1964 const currentTime = wxliAudioElement.currentTime; 1965 const lag = bufferEnd - currentTime; 1966 1967 // Threshold: 5 segundos 1968 if (lag > 5) { 1969 lastResyncTime = now; 1970 const targetTime = bufferEnd - 1; 1971 wxliAudioElement.currentTime = targetTime; 1972 1973 if (updateNowPlayingRef) { 1974 setTimeout(updateNowPlayingRef, 3000); 1975 } 1976 } 1977 } 1978 } 1979 1980 // Configurar stream HLS (para fallbacks HLS si se agregan) 1981 function setupHLSStream(streamUrl) { 1982 let networkErrorCount = 0; 1983 let mediaErrorCount = 0; 1984 const maxRetries = 5; 1985 1986 if (typeof Hls !== 'undefined' && Hls.isSupported()) { 1987 // Detectar si es dispositivo móvil o conexión lenta 1988 const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); 1989 const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; 1990 const isSlowConnection = conn && 1991 (conn.effectiveType === '2g' || 1992 conn.effectiveType === 'slow-2g' || 1993 conn.saveData === true); 1994
1995 // Detectar si está en WiFi (vs celular) 1996 // WiFi PSM causa microcortes en idle, necesita buffer más grande 1997 const isWiFi = conn && (conn.type === 'wifi' || conn.type === 'ethernet'); 1998 const isCellular = conn && (conn.type === 'cellular' || conn.type === 'wimax'); 1999 // Si no podemos detectar el tipo, asumir WiFi en móvil por precaución 2000 const assumeWiFi = isMobile && !isCellular; 2001 const useWiFiConfig = isWiFi || assumeWiFi; 2002 2003 // Configuración para móvil en WiFi - buffer grande para sobrevivir WiFi PSM en idle 2004 const mobileWiFiConfig = { 2005 lowLatencyMode: false, 2006 backBufferLength: 120, // 2 minutos de back buffer 2007 maxBufferLength: 120, // 2 minutos de buffer adelante 2008 maxMaxBufferLength: 180, // Hasta 3 minutos máximo 2009 maxBufferSize: 120 * 1000 * 1000, 2010 liveSyncDurationCount: 5, // Más tolerante con lag 2011 liveMaxLatencyDurationCount: 10, 2012 fragLoadingTimeOut: 40000, // 40s timeout para fragmentos 2013 }; 2014 2015 // Configuración para móvil en celular (5G/LTE) - funciona bien con menos buffer 2016 const mobileCellularConfig = { 2017 lowLatencyMode: false, 2018 backBufferLength: 60, 2019 maxBufferLength: 60, 2020 maxMaxBufferLength: 120, 2021 maxBufferSize: 60 * 1000 * 1000, 2022 liveSyncDurationCount: 4, 2023 liveMaxLatencyDurationCount: 8, 2024 fragLoadingTimeOut: 30000, 2025 }; 2026 2027 // Configuración estándar para desktop 2028 const desktopConfig = { 2029 lowLatencyMode: true, 2030 backBufferLength: 30, 2031 maxBufferLength: 30, 2032 maxMaxBufferLength: 60, 2033 maxBufferSize: 30 * 1000 * 1000, 2034 liveSyncDurationCount: 3, 2035 liveMaxLatencyDurationCount: 5, 2036 fragLoadingTimeOut: 20000, 2037 }; 2038 2039 // Seleccionar configuración según plataforma y tipo de conexión 2040 let platformConfig; 2041 if (!isMobile) { 2042 platformConfig = desktopConfig; 2043 } else if (isSlowConnection) { 2044 platformConfig = mobileWiFiConfig; // Conexión lenta = buffer grande 2045 } else if (useWiFiConfig) { 2046 platformConfig = mobileWiFiConfig; 2047 } else { 2048 platformConfig = mobileCellularConfig; 2049 } 2050 2051 hls = new Hls({ 2052 debug: false, 2053 enableWorker: true, 2054 lowLatencyMode: platformConfig.lowLatencyMode, 2055 backBufferLength: platformConfig.backBufferLength, 2056 maxBufferLength: platformConfig.maxBufferLength, 2057 maxMaxBufferLength: platformConfig.maxMaxBufferLength, 2058 maxBufferSize: platformConfig.maxBufferSize, 2059 maxBufferHole: 1.0, // Tolerante con huecos para redes inestables 2060 highBufferWatchdogPeriod: 2, // Menos agresivo 2061 nudgeOffset: 0.1, 2062 nudgeMaxRetry: 5, 2063 manifestLoadingTimeOut: 10000, 2064 manifestLoadingMaxRetry: 6, 2065 manifestLoadingMaxRetryTimeout: 32000, 2066 levelLoadingTimeOut: 10000, 2067 levelLoadingMaxRetry: 6, 2068 levelLoadingMaxRetryTimeout: 32000, 2069 fragLoadingTimeOut: platformConfig.fragLoadingTimeOut, 2070 fragLoadingMaxRetry: 6, 2071 fragLoadingMaxRetryTimeout: 32000, 2072 startFragPrefetch: true, 2073 testBandwidth: true, 2074 progressive: true, 2075 abrEwmaDefaultEstimate: 1500000, 2076 abrBandWidthFactor: 0.85, 2077 abrBandWidthUpFactor: 0.90, 2078 liveSyncDurationCount: platformConfig.liveSyncDurationCount, 2079 liveMaxLatencyDurationCount: platformConfig.liveMaxLatencyDurationCount, 2080 liveSyncOnStallIncrementCount: 1 2081 }); 2082 2083 // Log de configuración utilizada 2084 if (isMobile) { 2085 const connType = isWiFi ? 'WiFi' : (isCellular ? 'Cellular' : 'Unknown'); 2086 const configName = useWiFiConfig ? 'WiFi (buffer grande)' : 'Cellular'; 2087 } else { 2088 } 2089 2090 hls.loadSource(streamUrl); 2091 hls.attachMedia(wxliAudioElement); 2092 2093 hls.on(Hls.Events.ERROR, function(_event, data) { 2094 if (data.fatal) { 2095 switch(data.type) {
2096 case Hls.ErrorTypes.NETWORK_ERROR: 2097 networkErrorCount++; 2098 if (networkErrorCount < maxRetries) { 2099 setTimeout(() => { 2100 if (hls) { 2101 hls.startLoad(); 2102 } 2103 }, Math.min(1000 * Math.pow(2, networkErrorCount - 1), 8000)); 2104 2105 } else { 2106 if (hls) { 2107 hls.destroy(); 2108 hls = null; 2109 } 2110 if (!tryNextFallback()) { 2111 setTimeout(() => { 2112 networkErrorCount = 0; 2113 setupStream(); 2114 }, 3000); 2115 } 2116 } 2117 break; 2118 case Hls.ErrorTypes.MEDIA_ERROR: 2119 mediaErrorCount++; 2120 if (mediaErrorCount < maxRetries) { 2121 if (hls) { 2122 hls.recoverMediaError(); 2123 } 2124 } else { 2125 if (hls) { 2126 hls.destroy(); 2127 hls = null; 2128 } 2129 if (!tryNextFallback()) { 2130 setTimeout(() => { 2131 mediaErrorCount = 0; 2132 setupStream(); 2133 }, 2000); 2134 } 2135 } 2136 break; 2137 default: 2138 if (hls) { 2139 hls.destroy(); 2140 hls = null; 2141 } 2142 if (!tryNextFallback()) { 2143 setTimeout(() => { 2144 networkErrorCount = 0; 2145 mediaErrorCount = 0; 2146 setupStream(); 2147 }, 3000); 2148 } 2149 break; 2150 } 2151 } else { 2152 // Errores no fatales - solo registrar 2153 if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { 2154 if (data.response && data.response.code === 0) { 2155 } 2156 } 2157 } 2158 }); 2159 2160 // Resetear contadores de error cuando la carga es exitosa 2161 hls.on(Hls.Events.MANIFEST_PARSED, function() { 2162 networkErrorCount = 0; 2163 mediaErrorCount = 0; 2164 }); 2165 2166 // Resincronizar al live edge cuando se reanuda la reproducción después de una pausa 2167 // Usar handler nombrado y flag para evitar listeners duplicados 2168 if (!playResyncListenerAdded) { 2169 wxliAudioElement.addEventListener('play', handlePlayResync); 2170 playResyncListenerAdded = true; 2171 } 2172 2173 window.hlsInstance = hls; 2174 } else if (wxliAudioElement.canPlayType('application/vnd.apple.mpegurl')) { 2175 // preload='metadata' lets Safari probe bandwidth before play, 2176 // so its internal ABR starts with enough data to pick a high-quality level. 2177 wxliAudioElement.preload = 'metadata'; 2178 2179 // Register the resync listener synchronously â before the async fetch â 2180 // so a rapid second setupHLSStream call can't create a gap. 2181 if (!playResyncListenerAdded) { 2182 wxliAudioElement.addEventListener('play', handlePlayResync); 2183 playResyncListenerAdded = true; 2184 } 2185 2186 // Fetch the master playlist and assign the highest-bitrate variant directly. 2187 // Safari then has no lower level to fall back to, so it plays at full quality. 2188 resolveHighestHLSVariant(streamUrl, function(resolvedUrl) { 2189 wxliAudioElement.src = resolvedUrl; 2190 }); 2191 } 2192 2193 // Agregar event listeners solo una vez (evita acumulación de listeners) 2194 if (!streamListenersAdded) { 2195 streamListenersAdded = true; 2196 2197 // Variables para debouncing del waiting handler 2198 let waitingTimeout = null; 2199 let waitingCount = 0; 2200 let lastWaitingTime = 0; 2201 2202 wxliAudioElement.addEventListener('error', function(e) { 2203 setTimeout(() => { 2204 if (wxliAudioElement.error) { 2205 wxliAudioElement.load(); 2206 } 2207 }, 3000); 2208 }); 2209 2210 wxliAudioElement.addEventListener('waiting', function() { 2211 const now = Date.now(); 2212 2213 if (now - lastWaitingTime < 2000) { 2214 return; 2215 } 2216
2217 lastWaitingTime = now; 2218 2219 if (waitingTimeout) { 2220 clearTimeout(waitingTimeout); 2221 } 2222 2223 waitingTimeout = setTimeout(() => { 2224 if (!wxliAudioElement.paused && wxliAudioElement.readyState < 3) { 2225 waitingCount++; 2226 2227 if (hls && waitingCount < 3) { 2228 hls.startLoad(); 2229 } else if (waitingCount >= 3) { 2230 if (hls) { 2231 hls.destroy(); 2232 hls = null; 2233 } 2234 waitingCount = 0; 2235 setTimeout(() => setupStream(), 1500); 2236 } 2237 } 2238 }, 5000); 2239 }); 2240 2241 let suspendTimeout = null; 2242 let stalledCount = 0; 2243 2244 wxliAudioElement.addEventListener('playing', function() { 2245 if (waitingTimeout) { 2246 clearTimeout(waitingTimeout); 2247 waitingTimeout = null; 2248 } 2249 if (suspendTimeout) { 2250 clearTimeout(suspendTimeout); 2251 suspendTimeout = null; 2252 } 2253 waitingCount = 0; 2254 stalledCount = 0; 2255 }); 2256 2257 // Handler para stalled - cuando el navegador detiene la descarga 2258 wxliAudioElement.addEventListener('stalled', function() { 2259 stalledCount++; 2260 2261 if (stalledCount >= 2) { 2262 if (hls) { 2263 hls.startLoad(); 2264 } else if (!wxliAudioElement.paused) { 2265 const currentSrc = wxliAudioElement.src; 2266 if (currentSrc) { 2267 const baseUrl = currentSrc.split('?')[0]; 2268 wxliAudioElement.src = baseUrl + '?_t=' + Date.now(); 2269 wxliAudioElement.load(); 2270 wxliAudioElement.play().catch(function() {}); 2271 } 2272 } 2273 stalledCount = 0; 2274 } 2275 }); 2276 2277 // Handler para suspend con reconexión activa 2278 wxliAudioElement.addEventListener('suspend', function() { 2279 2280 if (!wxliAudioElement.paused) { 2281 if (suspendTimeout) clearTimeout(suspendTimeout); 2282 2283 suspendTimeout = setTimeout(function() { 2284 if (!wxliAudioElement.paused && wxliAudioElement.readyState < 4) { 2285 if (hls) { 2286 hls.startLoad(); 2287 } 2288 } 2289 }, 10000); 2290 } 2291 }); 2292 } 2293 } 2294 2295 function timeFormat(seconds) { 2296 // Protección contra NaN/undefined/null 2297 if (seconds == null || isNaN(seconds) || !isFinite(seconds)) { 2298 return '0:00'; 2299 } 2300 const m = Math.floor(seconds / 60); 2301 const s = Math.floor(seconds % 60); 2302 return m + ':' + (s < 10 ? '0' + s : s); 2303 } 2304 2305 // ============================================================================ 2306 // EFECTO TYPEWRITER PARA TRANSICIÃN DE TRACKS (Estilo Claude) 2307 // Sistema de animación fluido con requestAnimationFrame 2308 // Optimizado para iOS/móviles con compensación de throttling 2309 // ============================================================================ 2310 2311 // Configuración de velocidad de animación (ms por carácter) 2312 // iOS necesita velocidades más altas porque RAF se throttlea a ~30fps 2313 const TYPEWRITER_CONFIG = { 2314 deleteSpeed: isIOSDevice ? 20 : 12, // Velocidad de borrado (ms por carácter) 2315 typeSpeed: isIOSDevice ? 28 : 18, // Velocidad de escritura (ms por carácter) 2316 pauseBetween: 100, // Pausa entre borrar y escribir 2317 pauseAfterPhrase: 1800, // Pausa después de completar una frase 2318 maxCharsPerFrame: 5 // Máximo de caracteres a procesar por frame (compensación de throttling) 2319 }; 2320 2321 // Estado de la animación 2322 let typewriterState = { 2323 phase: 'idle', // 'idle' | 'deleting-title' | 'deleting-artist' | 'typing-artist' | 'typing-title' | 'waiting' 2324 text: '', 2325 targetText: '', 2326 index: 0, 2327 lastFrameTime: 0, 2328 accumulatedTime: 0, 2329 phrasePool: TRANSITION_PHRASES 2330 }; 2331 2332 // Animación typewriter durante el arranque (antes de recibir datos reales) 2333 function startBootAnimation() { 2334 const trackTitle = document.getElementById('track-title'); 2335 const artistText = document.getElementById('artist-text'); 2336 if (!trackTitle || !artistText) return; 2337 2338 isInTransition = true; 2339 2340 const phrase = BOOT_PHRASES[Math.floor(Math.random() * BOOT_PHRASES.length)]; 2341 trackTitle.textContent = ''; 2342 artistText.textContent = ''; 2343 artistText.classList.add('transition-phrase'); 2344 trackTitle.classList.add('transition-phrase'); 2345 2346 typewriterState = { 2347 phase: 'pause-before-type', 2348 text: '', 2349 targetText: phrase.artist, 2350 titleTarget: phrase.title, 2351 index: 0, 2352 lastFrameTime: performance.now(), 2353 accumulatedTime: -TYPEWRITER_CONFIG.pauseBetween, 2354 phrasePool: BOOT_PHRASES 2355 }; 2356 2357 artistText.classList.remove('typing'); 2358 trackTitle.classList.remove('typing'); 2359 2360 if (transitionAnimationId) cancelAnimationFrame(transitionAnimationId); 2361 transitionAnimationId = requestAnimationFrame(typewriterAnimationLoop); 2362 } 2363 2364 // Función para iniciar la transición con efecto typewriter 2365 function startTrackTransition() { 2366 if (isInTransition) return; 2367 isInTransition = true;
2368 transitionLocked = true; 2369 2370 const trackTitle = document.getElementById('track-title'); 2371 const artistText = document.getElementById('artist-text'); 2372 const artworkContainer = document.getElementById('artwork-container'); 2373 const playerArtwork = document.getElementById('player-artwork'); 2374 2375 if (!trackTitle || !artistText) return; 2376 2377 // Guardar texto actual 2378 2379 // Activar efecto visual en artwork 2380 if (artworkContainer) { 2381 artworkContainer.classList.remove('fade-in', 'loading-new'); 2382 artworkContainer.classList.add('transitioning'); 2383 2384 setTimeout(() => { 2385 if (isInTransition && playerArtwork) { 2386 playerArtwork.src = 'https://wxli.ai/assets/images/shows/player/logo.svg'; 2387 } 2388 }, 400); 2389 } 2390 2391 // Iniciar secuencia: borrar tÃtulo primero 2392 typewriterState = { 2393 phase: 'deleting-title', 2394 text: trackTitle.textContent, 2395 targetText: '', 2396 index: trackTitle.textContent.length, 2397 lastFrameTime: performance.now(), 2398 accumulatedTime: 0, 2399 phrasePool: TRANSITION_PHRASES 2400 }; 2401 2402 // Cursor solo en el campo activo 2403 trackTitle.classList.add('typing'); 2404 artistText.classList.remove('typing'); 2405 2406 // Iniciar loop de animación 2407 if (transitionAnimationId) { 2408 cancelAnimationFrame(transitionAnimationId); 2409 } 2410 transitionAnimationId = requestAnimationFrame(typewriterAnimationLoop); 2411 2412 // Polling de API durante transición 2413 if (transitionApiIntervalId) { 2414 clearInterval(transitionApiIntervalId); 2415 transitionApiIntervalId = null; 2416 } 2417 transitionApiIntervalId = setInterval(() => { 2418 if (isInTransition && updateNowPlayingRef) { 2419 updateNowPlayingRef(); 2420 } 2421 }, 10000); 2422 } 2423 2424 // Loop principal de animación usando requestAnimationFrame 2425 // Optimizado para iOS: procesa múltiples caracteres por frame si RAF está throttleado 2426 function typewriterAnimationLoop(currentTime) { 2427 if (!isInTransition) return; 2428 2429 // Pausar animación cuando la página está oculta para ahorrar CPU 2430 if (isProgressLoopPaused) { 2431 transitionAnimationId = requestAnimationFrame(typewriterAnimationLoop); 2432 return; 2433 } 2434 2435 const trackTitle = document.getElementById('track-title'); 2436 const artistText = document.getElementById('artist-text'); 2437 2438 if (!trackTitle || !artistText) return; 2439 2440 const deltaTime = currentTime - typewriterState.lastFrameTime; 2441 typewriterState.lastFrameTime = currentTime; 2442 typewriterState.accumulatedTime += deltaTime; 2443 2444 let speed; 2445 let charsToProcess; 2446 2447 switch (typewriterState.phase) { 2448 case 'deleting-title': 2449 speed = TYPEWRITER_CONFIG.deleteSpeed; 2450 // iOS fix: calcular cuántos caracteres procesar basado en tiempo acumulado 2451 charsToProcess = Math.min( 2452 Math.floor(typewriterState.accumulatedTime / speed), 2453 TYPEWRITER_CONFIG.maxCharsPerFrame 2454 ); 2455 if (charsToProcess > 0) { 2456 typewriterState.accumulatedTime = typewriterState.accumulatedTime % speed; 2457 typewriterState.index = Math.max(0, typewriterState.index - charsToProcess); 2458 trackTitle.textContent = typewriterState.text.slice(0, typewriterState.index); 2459 2460 if (typewriterState.index <= 0) { 2461 // TÃtulo borrado, pasar a borrar artista 2462 trackTitle.classList.remove('typing'); 2463 artistText.classList.add('typing'); 2464 typewriterState.phase = 'deleting-artist'; 2465 typewriterState.text = artistText.textContent; 2466 typewriterState.index = artistText.textContent.length; 2467 typewriterState.accumulatedTime = 0; 2468 } 2469 } 2470 break; 2471 2472 case 'deleting-artist': 2473 speed = TYPEWRITER_CONFIG.deleteSpeed; 2474 charsToProcess = Math.min( 2475 Math.floor(typewriterState.accumulatedTime / speed), 2476 TYPEWRITER_CONFIG.maxCharsPerFrame 2477 ); 2478 if (charsToProcess > 0) { 2479 typewriterState.accumulatedTime = typewriterState.accumulatedTime % speed; 2480 typewriterState.index = Math.max(0, typewriterState.index - charsToProcess); 2481 artistText.textContent = typewriterState.text.slice(0, typewriterState.index); 2482 2483 if (typewriterState.index <= 0) { 2484 // Todo borrado, seleccionar frase y empezar a escribir artista 2485 const pool = typewriterState.phrasePool; 2486 const phrase = pool[Math.floor(Math.random() * pool.length)]; 2487 artistText.classList.add('transition-phrase'); 2488 trackTitle.classList.add('transition-phrase'); 2489 typewriterState.phase = 'pause-before-type'; 2490 typewriterState.targetText = phrase.artist;
2491 typewriterState.titleTarget = phrase.title; 2492 typewriterState.accumulatedTime = -TYPEWRITER_CONFIG.pauseBetween; 2493 } 2494 } 2495 break; 2496 2497 case 'pause-before-type': 2498 if (typewriterState.accumulatedTime >= 0) { 2499 typewriterState.phase = 'typing-artist'; 2500 typewriterState.index = 0; 2501 typewriterState.accumulatedTime = 0; 2502 } 2503 break; 2504 2505 case 'typing-artist': 2506 speed = TYPEWRITER_CONFIG.typeSpeed; 2507 charsToProcess = Math.min( 2508 Math.floor(typewriterState.accumulatedTime / speed), 2509 TYPEWRITER_CONFIG.maxCharsPerFrame 2510 ); 2511 if (charsToProcess > 0) { 2512 typewriterState.accumulatedTime = typewriterState.accumulatedTime % speed; 2513 const targetLen = typewriterState.targetText.length; 2514 typewriterState.index = Math.min(typewriterState.index + charsToProcess, targetLen); 2515 artistText.textContent = typewriterState.targetText.slice(0, typewriterState.index); 2516 2517 if (typewriterState.index >= targetLen) { 2518 // Artista completo, pasar a escribir tÃtulo 2519 artistText.classList.remove('typing'); 2520 trackTitle.classList.add('typing'); 2521 typewriterState.phase = 'typing-title'; 2522 typewriterState.targetText = typewriterState.titleTarget; 2523 typewriterState.index = 0; 2524 typewriterState.accumulatedTime = 0; 2525 } 2526 } 2527 break; 2528 2529 case 'typing-title': 2530 speed = TYPEWRITER_CONFIG.typeSpeed; 2531 charsToProcess = Math.min( 2532 Math.floor(typewriterState.accumulatedTime / speed), 2533 TYPEWRITER_CONFIG.maxCharsPerFrame 2534 ); 2535 if (charsToProcess > 0) { 2536 typewriterState.accumulatedTime = typewriterState.accumulatedTime % speed; 2537 const targetLen = typewriterState.targetText.length; 2538 typewriterState.index = Math.min(typewriterState.index + charsToProcess, targetLen); 2539 trackTitle.textContent = typewriterState.targetText.slice(0, typewriterState.index); 2540 2541 if (typewriterState.index >= targetLen) { 2542 // Frase completa, esperar antes de reiniciar 2543 typewriterState.phase = 'waiting'; 2544 typewriterState.accumulatedTime = -TYPEWRITER_CONFIG.pauseAfterPhrase; 2545 } 2546 } 2547 break; 2548 2549 case 'waiting': 2550 if (typewriterState.accumulatedTime >= 0) { 2551 // Reiniciar ciclo: borrar tÃtulo 2552 typewriterState.phase = 'deleting-title'; 2553 typewriterState.text = trackTitle.textContent; 2554 typewriterState.index = trackTitle.textContent.length; 2555 artistText.classList.remove('transition-phrase'); 2556 trackTitle.classList.remove('transition-phrase'); 2557 trackTitle.classList.add('typing'); 2558 artistText.classList.remove('typing'); 2559 typewriterState.accumulatedTime = 0; 2560 } 2561 break; 2562 } 2563 2564 // Continuar el loop 2565 transitionAnimationId = requestAnimationFrame(typewriterAnimationLoop); 2566 } 2567 2568 // Función para terminar la transición 2569 // CRÃTICO: Esta función debe limpiar el estado inmediatamente para permitir 2570 // que los nuevos datos del track se muestren correctamente 2571 function endTrackTransition() { 2572 // Marcar como no en transición PRIMERO (evita race conditions) 2573 const wasInTransition = isInTransition; 2574 isInTransition = false; 2575 2576 // Cancelar animación inmediatamente 2577 if (transitionAnimationId) { 2578 cancelAnimationFrame(transitionAnimationId); 2579 transitionAnimationId = null; 2580 } 2581 2582 // Cancelar polling de API 2583 if (transitionApiIntervalId) { 2584 clearInterval(transitionApiIntervalId); 2585 transitionApiIntervalId = null; 2586 } 2587 2588 // Reset estado completo del typewriter 2589 typewriterState.phase = 'idle'; 2590 typewriterState.accumulatedTime = 0; 2591 typewriterState.index = 0; 2592 typewriterState.text = ''; 2593 typewriterState.targetText = ''; 2594 2595 const trackTitle = document.getElementById('track-title'); 2596 const artistText = document.getElementById('artist-text'); 2597 const artworkContainer = document.getElementById('artwork-container'); 2598 2599 // IMPORTANTE: Remover clases de typing INMEDIATAMENTE (no en setTimeout) 2600 // Esto permite que los nuevos textos se muestren sin el cursor 2601 if (artistText) { 2602 artistText.classList.remove('typing', 'transition-phrase'); 2603 } 2604 if (trackTitle) { 2605 trackTitle.classList.remove('typing', 'transition-phrase'); 2606 } 2607 2608 // El artwork puede tener transición visual, pero NO bloquea los textos 2609 if (artworkContainer) { 2610 artworkContainer.classList.remove('transitioning', 'loading-new'); 2611 // Usar requestAnimationFrame para el fade-in (más eficiente que setTimeout) 2612 requestAnimationFrame(() => { 2613 artworkContainer.classList.add('fade-in'); 2614 // Desbloquear transitionLocked después de la animación del artwork 2615 setTimeout(() => {
2616 transitionLocked = false; 2617 }, 600); 2618 }); 2619 } else { 2620 transitionLocked = false; 2621 } 2622 2623 // Log para debugging en iOS 2624 if (wasInTransition) { 2625 } 2626 } 2627 2628 // Función para sincronizar el progreso desde el popup player 2629 function syncProgressFromPopup(progressData) { 2630 if (!maudioContainer) return; 2631 2632 // Solo actualizar variables de estado - updateProgressBar maneja el DOM 2633 currentElapsed = progressData.elapsed || 0; 2634 currentDuration = progressData.duration || 0; 2635 lastUpdateTime = Date.now(); 2636 2637 // Sincronizar metadata del track si cambió la canción 2638 if (progressData.songId && progressData.songId !== currentPlayerSongId && progressData.track) { 2639 currentPlayerSongId = progressData.songId; 2640 2641 // Terminar transición si hay una activa antes de actualizar textos 2642 // Esto evita que la animación typewriter sobrescriba los nuevos datos 2643 if (isInTransition) { 2644 endTrackTransition(); 2645 } 2646 2647 // Actualizar tÃtulo y artista 2648 const trackTitle = document.getElementById('track-title'); 2649 const artistTextEl = document.getElementById('artist-text'); 2650 2651 if (trackTitle && progressData.track.title) { 2652 trackTitle.textContent = progressData.track.title; 2653 } 2654 if (artistTextEl && progressData.track.artist) { 2655 artistTextEl.textContent = progressData.track.artist; 2656 } 2657 2658 // Actualizar artwork si está disponible 2659 const playerArtwork = document.getElementById('player-artwork'); 2660 const artworkContainer = document.getElementById('artwork-container'); 2661 if (playerArtwork && progressData.track.art) { 2662 let cleanArtUrl = progressData.track.art.replace('/api/station/', '/station/'); 2663 if (cleanArtUrl.startsWith('/')) { 2664 cleanArtUrl = 'https://api.core.wxli.ai' + cleanArtUrl; 2665 } 2666 if (cleanArtUrl.match(/^https?:\/\//i) && playerArtwork.src !== cleanArtUrl) { 2667 const img = new Image(); 2668 img.onload = function() { 2669 // Preparar fade in del nuevo artwork (limpiar todas las clases primero) 2670 if (artworkContainer) { 2671 artworkContainer.classList.remove('fade-in', 'transitioning'); 2672 artworkContainer.classList.add('loading-new'); 2673 } 2674 playerArtwork.src = cleanArtUrl; 2675 // Trigger fade in después de un breve delay 2676 if (artworkContainer) { 2677 setTimeout(() => { 2678 artworkContainer.classList.remove('loading-new'); 2679 artworkContainer.classList.add('fade-in'); 2680 }, 100); 2681 } 2682 }; 2683 img.onerror = function() { 2684 playerArtwork.src = 'https://wxli.ai/assets/images/shows/player/logo.svg'; 2685 }; 2686 img.src = cleanArtUrl; 2687 } 2688 } 2689 2690 // Actualizar botón de like 2691 const playerLikeBtn = document.getElementById('player-like-btn'); 2692 if (playerLikeBtn) { 2693 playerLikeBtn.setAttribute('data-song-id', progressData.songId); 2694 syncLikesUI(); 2695 } 2696 } 2697 2698 } 2699 2700 // Función para leer el último estado de progreso del popup al volver de idle 2701 function readLastPopupProgress() { 2702 try { 2703 const savedProgress = localStorage.getItem('wxli-player-progress'); 2704 if (savedProgress) { 2705 const progressData = JSON.parse(savedProgress); 2706 // Verificar que sea del popup y relativamente reciente (menos de 30 segundos) 2707 const timeDiff = Date.now() - progressData.timestamp; 2708 if (progressData.instanceId && progressData.instanceId.startsWith('popup-') && timeDiff < 30000) { 2709 // Ajustar elapsed basado en el tiempo transcurrido 2710 progressData.elapsed = Math.min(progressData.elapsed + (timeDiff / 1000), progressData.duration); 2711 progressData.progressPercent = progressData.duration > 0 ? (progressData.elapsed / progressData.duration) * 100 : 0; 2712 syncProgressFromPopup(progressData); 2713 return true; 2714 } 2715 } 2716 } catch (err) { 2717 } 2718 return false;
2719 } 2720 2721 // Variable para controlar pausa del loop cuando página oculta 2722 let isProgressLoopPaused = false; 2723 2724 function updateProgressBar() { 2725 // Si la página está oculta, pausar el loop para ahorrar recursos 2726 if (isProgressLoopPaused) { 2727 // En background, no quemar CPU con RAF 2728 setTimeout(updateProgressBar, 1000); 2729 return; 2730 } 2731 2732 if (!maudioContainer || currentDuration === 0) { 2733 requestAnimationFrame(updateProgressBar); 2734 return; 2735 } 2736 2737 const now = Date.now(); 2738 const timeSinceUpdate = (now - lastUpdateTime) / 1000; 2739 const interpolatedElapsed = Math.min(currentElapsed + timeSinceUpdate, currentDuration); 2740 2741 if (interpolatedElapsed <= currentDuration) { 2742 const progressPercent = (interpolatedElapsed / currentDuration) * 100; 2743 const remaining = currentDuration - interpolatedElapsed; 2744 2745 const isEndingTrack = remaining <= 5; 2746 const showTransitionState = isEndingTrack || transitionLocked; 2747 2748 // Actualizar barra de progreso roja 2749 const progressBar = maudioContainer.querySelector('.progress-pass'); 2750 if (progressBar) { 2751 progressBar.style.width = progressPercent + '%'; 2752 2753 if (isEndingTrack) { 2754 progressBar.classList.add('waiting-next-track'); 2755 if (!isInTransition) { 2756 startTrackTransition(); 2757 } 2758 } else if (!transitionLocked) { 2759 progressBar.classList.remove('waiting-next-track'); 2760 if (isInTransition) { 2761 endTrackTransition(); 2762 } 2763 } 2764 } 2765 2766 // Actualizar tiempo actual en controles maudio 2767 const currentTimeDisplay = maudioContainer.querySelector('.current-time'); 2768 if (currentTimeDisplay) { 2769 currentTimeDisplay.textContent = showTransitionState ? '--:--' : timeFormat(interpolatedElapsed); 2770 } 2771 2772 // Actualizar duración total en controles maudio 2773 const durationDisplay = maudioContainer.querySelector('.duration'); 2774 if (durationDisplay) { 2775 durationDisplay.textContent = showTransitionState ? '--:--' : '-' + timeFormat(remaining); 2776 } 2777 2778 // Actualizar display de tiempo elapsed/remaining en el header 2779 const trackTimeDisplay = document.getElementById('track-time-display'); 2780 if (trackTimeDisplay) { 2781 trackTimeDisplay.textContent = showTransitionState ? ' | --:-- / ---:--' : ' | ' + timeFormat(interpolatedElapsed) + ' / -' + timeFormat(remaining); 2782 } 2783 } 2784 2785 requestAnimationFrame(updateProgressBar); 2786 } 2787 2788 function startProgressAnimation() { 2789 // Pausar/reanudar loop cuando la página se oculta/muestra (via eventManager) 2790 eventManager.onVisibilityChange(function(isHidden) { 2791 isProgressLoopPaused = isHidden; 2792 }, 10); // Prioridad 10 (baja) 2793 2794 requestAnimationFrame(updateProgressBar); 2795 } 2796 2797 function setupPlaylistToggle() { 2798 const liveStatusElement = document.querySelector('.live-status'); 2799 2800 if (!liveStatusElement) return; 2801 2802 // Función para actualizar el texto del badge 2803 function updateBadgeText() { 2804 // Limpiar estado previo 2805 liveStatusElement.classList.remove('scrolling'); 2806 liveStatusElement.removeAttribute('data-text'); 2807 2808 // Verificar si hay un playlist válido 2809 const hasValidPlaylist = currentPlaylistName && 2810 currentPlaylistName.trim() !== '' && 2811 currentPlaylistName.toUpperCase() !== 'WXLI Live'; 2812 2813 let badgeText; 2814 if (hasValidPlaylist) { 2815 // Mostrar: LIVE INTELLIGENCE ⢠NOMBRE PLAYLIST ⢠LIVE INTELLIGENCE con marquesina 2816 badgeText = `LIVE INTELLIGENCE ⢠${currentPlaylistName.toUpperCase()} ⢠LIVE INTELLIGENCE ⢠${currentPlaylistName.toUpperCase()}`; 2817 } else { 2818 // Mostrar: WXLI Live con marquesina (buffering) 2819 badgeText = 'WXLI Live'; 2820 } 2821 2822 // Aplicar marquesina siempre 2823 liveStatusElement.textContent = badgeText; 2824 liveStatusElement.setAttribute('data-text', badgeText); 2825 liveStatusElement.classList.add('scrolling'); 2826 } 2827 2828 // Inicializar el badge 2829 updateBadgeText(); 2830 2831 // Exponer la función globalmente para que pueda ser llamada cuando cambie el playlist 2832 window.updatePlaylistBadge = updateBadgeText; 2833 } 2834 2835 function setupMetadata() { 2836 const trackTitle = document.getElementById('track-title'); 2837 const playerArtwork = document.getElementById('player-artwork'); 2838 2839 async function updateNowPlaying() { 2840 try { 2841 // Usar cache global para evitar llamadas duplicadas 2842 const data = await wxliFetchNowPlaying(); 2843 2844 if (data.now_playing && data.now_playing.song) { 2845 const song = data.now_playing.song; 2846 const songId = song.id || song.title; 2847 2848 // Elapsed y duration de la API (ya ajustados por el pooler) 2849 const apiElapsedRaw = Math.floor((data.now_playing && data.now_playing.elapsed) || 0); 2850 const apiDurationRaw = Math.floor((data.now_playing && data.now_playing.duration) || 0); 2851 2852 // Obtener nombre del playlist reportado por la API (puede adelantarse) 2853 // IMPORTANTE: NO lo confirmamos en UI hasta que haya evidencia del cambio real (cambio de track o reinicio del elapsed) 2854 const reportedPlaylistName = 2855 data.now_playing.playlist || 2856 (data.live && data.live.is_live && data.live.streamer_name) || 2857 (data.station && data.station.name) || 2858 song.playlist || 2859 currentPlaylistName; 2860 2861 const playlistChangedReported = reportedPlaylistName !== currentPlaylistName; 2862 2863 // Detectar si la API reporta un track nuevo 2864 const isNewTrackFromApi = songId !== lastSongId; 2865 2866 // El nombre de playlist de la API puede adelantarse al audio, y la 2867 // última canción de la sesión saliente suele extenderse pasada la 2868 // hora programada. Por eso adoptamos el nombre nuevo SOLO cuando: 2869 // (1) arrancó realmente una canción nueva (new-track boundary), y 2870 // (2) el slot programado de la sesión entrante YA comenzó. 2871 // Asà el header/comentario cambian recién cuando suena la primera 2872 // canción de la sesión nueva, no durante el overrun de la saliente. 2873 let shouldCommitPlaylist = false;
2874 if (isNewTrackFromApi) { 2875 const noSessionYet = currentPlaylistName === 'WXLI Live'; 2876 if (!playlistChangedReported || noSessionYet) { 2877 // Mismo playlist (refresco normal) o primer arranque sin sesión aún. 2878 shouldCommitPlaylist = true; 2879 } else if (typeof window.wxliSessionSlotStarted !== 'function' || 2880 window.wxliSessionSlotStarted(reportedPlaylistName)) { 2881 // Cambio de sesión: confirmar solo si el slot nuevo ya empezó 2882 // (o si no hay schedule disponible para verificar). 2883 shouldCommitPlaylist = true; 2884 } 2885 // Si el playlist cambió pero el slot nuevo aún no empezó, se 2886 // difiere: seguimos mostrando la sesión saliente hasta que su 2887 // canción de cierre termine y arranque la primera de la entrante. 2888 } 2889 2890 // Confirmar el playlist SOLO cuando el cambio está confirmado 2891 if (shouldCommitPlaylist) { 2892 currentPlaylistName = reportedPlaylistName; 2893 2894 // Exponer globalmente para que otras IIFEs puedan acceder 2895 window.currentPlaylistName = currentPlaylistName; 2896 2897 // Si cambió el playlist confirmado, actualizar el schedule 2898 if (currentPlaylistName !== lastPlaylistName) { 2899 lastPlaylistName = currentPlaylistName; 2900 2901 // Llamar a loadSchedule si está disponible 2902 if (typeof window.loadSchedule === 'function') { 2903 window.loadSchedule(); 2904 } 2905 } 2906 2907 // Actualizar el badge con el nuevo nombre del playlist (confirmado) 2908 if (window.updatePlaylistBadge) { 2909 window.updatePlaylistBadge(); 2910 } 2911 } 2912 2913// Debug: descomentar para ver la estructura completa de la API 2914 2915 // Solo actualizar si cambió la canción 2916 if (songId !== lastSongId) { 2917 lastSongId = songId; 2918 currentPlayerSongId = songId; 2919 2920 // Guardar metadata completa de la canción actual 2921 currentPlayerSongData = { 2922 title: song.title || 'WXLI Live', 2923 artist: song.artist || 'WXLI Live', 2924 art: song.art || '' 2925 }; 2926 2927 // Terminar transición si hay una activa antes de actualizar textos 2928 if (isInTransition) { 2929 endTrackTransition(); 2930 } 2931 2932 trackTitle.textContent = song.title || 'WXLI Live'; 2933 2934 // Actualizar el texto del artista (ahora está en un span separado) 2935 const artistTextEl = document.getElementById('artist-text'); 2936 if (artistTextEl) { 2937 artistTextEl.textContent = song.artist || 'WXLI Live'; 2938 } 2939 2940 // Actualizar el botón de like del reproductor principal 2941 updatePlayerLikeButton(songId); 2942 2943 // URLs de fallback PNG para Android Auto / CarPlay (SVG no soportado) 2944 const FALLBACK_ARTWORK = [ 2945 { src: 'https://wxli.ai/assets/images/icons/android-chrome-96x96.png', sizes: '96x96', type: 'image/png' }, 2946 { src: 'https://wxli.ai/assets/images/icons/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' }, 2947 { src: 'https://wxli.ai/assets/images/icons/android-chrome-256x256.png', sizes: '256x256', type: 'image/png' }, 2948 { src: 'https://wxli.ai/assets/images/icons/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' } 2949 ]; 2950 2951 const displayTitle = song.title || 'En Vivo'; 2952 const displayArtist = song.artist ? `${song.artist} ⢠WXLI Live` : 'WXLI Live'; 2953 const displayAlbum = currentPlaylistName ? `${currentPlaylistName}${song.album ? ' ⢠' + song.album : ''}` : (song.album || 'WXLI Live'); 2954 2955 // Actualizar MediaSession inmediatamente con fallback, 2956 // independientemente de si hay artwork â para que CarPlay / Android Auto 2957 // siempre reciba el tÃtulo y artista correctos en el cambio de canción
2958 if ('mediaSession' in navigator && 'MediaMetadata' in window) { 2959 try { 2960 navigator.mediaSession.metadata = new MediaMetadata({ 2961 title: displayTitle, 2962 artist: displayArtist, 2963 album: displayAlbum, 2964 artwork: FALLBACK_ARTWORK 2965 }); 2966 } catch (error) { 2967 // iOS Safari antiguo â MediaSession no soportado completamente 2968 } 2969 } 2970 2971 if (song.art) { 2972 // Limpiar /api/ duplicado en la URL del album art 2973 let cleanArtUrl = song.art.replace('/api/station/', '/station/'); 2974 2975 // Si la URL es relativa, agregarle el dominio base 2976 if (cleanArtUrl.startsWith('/')) { 2977 cleanArtUrl = 'https://api.core.wxli.ai' + cleanArtUrl; 2978 } 2979 2980 // Validar que la URL sea segura (solo HTTP/HTTPS) para prevenir XSS 2981 if (cleanArtUrl.match(/^https?:\/\//i)) { 2982 const artworkContainer = document.getElementById('artwork-container'); 2983 // Precargar la nueva imagen antes de mostrarla 2984 const newImage = new Image(); 2985 newImage.onload = function() { 2986 // Imagen cargada, cambiar src y hacer fade in 2987 playerArtwork.src = cleanArtUrl; 2988 playerArtwork.alt = song.title + ' - ' + song.artist; 2989 2990 // Fade in del nuevo artwork (el logo ya está visible por la transición) 2991 if (artworkContainer) { 2992 artworkContainer.classList.remove('loading-new', 'transitioning'); 2993 artworkContainer.classList.add('fade-in'); 2994 } 2995 2996 // Actualizar MediaSession con el artwork real una vez cargado 2997 if ('mediaSession' in navigator && 'MediaMetadata' in window) { 2998 try { 2999 navigator.mediaSession.metadata = new MediaMetadata({ 3000 title: displayTitle, 3001 artist: displayArtist, 3002 album: displayAlbum, 3003 artwork: [ 3004 { src: cleanArtUrl, sizes: '256x256', type: 'image/jpeg' }, 3005 { src: cleanArtUrl, sizes: '512x512', type: 'image/jpeg' } 3006 ] 3007 }); 3008 } catch (error) { 3009 // iOS Safari antiguo â MediaSession no soportado completamente 3010 } 3011 } 3012 }; 3013 newImage.onerror = function() { 3014 // Si falla la carga, mantener el logo y el fallback ya establecido 3015 }; 3016 newImage.src = cleanArtUrl; 3017 } else { 3018 } 3019 } 3020 3021 // Actualizar tiempos con elapsed de la API (ya ajustado por el pooler) 3022 currentElapsed = apiElapsedRaw; 3023 currentDuration = apiDurationRaw; 3024 lastUpdateTime = Date.now(); 3025 updateMediaSessionPosition(currentElapsed, currentDuration); 3026 3027 // IMPORTANTE: Reprogramar actualizaciones para la nueva canción 3028 scheduleNextUpdate(); 3029 } else { 3030 // Misma canción: sincronizar con elapsed de la API 3031 const apiDuration = apiDurationRaw; 3032 3033 // Calcular el elapsed interpolado actual 3034 const now = Date.now(); 3035 const timeSinceUpdate = (now - lastUpdateTime) / 1000; 3036 const localElapsed = currentElapsed + timeSinceUpdate; 3037 3038 // Resync direccional: la API puede llegar cacheada (0-8s atrás del local), 3039 // asà que solo resincronizamos si la API está ADELANTE (nos quedamos atrás) 3040 // o si el local se disparó demasiado lejos (>10s por delante). 3041 const elapsedDiff = apiElapsedRaw - localElapsed; // positivo = API adelante, negativo = local adelante 3042 3043 if (elapsedDiff > 3 || elapsedDiff < -10) { 3044 currentElapsed = apiElapsedRaw; 3045 currentDuration = apiDuration;
3046 lastUpdateTime = now; 3047 updateMediaSessionPosition(currentElapsed, currentDuration); 3048 } else if (apiDuration !== currentDuration) { 3049 // Solo actualizar duration si cambió 3050 currentDuration = apiDuration; 3051 } 3052 } 3053 } 3054 } catch (error) { 3055 // Error silencioso 3056 } 3057 } 3058 3059 // Sistema de actualización inteligente con protección anti-thundering herd 3060 function scheduleNextUpdate() { 3061 // apiUpdateInterval es siempre un setTimeout â solo clearTimeout es necesario 3062 if (apiUpdateInterval) { 3063 clearTimeout(apiUpdateInterval); 3064 apiUpdateInterval = null; 3065 } 3066 3067 // Calcular tiempo restante usando interpolación local (más preciso) 3068 const now = Date.now(); 3069 const timeSinceUpdate = (now - lastUpdateTime) / 1000; 3070 const interpolatedElapsed = Math.min(currentElapsed + timeSinceUpdate, currentDuration); 3071 const timeRemaining = currentDuration - interpolatedElapsed; 3072 3073 // Si quedan menos de 5 segundos, actualizar cada 5 segundos (mÃnimo de la API) + jitter 3074 if (timeRemaining <= 5 && timeRemaining > 0) { 3075 // Agregar jitter aleatorio de 0-1000ms para evitar thundering herd 3076 const jitter = Math.random() * 1000; 3077 const updateInterval = 5000 + jitter; 3078 3079 apiUpdateInterval = setTimeout(async function() { 3080 await updateNowPlaying(); 3081 scheduleNextUpdate(); // Re-evaluar después de cada actualización 3082 }, updateInterval); 3083 } else { 3084 // Si quedan más de 5 segundos, esperar hasta que falten 5 segundos 3085 // Agregar jitter aleatorio de 0-2000ms para dispersar las conexiones 3086 const jitter = Math.random() * 2000; 3087 const waitTime = Math.max((timeRemaining - 5) * 1000 + jitter, 5000); 3088 3089 apiUpdateInterval = setTimeout(async function() { 3090 await updateNowPlaying(); 3091 scheduleNextUpdate(); 3092 }, waitTime); 3093 } 3094 } 3095 3096 // Exponer updateNowPlaying y scheduleNextUpdate para acceso desde resume/transiciones 3097 updateNowPlayingRef = updateNowPlaying; 3098 scheduleNextUpdateRef = scheduleNextUpdate; 3099 3100 // Primera actualización inmediata 3101 updateNowPlaying().then(() => { 3102 scheduleNextUpdate(); 3103 }); 3104 3105 // Actualizar inmediatamente cuando el audio empiece a reproducirse 3106 wxliAudioElement.addEventListener('play', function() { 3107 updateNowPlaying().then(() => { 3108 scheduleNextUpdate(); 3109 }); 3110 }); 3111 3112 // También actualizar cuando haya datos disponibles 3113 wxliAudioElement.addEventListener('loadeddata', function() { 3114 updateNowPlaying().then(() => { 3115 scheduleNextUpdate(); 3116 }); 3117 }); 3118 3119 // Función para verificar si el stream REALMENTE necesita refresh 3120 // MÃS CONSERVADORA: evita falsos positivos al volver de minimizar 3121 function streamNeedsRefresh() { 3122 // IMPORTANTE: Cuando la página vuelve de estar oculta, el navegador puede 3123 // reportar estados transitorios incorrectos. Ser MUY conservador. 3124 3125 // Si el audio está reproduciendo (no pausado), probablemente está bien 3126 if (!wxliAudioElement.paused && !wxliAudioElement.ended) { 3127 // Verificar si realmente está avanzando 3128 const currentTime = wxliAudioElement.currentTime; 3129 3130 // Guardar tiempo para verificar después 3131 if (!streamNeedsRefresh._lastCheckTime) { 3132 streamNeedsRefresh._lastCheckTime = currentTime; 3133 streamNeedsRefresh._lastCheckTimestamp = Date.now(); 3134 // Primera verificación: asumir que está bien si está "playing" 3135 return false; 3136 } 3137 3138 const timeSinceCheck = Date.now() - streamNeedsRefresh._lastCheckTimestamp; 3139 3140 // Si han pasado más de 500ms y el tiempo no avanzó, hay problema 3141 if (timeSinceCheck > 500 && currentTime === streamNeedsRefresh._lastCheckTime) { 3142 // Pero aún asÃ, verificar el buffer antes de decidir 3143 } else if (currentTime !== streamNeedsRefresh._lastCheckTime) { 3144 // El audio está avanzando, todo bien 3145 streamNeedsRefresh._lastCheckTime = currentTime; 3146 streamNeedsRefresh._lastCheckTimestamp = Date.now(); 3147 return false;
3148 } 3149 } 3150 3151 // networkState: 0=EMPTY, 1=IDLE, 2=LOADING, 3=NO_SOURCE 3152 // readyState: 0=NOTHING, 1=METADATA, 2=CURRENT_DATA, 3=FUTURE_DATA, 4=ENOUGH_DATA 3153 const networkState = wxliAudioElement.networkState; 3154 const readyState = wxliAudioElement.readyState; 3155 3156 // Solo considerar refresh si el estado es REALMENTE malo 3157 // networkState 1 (IDLE) puede ser transitorio al volver de background 3158 if (networkState === 0 || networkState === 3) { 3159 return true; 3160 } 3161 3162 // readyState < 1 es realmente malo (ni siquiera hay metadata) 3163 if (readyState < 1) { 3164 return true; 3165 } 3166 3167 // Verificar buffer - pero ser más tolerante 3168 if (wxliAudioElement.buffered.length === 0) { 3169 // Buffer vacÃo PUEDE ser transitorio, verificar si el audio está "playing" 3170 if (!wxliAudioElement.paused) { 3171 return false; // Dar chance de que se llene 3172 } 3173 return true; 3174 } 3175 3176 // Verificar buffer adelante - umbral MÃS BAJO (0.5s en vez de 2s) 3177 try { 3178 const currentTime = wxliAudioElement.currentTime; 3179 const bufferEnd = wxliAudioElement.buffered.end(wxliAudioElement.buffered.length - 1); 3180 const bufferAhead = bufferEnd - currentTime; 3181 3182 // Solo refresh si el buffer es REALMENTE crÃtico (< 0.5s) 3183 if (bufferAhead < 0.5) { 3184 return true; 3185 } 3186 } catch (e) { 3187 // Error al leer buffer - puede ser transitorio 3188 return false; 3189 } 3190 3191 return false; 3192 } 3193 3194 // Función centralizada para manejar regreso de idle/minimizado 3195 function handlePageResume(source) { 3196 const now = Date.now(); 3197 // Evitar ejecuciones múltiples (visibilitychange + pageshow + focus) 3198 if (handlePageResume._lastRun && (now - handlePageResume._lastRun) < 1500) { 3199 return; 3200 } 3201 handlePageResume._lastRun = now; 3202 3203 const timeHidden = pageHiddenTime > 0 ? now - pageHiddenTime : 0; 3204 const wasHiddenVeryLong = timeHidden > 120000; // 120s 3205 3206 3207 // Refrescar metadata inmediatamente si estuvo oculto > 10s (evita track stale por throttling de timers) 3208 if (timeHidden > 10000 && updateNowPlayingRef && scheduleNextUpdateRef) { 3209 WXLICache.invalidateCache('nowplaying'); 3210 updateNowPlayingRef().then(function() { 3211 scheduleNextUpdateRef(); 3212 }); 3213 } 3214 3215 // IMPORTANTE: Terminar cualquier transición activa inmediatamente 3216 if (isInTransition) { 3217 endTrackTransition(); 3218 } 3219 3220 // Desbloquear transitionLocked por si quedó atascado 3221 transitionLocked = false; 3222 3223 // Limpiar clases de transición del artwork container 3224 const artworkContainer = document.getElementById('artwork-container'); 3225 if (artworkContainer) { 3226 artworkContainer.classList.remove('transitioning', 'loading-new'); 3227 artworkContainer.classList.add('fade-in'); 3228 } 3229 3230 // Si el audio está pausado, NO forzar reload aquÃ. 3231 // El auto-resume se maneja en setupSystemEventListeners (y el usuario puede dar play). 3232 if (wxliAudioElement.paused) { 3233 pageHiddenTime = 0; 3234 return; 3235 } 3236 3237 // ============================================================================ 3238 // FIX MICRO-CUTS (PC + Android): 3239 // - En streams DIRECT (MP3/AAC), NO hacemos auto-refresh al volver de background. 3240 // Los reloads en resume son la principal causa de microcortes "se corta y vuelve". 3241 // - La recuperación de DIRECT se hace SOLO si hay freeze real (healthMonitor / stallDetection). 3242 // - Para HLS sà podemos resincronizar si el idle fue largo. 3243 // ============================================================================ 3244 if (!window.hlsInstance) { 3245 pageHiddenTime = 0; 3246 return; 3247 } 3248 3249 // HLS: resetear estado de verificación para streamNeedsRefresh 3250 streamNeedsRefresh._lastCheckTime = null; 3251 streamNeedsRefresh._lastCheckTimestamp = null; 3252 3253 // Si estuvo oculto poco tiempo (< 30s), no tocar nada 3254 if (timeHidden < 30000) { 3255 pageHiddenTime = 0; 3256 return; 3257 } 3258 3259 // Para tiempos medios (30s-120s), esperar y verificar antes de refrescar 3260 if (!wasHiddenVeryLong) { 3261 setTimeout(function() { 3262 if (wxliAudioElement.paused) return; 3263 3264 const needsRefresh = streamNeedsRefresh(); 3265 if (needsRefresh) { 3266 doStreamRefresh(); 3267 } else { 3268 } 3269 }, 500); 3270 3271 pageHiddenTime = 0; 3272 return; 3273 } 3274 3275 // Para tiempos muy largos (>120s), refrescar HLS (live edge) 3276 doStreamRefresh(); 3277 3278 pageHiddenTime = 0; 3279 } 3280 3281// Función auxiliar para refrescar el stream 3282 function doStreamRefresh() { 3283 3284 if (window.hlsInstance) { 3285 // Para HLS: reiniciar desde live edge 3286 window.hlsInstance.stopLoad(); 3287 setTimeout(function() { 3288 if (window.hlsInstance) { 3289 window.hlsInstance.startLoad(-1); 3290 } 3291 }, 100); 3292 } else if (wxliAudioElement.src) { 3293 // Para stream directo: usar safeLoadAndPlay con URL fresca 3294 const baseUrl = wxliAudioElement.src.split('?')[0]; 3295 const freshUrl = baseUrl + '?_t=' + Date.now(); 3296 safeLoadAndPlay(wxliAudioElement, freshUrl, 'doStreamRefresh'); 3297 } 3298 } 3299 3300 // Detectar cuando la página vuelve de estar oculta (idle) - via eventManager 3301 // NOTA: pageHiddenTime ya se actualiza en el listener central del eventManager
3302 eventManager.onVisibilityChange(function(isHidden) { 3303 if (!isHidden) { 3304 handlePageResume('visibilitychange'); 3305 } 3306 // No necesitamos actualizar pageHiddenTime aquÃ, ya lo hace el listener central 3307 }, 8); // Prioridad 8 3308 3309 // CRÃTICO para móviles: pageshow se dispara cuando el usuario vuelve de una app minimizada 3310 // Más confiable que visibilitychange en iOS/Android 3311 window.addEventListener('pageshow', function(event) { 3312 // event.persisted indica que la página viene del bfcache (back-forward cache) 3313 if (event.persisted) { 3314 3315 const wasPlaying = !wxliAudioElement.paused; 3316 3317 // SOLO refrescar si el stream realmente tiene problemas 3318 // (no refrescar innecesariamente porque causa cortes) 3319 if (wasPlaying && streamNeedsRefresh()) { 3320 3321 if (window.hlsInstance) { 3322 window.hlsInstance.stopLoad(); 3323 setTimeout(function() { 3324 if (window.hlsInstance) { 3325 window.hlsInstance.startLoad(-1); 3326 } 3327 }, 100); 3328 } else if (wxliAudioElement.src) { 3329 // Usar safeLoadAndPlay para manejar Firefox correctamente 3330 const baseUrl = wxliAudioElement.src.split('?')[0]; 3331 const freshUrl = baseUrl + '?_t=' + Date.now(); 3332 safeLoadAndPlay(wxliAudioElement, freshUrl, 'pageshow-bfcache'); 3333 } 3334 } else { 3335 } 3336 3337 handlePageResume('pageshow-persisted'); 3338 } 3339 }); 3340 3341 // Backup: focus puede capturar casos que visibilitychange no detecta 3342 let lastFocusTime = 0; 3343 window.addEventListener('focus', function() { 3344 const now = Date.now(); 3345 // Evitar llamadas duplicadas si visibilitychange ya se disparó recientemente 3346 if (now - lastFocusTime > 2000 && now - lastUpdateTime > 5000) { 3347 lastFocusTime = now; 3348 handlePageResume('focus'); 3349 } 3350 }); 3351 } 3352 3353 if (document.readyState === 'loading') { 3354 document.addEventListener('DOMContentLoaded', initializeWXLI); 3355 } else { 3356 initializeWXLI(); 3357 } 3358 3359 })(); 3360 3361// Banner Fade Animation 3362(function() { 3363 'use strict'; 3364 3365 const phrases = [ 3366 'Music with context.', 3367 'No playlists.', 3368 'No noise.', 3369 'Just signal.', 3370 'Live 24/7.', 3371 'Future, now playing.', 3372 ]; 3373 let currentIndex = 0; 3374 let bannerTitle = null; 3375 let bannerFadeInterval = null; 3376 3377 function initBannerFade() { 3378 bannerTitle = document.querySelector('.banner-title.style-two'); 3379 3380 if (bannerTitle) { 3381 if (bannerFadeInterval) { 3382 clearInterval(bannerFadeInterval); 3383 } 3384 bannerFadeInterval = setInterval(changePhrase, 2500); 3385 } 3386 } 3387 3388 function changePhrase() { 3389 bannerTitle.classList.add('smoke-out'); 3390 bannerTitle.classList.remove('smoke-in'); 3391 3392 setTimeout(() => { 3393 currentIndex = (currentIndex + 1) % phrases.length; 3394 const newPhrase = phrases[currentIndex]; 3395 const phraseWithColoredDots = newPhrase.replace(/\./g, '<span style="color: #f82a87;">.</span>'); 3396 bannerTitle.innerHTML = phraseWithColoredDots; 3397 bannerTitle.classList.remove('smoke-out'); 3398 bannerTitle.classList.add('smoke-in'); 3399 3400 setTimeout(() => { 3401 bannerTitle.classList.remove('smoke-in'); 3402 }, 800); 3403 }, 800); 3404 } 3405 3406 if (document.readyState === 'loading') { 3407 document.addEventListener('DOMContentLoaded', initBannerFade); 3408 } else { 3409 initBannerFade(); 3410 } 3411 3412 // Pausar/reanudar interval cuando la página está oculta (fix memory leak) 3413 document.addEventListener('visibilitychange', function() { 3414 if (document.hidden) { 3415 if (bannerFadeInterval) { 3416 clearInterval(bannerFadeInterval); 3417 bannerFadeInterval = null; 3418 } 3419 } else { 3420 if (!bannerFadeInterval && bannerTitle) { 3421 bannerFadeInterval = setInterval(changePhrase, 3000); 3422 } 3423 } 3424 }); 3425})(); 3426 3427 (function() { 3428 'use strict'; 3429 3430 const latestSongsContainer = document.getElementById('latest-songs-container'); 3431 3432 // Track current songs for smart comparison 3433 let currentSongIds = []; 3434 3435 // Funciones para manejar likes en localStorage 3436 function getLikedSongs() { 3437 const liked = localStorage.getItem('wxli-liked-songs'); 3438 return liked ? JSON.parse(liked) : []; 3439 } 3440 3441 function saveLikedSongs(likedSongs) { 3442 localStorage.setItem('wxli-liked-songs', JSON.stringify(likedSongs)); 3443 3444 // Disparar evento personalizado para sincronización en la misma pestaña 3445 window.dispatchEvent(new CustomEvent('wxli-likes-changed', { 3446 detail: { likedSongs: likedSongs } 3447 })); 3448 } 3449 3450 function isSongLiked(songId) { 3451 const likedSongs = getLikedSongs(); 3452 return likedSongs.includes(songId); 3453 } 3454 3455 function toggleLike(songId, songData = null) { 3456 let likedSongs = getLikedSongs(); 3457 const index = likedSongs.indexOf(songId); 3458 3459 if (index > -1) { 3460 // Remover like 3461 likedSongs.splice(index, 1); 3462 saveLikedSongs(likedSongs); 3463 3464 // PREPARACIÃN BACKEND: Si tienes una función para sync, descomentala: 3465 // sendLikeToBackend(songId, false); 3466 3467 return false;
3468 } else { 3469 // Agregar like 3470 likedSongs.push(songId); 3471 saveLikedSongs(likedSongs); 3472 3473 // PREPARACIÃN BACKEND: Guardar metadata adicional para futura sincronización 3474 if (songData) { 3475 saveSongMetadata(songId, songData); 3476 } 3477 3478 // PREPARACIÃN BACKEND: Si tienes una función para sync, descomentala: 3479 // sendLikeToBackend(songId, true, songData); 3480 3481 return true; 3482 } 3483 } 3484 3485 // PREPARACIÃN BACKEND: Guardar metadata de canciones likeadas 3486 function saveSongMetadata(songId, songData) { 3487 const metadata = localStorage.getItem('wxli-songs-metadata'); 3488 const metadataObj = metadata ? JSON.parse(metadata) : {}; 3489 3490 metadataObj[songId] = { 3491 title: songData.title || 'Unknown', 3492 artist: songData.artist || 'Unknown', 3493 art: songData.art || '', 3494 likedAt: Date.now() 3495 }; 3496 3497 localStorage.setItem('wxli-songs-metadata', JSON.stringify(metadataObj)); 3498 } 3499 3500 // PREPARACIÃN BACKEND: Función para enviar likes al servidor (implementar cuando tengas API) 3501 /* 3502 async function sendLikeToBackend(songId, isLiked, songData = null) { 3503 try { 3504 const response = await fetch('/api/likes', { 3505 method: 'POST', 3506 headers: { 'Content-Type': 'application/json' }, 3507 body: JSON.stringify({ 3508 songId: songId, 3509 action: isLiked ? 'like' : 'unlike', 3510 metadata: songData ? { 3511 title: songData.title, 3512 artist: songData.artist, 3513 art: songData.art 3514 } : null, 3515 timestamp: Date.now() 3516 }) 3517 }); 3518 3519 if (response.ok) { 3520 } 3521 } catch (error) { 3522 } 3523 } 3524 */ 3525 3526 function handleLikeClick(event) { 3527 const button = event.currentTarget; 3528 const songId = button.getAttribute('data-song-id'); 3529 const icon = button.querySelector('i'); 3530 3531 // Toggle like 3532 const isLiked = toggleLike(songId); 3533 3534 // Actualizar icono 3535 if (isLiked) { 3536 icon.classList.remove('far'); 3537 icon.classList.add('fas'); 3538 button.classList.add('liked'); 3539 } else { 3540 icon.classList.remove('fas'); 3541 icon.classList.add('far'); 3542 button.classList.remove('liked'); 3543 } 3544 3545 // Animación de pulso 3546 button.style.transform = 'scale(1.3)'; 3547 setTimeout(() => { 3548 button.style.transform = 'scale(1)'; 3549 }, 200); 3550 3551 // Sincronizar con el reproductor principal si es la misma canción 3552 const playerLikeBtn = document.getElementById('player-like-btn'); 3553 if (playerLikeBtn && playerLikeBtn.getAttribute('data-song-id') === songId) { 3554 const playerIcon = playerLikeBtn.querySelector('i'); 3555 if (isLiked) { 3556 playerIcon.classList.remove('far'); 3557 playerIcon.classList.add('fas'); 3558 playerLikeBtn.classList.add('liked'); 3559 } else { 3560 playerIcon.classList.remove('fas'); 3561 playerIcon.classList.add('far'); 3562 playerLikeBtn.classList.remove('liked'); 3563 } 3564 } 3565 } 3566 3567 // Helper to create song HTML element 3568 function createSongElement(item, index) { 3569 const song = item.song; 3570 const playedAt = new Date(item.played_at * 1000); 3571 const timeAgo = getTimeAgo(playedAt); 3572 3573 const songId = song.id || `${song.title}-${song.artist}`.replace(/[^a-zA-Z0-9]/g, '-'); 3574 const isLiked = isSongLiked(songId); 3575 const heartClass = isLiked ? 'fas' : 'far'; 3576 const likedClass = isLiked ? 'liked' : ''; 3577 3578 let cleanArtUrl = null; 3579 if (song.art) { 3580 if (song.art.startsWith('/')) { 3581 cleanArtUrl = 'https://api.core.wxli.ai' + song.art.replace('/api/station/', '/station/'); 3582 } else { 3583 cleanArtUrl = song.art.replace('/api/station/', '/station/'); 3584 } 3585 } 3586 3587 const div = document.createElement('div'); 3588 div.className = 'single-audio-player style-two style-sm'; 3589 div.setAttribute('data-song-id', songId); 3590 div.style.cursor = 'pointer'; 3591 const fallbackSrc = 'https://wxli.ai/assets/images/shows/player/logo.svg'; 3592 const isEsLabel = document.documentElement.lang === 'es'; 3593 const buyQuery = encodeURIComponent(`${song.artist || ''} ${song.title || ''}`.trim()); 3594 const beatportUrl = `https://www.beatport.com/search?q=${buyQuery}`; 3595 div.innerHTML = ` 3596 <div class="single-audio-thumb"> 3597 <img class="sa-art" alt=""> 3598 </div> 3599 <div class="single-audio-content"> 3600 <div class="single-audio-content-top"> 3601 <h4 class="title"><span class="ptxt-t"></span></h4> 3602 <button type="button" class="bookmark-icon ${likedClass}"><i class="${heartClass} fa-heart"></i></button> 3603 </div> 3604 <p style="margin: 5px 0; color: #c5c5c5; font-size: 16px;"><span class="ptxt-a"></span></p> 3605 <p class="sa-time" style="margin: 0; color: #999; font-size: 11px;">
3606 <span class="sa-time-text"></span> 3607 <a class="sa-buy-link" href="${beatportUrl}" target="_blank" rel="noopener noreferrer" aria-label="${isEsLabel ? 'Buscar en Beatport' : 'Find on Beatport'}"><i class="fas fa-shopping-bag" aria-hidden="true"></i> Beatport</a> 3608 </p> 3609 </div> 3610 `; 3611 const artImg = div.querySelector('.sa-art'); 3612 artImg.src = cleanArtUrl || fallbackSrc; 3613 artImg.onerror = function() { this.onerror = null; this.src = fallbackSrc; }; 3614 div.querySelector('.ptxt-t').textContent = '\u201C' + (song.title || 'Sin tÃtulo') + '\u201D'; 3615 div.querySelector('.ptxt-a').textContent = (song.artist || 'Artista desconocido') + (song.album ? ' \u2022 ' + song.album : ''); 3616 div.querySelector('.sa-time-text').textContent = timeAgo; 3617 3618 const likeButton = div.querySelector('.bookmark-icon'); 3619 likeButton.setAttribute('data-song-id', songId); 3620 likeButton.addEventListener('click', handleLikeClick); 3621 3622 div.querySelectorAll('.sa-buy-link').forEach(function(link) { 3623 link.addEventListener('click', function(e) { e.stopPropagation(); }); 3624 }); 3625 3626 // Click en cualquier parte del card da like (excepto si ya se hizo click en el botón o en un link de compra) 3627 div.addEventListener('click', function(e) { 3628 if (!e.target.closest('.bookmark-icon') && !e.target.closest('.sa-buy-link')) { 3629 likeButton.click(); 3630 } 3631 }); 3632 3633 return { element: div, songId }; 3634 } 3635 3636 async function loadLatestSongs() { 3637 try { 3638 // Usar cache global para evitar llamadas duplicadas 3639 const data = await wxliFetchNowPlaying(); 3640 3641 if (!latestSongsContainer) return; 3642 3643 const songs = data.song_history ? data.song_history.slice(0, 5) : []; 3644 3645 if (songs.length === 0) { 3646 latestSongsContainer.innerHTML = '<div class="text-center" style="padding: 40px; color: #c5c5c5;"><p>No hay canciones disponibles en el historial.</p></div>'; 3647 currentSongIds = []; 3648 return; 3649 } 3650 3651 // Build new song list 3652 const newSongData = songs.map((item, index) => { 3653 const song = item.song; 3654 const songId = song.id || `${song.title}-${song.artist}`.replace(/[^a-zA-Z0-9]/g, '-'); 3655 return { item, index, songId }; 3656 }); 3657 3658 const newSongIds = newSongData.map(s => s.songId); 3659 3660 // First load - no animation needed 3661 if (currentSongIds.length === 0) { 3662 latestSongsContainer.innerHTML = ''; 3663 newSongData.forEach(({ item, index }) => { 3664 const { element } = createSongElement(item, index); 3665 latestSongsContainer.appendChild(element); 3666 }); 3667 currentSongIds = newSongIds; 3668 return; 3669 } 3670 3671 // Check if there are new songs at the beginning 3672 const newSongsCount = newSongIds.findIndex(id => currentSongIds.includes(id)); 3673 3674 // If no changes, just update time ago without animation 3675 if (JSON.stringify(newSongIds) === JSON.stringify(currentSongIds)) { 3676 const existingCards = latestSongsContainer.querySelectorAll('.single-audio-player'); 3677 existingCards.forEach((card, index) => { 3678 if (newSongData[index]) { 3679 const timeAgoEl = card.querySelector('.sa-time-text'); 3680 if (timeAgoEl) { 3681 const playedAt = new Date(newSongData[index].item.played_at * 1000); 3682 timeAgoEl.textContent = getTimeAgo(playedAt); 3683 } 3684 } 3685 }); 3686 return; 3687 } 3688 3689 // Smart replace with animation 3690 const actualNewCount = newSongsCount === -1 ? newSongIds.length : newSongsCount; 3691 3692 if (actualNewCount > 0) { 3693 const existingCards = Array.from(latestSongsContainer.querySelectorAll('.single-audio-player')); 3694 const cardsToRemove = existingCards.slice(-(actualNewCount)); 3695 const remainingCards = existingCards.slice(0, existingCards.length - actualNewCount); 3696 3697 // FLIP Step 1: medir posiciones reales ANTES de cualquier cambio DOM 3698 const firstPositions = remainingCards.map(card => card.getBoundingClientRect().top); 3699 3700 // Exit animation 3701 cardsToRemove.forEach(card => card.classList.add('song-exiting')); 3702 await new Promise(resolve => setTimeout(resolve, 300)); 3703 cardsToRemove.forEach(card => card.remove()); 3704 3705 // Insertar nuevas cards 3706 const fragment = document.createDocumentFragment(); 3707 for (let i = 0; i < actualNewCount; i++) { 3708 const { item, index } = newSongData[i]; 3709 const { element } = createSongElement(item, index); 3710 element.classList.add('song-entering'); 3711 fragment.appendChild(element); 3712 } 3713 latestSongsContainer.insertBefore(fragment, latestSongsContainer.firstChild); 3714 3715 // FLIP Step 2: medir nuevas posiciones en un solo batch (un solo reflow) 3716 const lastPositions = remainingCards.map(card => card.getBoundingClientRect().top); 3717 const deltas = remainingCards.map((_, i) => firstPositions[i] - lastPositions[i]); 3718 3719 // FLIP Step 3: aplicar transforms inversos sin transición (snap instantáneo) 3720 remainingCards.forEach((card, i) => {
3721 card.style.transition = 'none'; 3722 card.style.transform = `translateY(${deltas[i]}px)`; 3723 }); 3724 3725 // FLIP Step 4: usar doble rAF para garantizar que el frame con el transform 3726 // inverso se pinte antes de activar la transición hacia la posición final. 3727 // offsetHeight solo no es suficiente â el navegador puede colapsar ambos 3728 // cambios en el mismo frame y no animar nada. 3729 requestAnimationFrame(() => { 3730 requestAnimationFrame(() => { 3731 remainingCards.forEach(card => { 3732 card.style.transition = 'transform 0.4s cubic-bezier(0.4, 0, 0.2, 1)'; 3733 card.style.transform = 'translateY(0)'; 3734 }); 3735 3736 // Limpiar estilos inline al terminar (después de que empiece la transición) 3737 setTimeout(() => { 3738 remainingCards.forEach(card => { 3739 card.style.transform = ''; 3740 card.style.transition = ''; 3741 }); 3742 latestSongsContainer.querySelectorAll('.song-entering').forEach(card => { 3743 card.classList.remove('song-entering'); 3744 }); 3745 }, 450); 3746 }); 3747 }); 3748 3749 } else { 3750 // Complete refresh needed (order changed significantly) 3751 latestSongsContainer.innerHTML = ''; 3752 newSongData.forEach(({ item, index }) => { 3753 const { element } = createSongElement(item, index); 3754 latestSongsContainer.appendChild(element); 3755 }); 3756 } 3757 3758 currentSongIds = newSongIds; 3759 3760 } catch (error) { 3761 if (latestSongsContainer) { 3762 latestSongsContainer.innerHTML = '<div class="text-center" style="padding: 40px; color: #c5c5c5;"><p>Signal archive unavailable.</p></div>'; 3763 } 3764 } 3765 } 3766 3767 function getTimeAgo(date) { 3768 const now = new Date(); 3769 const diffInSeconds = Math.floor((now - date) / 1000); 3770 3771 if (diffInSeconds < 60) { 3772 return diffInSeconds + 's ago'; 3773 } else if (diffInSeconds < 3600) { 3774 const minutes = Math.floor(diffInSeconds / 60); 3775 return minutes + 'm ago'; 3776 } else if (diffInSeconds < 86400) { 3777 const hours = Math.floor(diffInSeconds / 3600); 3778 return hours + 'h ago'; 3779 } else { 3780 const days = Math.floor(diffInSeconds / 86400); 3781 return days + 'd ago'; 3782 } 3783 } 3784 3785 // Cargar canciones al cargar la página 3786 if (document.readyState === 'loading') { 3787 document.addEventListener('DOMContentLoaded', loadLatestSongs); 3788 } else { 3789 loadLatestSongs(); 3790 } 3791 3792 // Función para programar siguiente actualización con jitter 3793 let latestSongsTimeout = null; 3794 const LATEST_SONGS_BASE_INTERVAL = 30000; // 30 segundos base 3795 const LATEST_SONGS_JITTER = 3000; // Hasta 3 segundos de jitter 3796 3797 function scheduleLatestSongsUpdate() { 3798 if (latestSongsTimeout) { 3799 clearTimeout(latestSongsTimeout); 3800 } 3801 // Agregar jitter aleatorio para evitar sincronización entre usuarios 3802 const jitter = Math.random() * LATEST_SONGS_JITTER; 3803 const nextUpdate = LATEST_SONGS_BASE_INTERVAL + jitter; 3804 3805 latestSongsTimeout = setTimeout(function() { 3806 loadLatestSongs(); 3807 scheduleLatestSongsUpdate(); // Programar siguiente actualización 3808 }, nextUpdate); 3809 } 3810 3811 // Iniciar el ciclo de actualización 3812 scheduleLatestSongsUpdate(); 3813 3814 // Pausar/reanudar cuando la página está oculta (fix memory leak) 3815 document.addEventListener('visibilitychange', function() { 3816 if (document.hidden) { 3817 // Pausar cuando la página está oculta 3818 if (latestSongsTimeout) { 3819 clearTimeout(latestSongsTimeout); 3820 latestSongsTimeout = null; 3821 } 3822 } else { 3823 // Reanudar cuando vuelve a estar visible 3824 currentSongIds = []; 3825 loadLatestSongs(); 3826 scheduleLatestSongsUpdate(); 3827 } 3828 }); 3829 3830 })(); 3831 (function() { 3832 'use strict'; 3833 3834 const scheduleContainer = document.getElementById('schedule-container'); 3835 // scheduleAPI ahora se maneja en WXLICache.fetchSchedule() 3836 3837 // Detectar idioma del sitio 3838 const currentLang = document.documentElement.lang || 'es'; 3839 3840 // Traducciones para los countdowns 3841 const i18n = { 3842 es: { 3843 liveNow: 'EN VIVO', 3844 startsIn: 'Comienza en', 3845 endsIn: 'Tiempo restante de la sesión', 3846 starting: 'Iniciando...' 3847 }, 3848 en: { 3849 liveNow: 'LIVE NOW', 3850 startsIn: 'Starts in', 3851 endsIn: 'Time remaining in this session', 3852 starting: 'Starting...' 3853 } 3854 }; 3855 3856 // Obtener traducciones según idioma actual 3857 const t = i18n[currentLang] || i18n['es']; 3858 3859 // Imágenes por defecto para cualquier playlist 3860 const defaultPlaylistImages = { 3861 thumb: 'https://wxli.ai/assets/images/shows/podcast/wxli.png', 3862 artist: 'https://wxli.ai/assets/images/logo.svg' 3863 }; 3864 3865 // Ãltimo show conocido como en vivo (fallback durante transición de sesión) 3866 let lastKnownLiveShowName = null; 3867 3868 // Cache del último schedule + helper expuesto a updateNowPlaying para 3869 // confirmar que el slot PROGRAMADO de una sesión ya empezó antes de 3870 // adoptar su nombre de playlist. Evita que el header/comentario cambien 3871 // durante el overrun de la última canción de la sesión saliente. 3872 let latestScheduleData = []; 3873 const SESSION_SLOT_GRACE_MS = 10 * 60 * 1000; 3874 window.wxliSessionSlotStarted = function(name) { 3875 const target = (name || '').trim().toLowerCase(); 3876 if (!target) return false;
3877 const nowMs = Date.now(); 3878 return latestScheduleData.some(item => { 3879 if ((item.name || '').trim().toLowerCase() !== target) return false; 3880 const s = new Date(item.start).getTime(); 3881 const e = new Date(item.end).getTime(); 3882 return s <= nowMs && nowMs <= e + SESSION_SLOT_GRACE_MS; 3883 }); 3884 }; 3885 3886 // Función para agrupar shows únicos y obtener su horario general 3887 function getUniqueShows(scheduleData) { 3888 const uniqueShows = new Map(); 3889 3890 scheduleData.forEach(item => { 3891 if (!uniqueShows.has(item.name)) { 3892 // Primera vez que vemos este show 3893 uniqueShows.set(item.name, { 3894 name: item.name, 3895 title: item.title, 3896 description: item.description, 3897 start: item.start, 3898 end: item.end, 3899 is_now: item.is_now, 3900 allSlots: [{ start: item.start, end: item.end }] 3901 }); 3902 } else { 3903 // Show ya existe, agregar el slot de tiempo 3904 const show = uniqueShows.get(item.name); 3905 show.allSlots.push({ start: item.start, end: item.end }); 3906 3907 // Si este slot está sonando ahora, actualizar is_now 3908 if (item.is_now) { 3909 show.is_now = true; 3910 show.start = item.start; 3911 show.end = item.end; 3912 } 3913 } 3914 }); 3915 3916 return Array.from(uniqueShows.values()); 3917 } 3918 3919 async function loadSchedule() { 3920 try { 3921 // Obtener schedule desde ayer hasta 7 dÃas adelante para capturar el show actual y todos los shows 3922 const today = new Date(); 3923 const startDate = new Date(today); 3924 startDate.setDate(startDate.getDate() - 1); // Empezar desde ayer 3925 3926 const endDate = new Date(today); 3927 endDate.setDate(endDate.getDate() + 7); 3928 3929 const startStr = startDate.toISOString().split('T')[0]; 3930 const endStr = endDate.toISOString().split('T')[0]; 3931 3932 // Usar cache centralizado para evitar llamadas duplicadas 3933 const data = await WXLICache.fetchSchedule(startStr, endStr); 3934 3935 // Guardar para wxliSessionSlotStarted() â debe poblarse aunque no 3936 // exista el contenedor del schedule (la confirmación de sesión no 3937 // depende del render del listado). 3938 latestScheduleData = Array.isArray(data) ? data : []; 3939 3940 if (!scheduleContainer) return; 3941 3942 // Obtener shows únicos 3943 let uniqueShows = getUniqueShows(data); 3944 3945 if (uniqueShows.length === 0) { 3946 // Solo actualizar si el contenido es diferente 3947 const errorMessage = '<div class="col-12 text-center" style="padding: 40px; color: #c5c5c5;"><p>No hay programación disponible.</p></div>'; 3948 if (scheduleContainer.innerHTML !== errorMessage) { 3949 scheduleContainer.innerHTML = errorMessage; 3950 } 3951 return; 3952 } 3953 3954 // Determinar qué session está realmente en vivo según el track actual (no por hora) 3955 // currentPlaylistName viene sincronizado con el stream (el pooler maneja el delay) 3956 const currentPlaylist = (window.currentPlaylistName || '').trim().toLowerCase(); 3957 const nowMs = Date.now(); 3958 // Tolerancia para el delay de transmisión: una sesión sigue contando como 3959 // "en su ventana" hasta unos minutos después de su fin programado. 3960 const SLOT_GRACE_MS = 10 * 60 * 1000; 3961 3962 // ¿Este show tiene un slot que YA empezó y aún está dentro de ventana 3963 // (y no es uno futuro)? 3964 const hasStartedSlot = (show) => { 3965 const slots = (show.allSlots && show.allSlots.length) 3966 ? show.allSlots 3967 : [{ start: show.start, end: show.end }]; 3968 return slots.some(slot => { 3969 const s = new Date(slot.start).getTime(); 3970 const e = new Date(slot.end).getTime(); 3971 return s <= nowMs && nowMs <= e + SLOT_GRACE_MS; 3972 }); 3973 }; 3974 3975 // Sobreescribir is_now basándose en el playlist que realmente está sonando. 3976 // Además exigimos que el slot programado YA haya comenzado: asà NO mostramos
3977 // la próxima sesión como "EN VIVO" antes de que realmente arranque, aunque 3978 // el metadata del playlist cambie un poco antes del lÃmite programado. 3979 uniqueShows.forEach(show => { 3980 const showNameLower = (show.name || '').trim().toLowerCase(); 3981 show.is_now = showNameLower === currentPlaylist && hasStartedSlot(show); 3982 }); 3983 3984 // Si ningún show matchea el playlist actual (transición de sesión en curso), 3985 // mantener el último show conocido como "en vivo" para evitar que desaparezca 3986 // el overlay animado hasta que el nuevo match se confirme. 3987 const hasLiveMatch = uniqueShows.some(show => show.is_now); 3988 if (!hasLiveMatch && lastKnownLiveShowName) { 3989 uniqueShows.forEach(show => { 3990 if ((show.name || '').trim().toLowerCase() === lastKnownLiveShowName) { 3991 show.is_now = true; 3992 } 3993 }); 3994 } else if (hasLiveMatch) { 3995 lastKnownLiveShowName = currentPlaylist; 3996 } 3997 3998 // Ordenar shows: primero el que está en vivo, luego por próximo en sonar 3999 const now = new Date(); 4000 uniqueShows.sort((a, b) => { 4001 // Prioridad absoluta al playlist que está sonando (confirmado por nowplaying) 4002 if (a.is_now && !b.is_now) return -1; 4003 if (!a.is_now && b.is_now) return 1; 4004 4005 // Prioridad 2: Ordenar por próximo show en sonar 4006 // Buscar la próxima ocurrencia futura en allSlots 4007 const getNextOccurrence = (show) => { 4008 if (!show.allSlots || show.allSlots.length === 0) { 4009 return new Date(show.start); 4010 } 4011 4012 // Encontrar el próximo slot que aún no ha comenzado 4013 const futureSlots = show.allSlots 4014 .map(slot => new Date(slot.start)) 4015 .filter(startTime => startTime >= now) 4016 .sort((x, y) => x - y); 4017 4018 // Si hay slots futuros, retornar el más cercano 4019 if (futureSlots.length > 0) { 4020 return futureSlots[0]; 4021 } 4022 4023 // Si no hay slots futuros, todos ya pasaron - usar un tiempo muy grande 4024 return new Date(now.getTime() + (365 * 24 * 60 * 60 * 1000)); 4025 }; 4026 4027 const aNext = getNextOccurrence(a); 4028 const bNext = getNextOccurrence(b); 4029 4030 return aNext - bNext; 4031 }); 4032 4033 // Mostrar SOLO la sesión en vivo (sin "próxima sesión"). 4034 // uniqueShows ya viene ordenado con el show en vivo primero; el 4035 // fallback de lastKnownLiveShowName garantiza que durante una 4036 // transición siga habiendo un show marcado como en vivo. 4037 const liveShows = uniqueShows.filter(show => show.is_now); 4038 const showsToDisplay = (liveShows.length > 0 ? liveShows : uniqueShows).slice(0, 1); 4039 4040 // Crear un fragmento temporal para construir el nuevo HTML 4041 const newHTMLArray = []; 4042 4043 // Sanitize API strings for safe use in HTML attributes 4044 function escAttr(s) { 4045 return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>'); 4046 } 4047 4048 // Generar HTML para cada show 4049 showsToDisplay.forEach((show, index) => { 4050 const playlistName = show.name; 4051 const images = defaultPlaylistImages; 4052 4053 // Agregar indicador de "EN VIVO" basado en el playlist actual del stream 4054 // is_now ya fue sobreescrito arriba para reflejar el track real (no la hora) 4055 const isActuallyLive = show.is_now; 4056 4057 const isLiveClass = isActuallyLive ? 'is-live' : ''; 4058 4059 // Overlay animado solo para shows en vivo (mismo efecto que sponsor) 4060 const liveOverlay = isActuallyLive ? '<div class="live-animated-overlay"></div>' : ''; 4061 4062 // Calcular próxima ocurrencia para countdown 4063 let nextStart = null; 4064 let currentStart = null; 4065 let currentEnd = null; 4066 4067 if (isActuallyLive) { 4068 // Show en vivo - buscar el slot que contiene la hora actual (o el más reciente) 4069 if (show.allSlots && show.allSlots.length > 0) { 4070 const nowMs = now.getTime(); 4071 // Buscar slot que contenga la hora actual 4072 const activeSlot = show.allSlots.find(slot => { 4073 const s = new Date(slot.start).getTime(); 4074 const e = new Date(slot.end).getTime(); 4075 return nowMs >= s && nowMs <= e; 4076 }); 4077 if (activeSlot) { 4078 currentStart = new Date(activeSlot.start).getTime(); 4079 currentEnd = new Date(activeSlot.end).getTime(); 4080 } else { 4081 // No hay slot en hora actual (el stream aún lo reproduce por delay) 4082 // Usar el slot más reciente que ya terminó 4083 const pastSlots = show.allSlots 4084 .map(slot => ({ start: new Date(slot.start).getTime(), end: new Date(slot.end).getTime() })) 4085 .filter(slot => slot.end <= nowMs) 4086 .sort((a, b) => b.end - a.end); 4087 if (pastSlots.length > 0) { 4088 currentStart = pastSlots[0].start; 4089 currentEnd = pastSlots[0].end; 4090 } else { 4091 currentStart = new Date(show.start).getTime(); 4092 currentEnd = new Date(show.end).getTime(); 4093 } 4094 } 4095 } else { 4096 currentStart = new Date(show.start).getTime(); 4097 currentEnd = new Date(show.end).getTime(); 4098 } 4099 } else { 4100 // Buscar la próxima ocurrencia futura 4101 if (show.allSlots && show.allSlots.length > 0) { 4102 const futureSlots = show.allSlots 4103 .map(slot => new Date(slot.start)) 4104 .filter(startTime => startTime > now) 4105 .sort((x, y) => x - y); 4106 4107 if (futureSlots.length > 0) { 4108 nextStart = futureSlots[0].getTime(); 4109 } 4110 } 4111 } 4112 4113 // Badge y contador dinámico 4114 let countdownHTML = ''; 4115 if (isActuallyLive) { 4116 countdownHTML = ` 4117 <div class="session-countdown session-live" data-live-start="${currentStart}" data-live-end="${currentEnd}">
4118 <span class="countdown-badge live-badge">${t.liveNow}</span> 4119 <span class="countdown-timer" data-type="live-remaining" title="${t.endsIn}"></span> 4120 </div>`; 4121 } else if (nextStart) { 4122 countdownHTML = ` 4123 <div class="session-countdown session-upcoming" data-next-start="${nextStart}"> 4124 <span class="countdown-badge upcoming-badge"><i class="far fa-clock"></i> ${t.startsIn}</span> 4125 <span class="countdown-timer" data-type="remaining"></span> 4126 </div>`; 4127 } 4128 4129 const safePlaylistName = escAttr(playlistName); 4130 const showHTML = ` 4131 <div class="col-12 col-sm-8 col-md-6 col-lg-4" data-show-name="${safePlaylistName}"> 4132 <div class="podcast-item link-item ${isLiveClass}"> 4133 <div class="podcast-item-thumb"> 4134 ${liveOverlay} 4135 <img src="${images.thumb}" alt="${safePlaylistName}"> 4136 ${isActuallyLive ? '<div class="ai-comment-text" style="display:none"></div>' : ''} 4137 </div> 4138 <div class="podcast-item-content"> 4139 <div class="artist-thumb"> 4140 <img src="${images.artist}" alt="${safePlaylistName}"> 4141 </div> 4142 <div class="artist-content"> 4143 <h5 class="show-name" data-pname="${safePlaylistName}"></h5> 4144 ${countdownHTML} 4145 </div> 4146 </div> 4147 </div><!-- podcast-item end --> 4148 </div> 4149 `; 4150 4151 newHTMLArray.push(showHTML.trim()); 4152 }); 4153 4154 // Comparar el nuevo HTML con el existente para evitar parpadeos innecesarios 4155 const newHTML = newHTMLArray.join(''); 4156 4157 // Normalizar espacios en blanco para comparación más precisa 4158 const normalizeHTML = (html) => html.replace(/\s+/g, ' ').trim(); 4159 4160 // Usar lastHtml guardado (innerHTML ya contiene canvas, siempre diferirÃa) 4161 const lastHtml = scheduleContainer.dataset.lastHtml || ''; 4162 4163 if (normalizeHTML(newHTML) !== normalizeHTML(lastHtml)) { 4164 // Solo actualizar si hay cambios reales en el contenido 4165 scheduleContainer.dataset.lastHtml = newHTML; 4166 scheduleContainer.innerHTML = newHTML; 4167 // Renderizar nombres de sesiones como canvas (anti-bot) 4168 scheduleContainer.querySelectorAll('.show-name[data-pname]').forEach(function(el) { 4169 wxliProtectText(el, el.getAttribute('data-pname'), '#ffffff', 24, 'Lexend'); 4170 el.removeAttribute('data-pname'); 4171 }); 4172 } 4173 4174 } catch (error) { 4175 // Error al cargar la programación 4176 if (scheduleContainer) { 4177 const errorMessage = '<div class="col-12 text-center" style="padding: 40px; color: #c5c5c5;"><p>Unable to load sessions.</p></div>'; 4178 if (scheduleContainer.innerHTML !== errorMessage) { 4179 scheduleContainer.innerHTML = errorMessage; 4180 } 4181 } 4182 } 4183 } 4184 4185 // Cargar programación al cargar la página 4186 if (document.readyState === 'loading') { 4187 document.addEventListener('DOMContentLoaded', loadSchedule); 4188 } else { 4189 loadSchedule(); 4190 } 4191 4192 // Función para formatear tiempo en formato legible 4193 function formatCountdownTime(ms) { 4194 if (ms <= 0) return '00:00'; 4195 4196 const totalSeconds = Math.floor(ms / 1000); 4197 const hours = Math.floor(totalSeconds / 3600); 4198 const minutes = Math.floor((totalSeconds % 3600) / 60); 4199 const seconds = totalSeconds % 60; 4200 4201 if (hours > 0) { 4202 return `${hours}h ${minutes.toString().padStart(2, '0')}m ${seconds.toString().padStart(2, '0')}s`; 4203 } else if (minutes > 0) { 4204 return `${minutes}m ${seconds.toString().padStart(2, '0')}s`; 4205 } else { 4206 return `${seconds}s`; 4207 } 4208 } 4209 4210 // Actualizar todos los countdowns de sesiones 4211 function updateSessionCountdowns() { 4212 const now = Date.now(); 4213 const countdowns = document.querySelectorAll('.session-countdown'); 4214 4215 countdowns.forEach(countdown => { 4216 const timer = countdown.querySelector('.countdown-timer'); 4217 if (!timer) return; 4218 4219 const timerType = timer.getAttribute('data-type'); 4220 4221 if (timerType === 'live-remaining') { 4222 // Show en vivo - mostrar tiempo restante hasta que termine la sesión 4223 const liveEnd = parseInt(countdown.getAttribute('data-live-end'), 10); 4224 if (liveEnd) { 4225 const remaining = liveEnd - now; 4226 if (remaining > 0) { 4227 timer.textContent = formatCountdownTime(remaining); 4228 countdown.dataset.ended = ''; 4229 } else { 4230 // La sesión llegó a su fin programado. NO saltamos a la próxima 4231 // sesión hasta que realmente empiece a sonar (lo confirma el 4232 // handler de cambio de playlist). Mostramos 00:00 y refrescamos 4233 // una sola vez, para no entrar en un loop de recargas. 4234 timer.textContent = formatCountdownTime(0); 4235 if (countdown.dataset.ended !== '1') { 4236 countdown.dataset.ended = '1'; 4237 loadSchedule(); 4238 } 4239 } 4240 } 4241 } else if (timerType === 'elapsed') { 4242 // (legacy) Show en vivo - mostrar tiempo transcurrido 4243 const liveStart = parseInt(countdown.getAttribute('data-live-start'), 10); 4244 if (liveStart) {
4245 const elapsed = now - liveStart; 4246 timer.textContent = formatCountdownTime(elapsed); 4247 } 4248 } else if (timerType === 'remaining') { 4249 // Show próximo - mostrar countdown 4250 const nextStart = parseInt(countdown.getAttribute('data-next-start'), 10); 4251 if (nextStart) { 4252 const remaining = nextStart - now; 4253 if (remaining > 0) { 4254 timer.textContent = formatCountdownTime(remaining); 4255 } else { 4256 // El show deberÃa haber comenzado - recargar schedule 4257 timer.textContent = t.starting; 4258 loadSchedule(); 4259 } 4260 } 4261 } 4262 }); 4263 } 4264 4265 // Iniciar actualización de countdowns cada segundo 4266 let countdownInterval = null; 4267 4268 function startCountdownUpdates() { 4269 if (countdownInterval) return; 4270 updateSessionCountdowns(); // Actualizar inmediatamente 4271 countdownInterval = setInterval(updateSessionCountdowns, 1000); 4272 } 4273 4274 function stopCountdownUpdates() { 4275 if (countdownInterval) { 4276 clearInterval(countdownInterval); 4277 countdownInterval = null; 4278 } 4279 } 4280 4281 // Iniciar countdowns cuando la página esté lista 4282 if (document.readyState === 'loading') { 4283 document.addEventListener('DOMContentLoaded', startCountdownUpdates); 4284 } else { 4285 // Esperar un poco para que el schedule se cargue primero 4286 setTimeout(startCountdownUpdates, 500); 4287 } 4288 4289 // Pausar countdowns cuando la página está oculta para ahorrar recursos 4290 document.addEventListener('visibilitychange', function() { 4291 if (document.hidden) { 4292 stopCountdownUpdates(); 4293 } else { 4294 startCountdownUpdates(); 4295 } 4296 }); 4297 4298 // OPTIMIZACIÃN: Actualizar cada 1 hora con jitter para evitar sincronización entre usuarios 4299 // La actualización principal se hará cuando cambie el playlist en nowplaying 4300 let scheduleTimeout = null; 4301 const SCHEDULE_BASE_INTERVAL = 3600000; // 1 hora base 4302 const SCHEDULE_JITTER = 60000; // Hasta 1 minuto de jitter 4303 4304 function scheduleNextScheduleUpdate() { 4305 if (scheduleTimeout) { 4306 clearTimeout(scheduleTimeout); 4307 } 4308 // Agregar jitter aleatorio 4309 const jitter = Math.random() * SCHEDULE_JITTER; 4310 const nextUpdate = SCHEDULE_BASE_INTERVAL + jitter; 4311 4312 scheduleTimeout = setTimeout(function() { 4313 loadSchedule(); 4314 scheduleNextScheduleUpdate(); 4315 }, nextUpdate); 4316 } 4317 4318 // Iniciar el ciclo de actualización 4319 scheduleNextScheduleUpdate(); 4320 4321 // Actualizar schedule cuando la página vuelve de idle 4322 document.addEventListener('visibilitychange', function() { 4323 if (!document.hidden) { 4324 // Página visible de nuevo: actualizar schedule y reprogramar 4325 loadSchedule(); 4326 scheduleNextScheduleUpdate(); 4327 } else { 4328 // Página oculta: pausar el timeout para ahorrar recursos 4329 if (scheduleTimeout) { 4330 clearTimeout(scheduleTimeout); 4331 scheduleTimeout = null; 4332 } 4333 } 4334 }); 4335 4336 // Exponer función globalmente para que pueda ser llamada cuando cambie el playlist 4337 window.loadSchedule = loadSchedule; 4338 4339 })(); 4340 (function() { 4341 'use strict'; 4342 4343 let stickyPlayerContainer = null; 4344 let originalPlayer = null; 4345 let bannerPlayerContainer = null; 4346 let bannerSection = null; 4347 let isPlayerSticky = false; 4348 let isTransitioning = false; // Prevenir activaciones/desactivaciones simultáneas 4349 let playerPlaceholder = null; 4350 let ticking = false; 4351 4352 function initStickyPlayer() { 4353 // Obtener el reproductor original del banner 4354 originalPlayer = document.querySelector('#wxli-player'); 4355 bannerPlayerContainer = document.querySelector('.banner-bottom-player'); 4356 bannerSection = document.querySelector('#wxli-banner'); 4357 4358 if (!originalPlayer || !bannerSection || !bannerPlayerContainer) { 4359 return; 4360 } 4361 4362 // Crear el contenedor sticky en el header 4363 stickyPlayerContainer = document.createElement('div'); 4364 stickyPlayerContainer.className = 'player-sticky-container'; 4365 stickyPlayerContainer.style.position = 'fixed'; 4366 stickyPlayerContainer.style.willChange = 'transform'; 4367 document.body.insertBefore(stickyPlayerContainer, document.body.firstChild); 4368 4369 // Crear un contenedor interno con la clase container 4370 const containerDiv = document.createElement('div'); 4371 containerDiv.className = 'container'; 4372 stickyPlayerContainer.appendChild(containerDiv); 4373 4374 // Crear un placeholder para mantener el espacio en el banner 4375 playerPlaceholder = document.createElement('div'); 4376 playerPlaceholder.className = 'player-placeholder'; 4377 playerPlaceholder.style.display = 'none'; 4378 4379 // Usar getBoundingClientRect en lugar de offset para mejor precisión en iOS 4380 function checkStickyState() { 4381 const rect = bannerSection.getBoundingClientRect(); 4382 const bannerBottom = rect.bottom; 4383 4384 // Activar sticky cuando el banner está completamente fuera de vista 4385 if (bannerBottom <= 0 && !isPlayerSticky) { 4386 activateStickyPlayer(); 4387 } 4388 // Desactivar cuando el banner vuelve a estar visible 4389 else if (bannerBottom > 0 && isPlayerSticky) { 4390 deactivateStickyPlayer(); 4391 } 4392 } 4393 4394 // Configurar listeners globales con guard anti-duplicación (por si el script se re-inicializa) 4395 if (!window.__wxliStickyGlobal) { 4396 window.__wxliStickyGlobal = { 4397 listenersAdded: false, 4398 onScroll: null, 4399 checkStickyState: null 4400 }; 4401 } 4402 4403 // Actualizar referencias a los handlers actuales (si initStickyPlayer corre de nuevo) 4404 window.__wxliStickyGlobal.onScroll = onScroll; 4405 window.__wxliStickyGlobal.checkStickyState = checkStickyState; 4406 4407 if (!window.__wxliStickyGlobal.listenersAdded) { 4408 // Scroll listener con RAF (wrapper) 4409 window.addEventListener('scroll', function() { 4410 if (window.__wxliStickyGlobal.onScroll) { 4411 window.__wxliStickyGlobal.onScroll(); 4412 } 4413 }, { passive: true }); 4414 4415 // CRÃTICO para iOS: escuchar touchend (cuando el usuario suelta) 4416 window.addEventListener('touchend', function() { 4417 setTimeout(function() { 4418 if (window.__wxliStickyGlobal.checkStickyState) { 4419 window.__wxliStickyGlobal.checkStickyState(); 4420 } 4421 }, 50); 4422 }, { passive: true }); 4423 4424 // También en touchcancel 4425 window.addEventListener('touchcancel', function() { 4426 setTimeout(function() { 4427 if (window.__wxliStickyGlobal.checkStickyState) { 4428 window.__wxliStickyGlobal.checkStickyState(); 4429 } 4430 }, 50); 4431 }, { passive: true }); 4432 4433 // Recalcular en resize 4434 window.addEventListener('resize', function() { 4435 if (window.__wxliStickyGlobal.checkStickyState) { 4436 window.__wxliStickyGlobal.checkStickyState(); 4437 } 4438 }, { passive: true }); 4439 4440 // Recalcular cuando el usuario regresa a la página (sale y vuelve al móvil) 4441 document.addEventListener('visibilitychange', function() { 4442 if (!document.hidden) { 4443 setTimeout(function() { 4444 if (window.__wxliStickyGlobal.checkStickyState) { 4445 window.__wxliStickyGlobal.checkStickyState(); 4446 } 4447 }, 50); 4448 } 4449 }); 4450 4451 window.__wxliStickyGlobal.listenersAdded = true; 4452 } 4453// Función para manejar scroll 4454 function onScroll() { 4455 if (!ticking) { 4456 window.requestAnimationFrame(function() { 4457 checkStickyState(); 4458 ticking = false;
4459 }); 4460 ticking = true; 4461 } 4462 } 4463 } 4464 4465 function activateStickyPlayer() { 4466 // Prevenir activaciones simultáneas 4467 if (isTransitioning || isPlayerSticky) return; 4468 isTransitioning = true; 4469 4470 // Obtener la altura del reproductor antes de moverlo 4471 const playerHeight = originalPlayer.offsetHeight; 4472 playerPlaceholder.style.height = playerHeight + 'px'; 4473 4474 // Insertar placeholder en el lugar del reproductor 4475 originalPlayer.parentNode.insertBefore(playerPlaceholder, originalPlayer); 4476 playerPlaceholder.style.display = 'block'; 4477 4478 // Mover el reproductor al contenedor sticky 4479 stickyPlayerContainer.querySelector('.container').appendChild(originalPlayer); 4480 4481 // Los badges ya están en el HTML correcto (title-badges-container) 4482 // No necesitamos moverlos 4483 4484 // Activar el sticky con un pequeño delay para asegurar transición suave en iOS 4485 requestAnimationFrame(function() { 4486 stickyPlayerContainer.classList.add('active'); 4487 document.body.classList.add('player-is-sticky'); 4488 isPlayerSticky = true; 4489 // Permitir nuevas transiciones después de que termine la animación CSS 4490 setTimeout(function() { 4491 isTransitioning = false; 4492 }, 400); 4493 }); 4494 } 4495 4496 function deactivateStickyPlayer() { 4497 // Prevenir desactivaciones simultáneas 4498 if (isTransitioning || !isPlayerSticky) return; 4499 isTransitioning = true; 4500 4501 // Los badges ya están en el HTML correcto (title-badges-container) 4502 // No necesitamos moverlos de vuelta 4503 4504 // Marcar como no sticky inmediatamente para evitar estados inconsistentes 4505 isPlayerSticky = false; 4506 document.body.classList.remove('player-is-sticky'); 4507 4508 // Guardar referencia al reproductor antes de moverlo 4509 const playerToMove = originalPlayer; 4510 4511 // Asegurar que el reproductor sea visible antes de moverlo 4512 playerToMove.style.visibility = 'visible'; 4513 playerToMove.style.opacity = '1'; 4514 4515 // Devolver el reproductor a su posición original 4516 playerPlaceholder.parentNode.insertBefore(playerToMove, playerPlaceholder); 4517 playerPlaceholder.style.display = 'none'; 4518 4519 // Forzar reflow para asegurar que el navegador procese el cambio de DOM 4520 void playerToMove.offsetHeight; 4521 4522 // Desactivar el sticky container con requestAnimationFrame para sincronizar con el render 4523 requestAnimationFrame(function() { 4524 stickyPlayerContainer.classList.remove('active'); 4525 4526 // Segundo requestAnimationFrame para asegurar que los estilos se aplicaron 4527 requestAnimationFrame(function() { 4528 // Verificar que el reproductor esté visible en su nueva posición 4529 playerToMove.style.visibility = ''; 4530 playerToMove.style.opacity = ''; 4531 // Permitir nuevas transiciones después de que termine la animación CSS 4532 setTimeout(function() { 4533 isTransitioning = false; 4534 }, 400); 4535 }); 4536 }); 4537 } 4538 4539 // Inicializar cuando el DOM esté listo 4540 if (document.readyState === 'loading') { 4541 document.addEventListener('DOMContentLoaded', initStickyPlayer); 4542 } else { 4543 initStickyPlayer(); 4544 } 4545 4546 })(); 4547 (function() { 4548 'use strict'; 4549 4550 let ticking = false; 4551 function updateNavbarVisibility() { 4552 const scrollY = window.pageYOffset || window.scrollY || document.documentElement.scrollTop; 4553 4554 // Si estamos en el top (primeros 50px), mostrar navbar siempre 4555 if (scrollY <= 50) { 4556 document.body.classList.add('at-top'); 4557 } else { 4558 document.body.classList.remove('at-top'); 4559 } 4560 4561 } 4562 4563 function onScroll() { 4564 if (!ticking) { 4565 window.requestAnimationFrame(function() { 4566 updateNavbarVisibility(); 4567 ticking = false; 4568 }); 4569 ticking = true; 4570 } 4571 } 4572 4573 // Escuchar scroll 4574 window.addEventListener('scroll', onScroll, { passive: true }); 4575 4576 // CRÃTICO para iOS: escuchar eventos táctiles 4577 window.addEventListener('touchend', function() { 4578 setTimeout(updateNavbarVisibility, 50); 4579 }, { passive: true }); 4580 4581 window.addEventListener('touchcancel', function() { 4582 setTimeout(updateNavbarVisibility, 50); 4583 }, { passive: true }); 4584 4585 // Estado inicial 4586 updateNavbarVisibility(); 4587 })(); 4588 (function() { 4589 'use strict'; 4590 4591 function initLogoClick() { 4592 // Esperar un momento para que el sticky player se cree 4593 setTimeout(() => { 4594 const stickyLogo = document.getElementById('sticky-logo-link'); 4595 4596 if (stickyLogo) { 4597 stickyLogo.addEventListener('click', function(e) { 4598 e.preventDefault(); 4599 4600 // Smooth scroll al top de la página 4601 window.scrollTo({ 4602 top: 0, 4603 behavior: 'smooth' 4604 }); 4605 }); 4606 } 4607 }, 500); 4608 } 4609 4610 // Inicializar cuando el DOM esté listo 4611 if (document.readyState === 'loading') { 4612 document.addEventListener('DOMContentLoaded', initLogoClick); 4613 } else { 4614 initLogoClick(); 4615 } 4616 4617 })(); 4618 (function() { 4619 'use strict'; 4620 4621 const form = document.getElementById('form'); 4622 if (!form) return; // El formulario no existe en esta página 4623 const submitBtn = form.querySelector('button[type="submit"]'); 4624 4625 // Función para mostrar notificaciones 4626 function showNotification(type, title, message) { 4627 // Crear el elemento de notificación 4628 const notification = document.createElement('div'); 4629 notification.className = `form-notification ${type}`; 4630 4631 const icon = type === 'success' ? 'â' : 'â'; 4632 4633 notification.innerHTML = ` 4634 <div class="form-notification-content"> 4635 <div class="form-notification-icon">${icon}</div> 4636 <div class="form-notification-text"> 4637 <h4 class="form-notification-title"></h4> 4638 <p class="form-notification-message"></p> 4639 </div> 4640 </div> 4641 <button class="form-notification-close" aria-label="Close">Ã</button> 4642 <div class="form-notification-progress"></div> 4643 `; 4644 notification.querySelector('.form-notification-title').textContent = title; 4645 notification.querySelector('.form-notification-message').textContent = message; 4646 4647 // Agregar al body 4648 document.body.appendChild(notification); 4649 4650 // Mostrar la notificación 4651 setTimeout(() => { 4652 notification.classList.add('show'); 4653 }, 100); 4654 4655 // Botón de cerrar 4656 const closeBtn = notification.querySelector('.form-notification-close'); 4657 closeBtn.addEventListener('click', () => { 4658 removeNotification(notification); 4659 }); 4660 4661 // Auto-cerrar después de 8 segundos 4662 setTimeout(() => { 4663 removeNotification(notification); 4664 }, 8000); 4665 } 4666 4667 function removeNotification(notification) { 4668 notification.classList.remove('show'); 4669 setTimeout(() => { 4670 if (notification.parentNode) { 4671 notification.parentNode.removeChild(notification); 4672 } 4673 }, 400); 4674 } 4675 4676 // Validación adicional de campos 4677 function validateForm() { 4678 const name = form.querySelector('[name="name"]').value.trim(); 4679 const email = form.querySelector('[name="email"]').value.trim(); 4680 const message = form.querySelector('[name="message"]').value.trim(); 4681 4682 // Validar que no haya caracteres sospechosos 4683 const dangerousPatterns = /<script|javascript:|onerror=|onclick=/i; 4684 4685 if (dangerousPatterns.test(name) || dangerousPatterns.test(email) || dangerousPatterns.test(message)) { 4686 showNotification( 4687 'error', 4688 'Contenido No Permitido', 4689 'Se detectó contenido no permitido en el formulario.' 4690 ); 4691 return false;
4692 } 4693 4694 return true; 4695 } 4696 4697 form.addEventListener('submit', async (e) => { 4698 e.preventDefault(); 4699 4700 // Validar antes de enviar 4701 if (!validateForm()) { 4702 return; 4703 } 4704 4705 const formData = new FormData(form); 4706 formData.append("access_key", "fd9a20d3-8eda-4e6e-b103-3289fa6d1f94"); 4707 4708 const originalText = submitBtn.textContent; 4709 4710 submitBtn.textContent = "Sending..."; 4711 submitBtn.disabled = true; 4712 4713 try { 4714 const response = await fetch("https://api.web3forms.com/submit", { 4715 method: "POST", 4716 body: formData 4717 }); 4718 4719 const data = await response.json(); 4720 4721 if (response.ok) { 4722 showNotification( 4723 'success', 4724 '¡Mensaje Enviado!', 4725 'Gracias por tu interés. Te contactaremos pronto.' 4726 ); 4727 form.reset(); 4728 } else { 4729 showNotification( 4730 'error', 4731 'Error al Enviar', 4732 data.message || 'Hubo un problema al enviar el mensaje.' 4733 ); 4734 } 4735 4736 } catch (error) { 4737 showNotification( 4738 'error', 4739 'Error de Conexión', 4740 'No se pudo conectar con el servidor. Por favor, intenta nuevamente.' 4741 ); 4742 } finally { 4743 submitBtn.textContent = originalText; 4744 submitBtn.disabled = false; 4745 } 4746 }); 4747 4748 })(); 4749 (function() { 4750 'use strict'; 4751 4752 const subscribeForm = document.getElementById('subscribe-form'); 4753 if (!subscribeForm) return; 4754 const subscribeBtn = subscribeForm.querySelector('button[type="submit"]'); 4755 4756 // Reutilizar las funciones de notificación del formulario de sponsor 4757 function showNotification(type, title, message) { 4758 const notification = document.createElement('div'); 4759 notification.className = `form-notification ${type}`; 4760 4761 const icon = type === 'success' ? 'â' : 'â'; 4762 4763 notification.innerHTML = ` 4764 <div class="form-notification-content"> 4765 <div class="form-notification-icon">${icon}</div> 4766 <div class="form-notification-text"> 4767 <h4 class="form-notification-title"></h4> 4768 <p class="form-notification-message"></p> 4769 </div> 4770 </div> 4771 <button class="form-notification-close" aria-label="Close">Ã</button> 4772 <div class="form-notification-progress"></div> 4773 `; 4774 notification.querySelector('.form-notification-title').textContent = title; 4775 notification.querySelector('.form-notification-message').textContent = message; 4776 4777 document.body.appendChild(notification); 4778 4779 setTimeout(() => { 4780 notification.classList.add('show'); 4781 }, 100); 4782 4783 const closeBtn = notification.querySelector('.form-notification-close'); 4784 closeBtn.addEventListener('click', () => { 4785 removeNotification(notification); 4786 }); 4787 4788 setTimeout(() => { 4789 removeNotification(notification); 4790 }, 8000); 4791 } 4792 4793 function removeNotification(notification) { 4794 notification.classList.remove('show'); 4795 setTimeout(() => { 4796 if (notification.parentNode) { 4797 notification.parentNode.removeChild(notification); 4798 } 4799 }, 400); 4800 } 4801 4802 // Validación de seguridad para email 4803 function validateSubscribeForm() { 4804 const email = subscribeForm.querySelector('[name="email"]').value.trim(); 4805 4806 // Validar que no haya caracteres sospechosos 4807 const dangerousPatterns = /<script|javascript:|onerror=|onclick=/i; 4808 4809 if (dangerousPatterns.test(email)) { 4810 showNotification( 4811 'error', 4812 'Email No Válido', 4813 'Se detectó contenido no permitido en el email.' 4814 ); 4815 return false; 4816 } 4817 4818 return true; 4819 } 4820 4821 subscribeForm.addEventListener('submit', async (e) => { 4822 e.preventDefault(); 4823 4824 // Validar antes de enviar 4825 if (!validateSubscribeForm()) { 4826 return; 4827 } 4828 4829 const formData = new FormData(subscribeForm); 4830 formData.append("access_key", "fd9a20d3-8eda-4e6e-b103-3289fa6d1f94"); 4831 formData.append("subject", "Nueva Suscripción - WXLI"); 4832 4833 const originalText = subscribeBtn.textContent; 4834 4835 subscribeBtn.textContent = "Enviando..."; 4836 subscribeBtn.disabled = true; 4837 4838 try { 4839 const response = await fetch("https://api.web3forms.com/submit", { 4840 method: "POST", 4841 body: formData 4842 }); 4843 4844 const data = await response.json(); 4845 4846 if (response.ok) { 4847 showNotification( 4848 'success', 4849 '¡Suscripción Exitosa!', 4850 'Te has unido a nuestra comunidad. ¡Prepárate para premios y accesos exclusivos!' 4851 ); 4852 subscribeForm.reset(); 4853 } else { 4854 showNotification( 4855 'error', 4856 'Error al Suscribirse', 4857 data.message || 'Hubo un problema con tu suscripción.' 4858 ); 4859 } 4860 4861 } catch (error) { 4862 showNotification( 4863 'error', 4864 'Error de Conexión',
4865 'No se pudo procesar tu suscripción. Por favor, intenta nuevamente.' 4866 ); 4867 } finally { 4868 subscribeBtn.textContent = originalText; 4869 subscribeBtn.disabled = false; 4870 } 4871 }); 4872 4873 })(); 4874 4875// ============================================ 4876// Progressive Loading - Intersection Observer 4877// iOS/Safari Compatible Version 4878// ============================================ 4879(function() { 4880 'use strict'; 4881 4882 // Configuration 4883 var LAZY_LOAD_CONFIG = { 4884 rootMargin: '100px 0px', 4885 threshold: 0.01 4886 }; 4887 4888 // Lazy load images with fade-in effect 4889 function initLazyImages() { 4890 var lazyImages = document.querySelectorAll('img.lazy-img:not(.loaded)'); 4891 4892 // iOS Safari supports IntersectionObserver since iOS 12.2 4893 if ('IntersectionObserver' in window) { 4894 var imageObserver = new IntersectionObserver(function(entries, observer) { 4895 entries.forEach(function(entry) { 4896 if (entry.isIntersecting) { 4897 var img = entry.target; 4898 4899 // If image has data-src, use it 4900 if (img.dataset.src) { 4901 img.src = img.dataset.src; 4902 img.removeAttribute('data-src'); 4903 } 4904 4905 // Add loaded class after image loads 4906 if (img.complete && img.naturalHeight !== 0) { 4907 img.classList.add('loaded'); 4908 } else { 4909 img.addEventListener('load', function() { 4910 img.classList.add('loaded'); 4911 }); 4912 img.addEventListener('error', function() { 4913 img.classList.add('loaded'); 4914 }); 4915 } 4916 4917 observer.unobserve(img); 4918 } 4919 }); 4920 }, LAZY_LOAD_CONFIG); 4921 4922 lazyImages.forEach(function(img) { 4923 imageObserver.observe(img); 4924 }); 4925 } else { 4926 // Fallback for older browsers 4927 lazyImages.forEach(function(img) { 4928 if (img.dataset.src) { 4929 img.src = img.dataset.src; 4930 } 4931 img.classList.add('loaded'); 4932 }); 4933 } 4934 } 4935 4936 // Lazy load videos with Intersection Observer (iOS compatible) 4937 function initLazyVideos() { 4938 var lazyVideos = document.querySelectorAll('video.lazy-video:not(.loaded)'); 4939 4940 if ('IntersectionObserver' in window) { 4941 var videoObserver = new IntersectionObserver(function(entries, observer) { 4942 entries.forEach(function(entry) { 4943 if (entry.isIntersecting) { 4944 var video = entry.target; 4945 loadVideo(video); 4946 observer.unobserve(video); 4947 } 4948 }); 4949 }, { 4950 rootMargin: '200px 0px', 4951 threshold: 0.01 4952 }); 4953 4954 lazyVideos.forEach(function(video) { 4955 videoObserver.observe(video); 4956 }); 4957 } else { 4958 // Fallback 4959 lazyVideos.forEach(function(video) { 4960 loadVideo(video); 4961 }); 4962 } 4963 } 4964 4965 // Load a single video (iOS compatible) 4966 function loadVideo(video) { 4967 // Load video sources 4968 var sources = video.querySelectorAll('source[data-src]'); 4969 sources.forEach(function(source) { 4970 source.src = source.dataset.src; 4971 source.removeAttribute('data-src'); 4972 }); 4973 4974 // If video has data-src attribute directly 4975 if (video.dataset.src) { 4976 video.src = video.dataset.src; 4977 video.removeAttribute('data-src'); 4978 } 4979 4980 // iOS requires user interaction for video playback 4981 // Use 'canplay' event which fires earlier and is more reliable on iOS 4982 var loadHandler = function() { 4983 video.classList.add('loaded'); 4984 4985 // For background videos with autoplay 4986 if (video.hasAttribute('autoplay') && video.hasAttribute('muted') && video.hasAttribute('playsinline')) { 4987 // iOS allows autoplay for muted inline videos 4988 var playPromise = video.play(); 4989 if (playPromise !== undefined) { 4990 playPromise.catch(function(e) { 4991 // Autoplay was prevented - this is expected on some iOS versions 4992 // Video will still be visible, just paused 4993 }); 4994 } 4995 } 4996 }; 4997 4998 // Use multiple events for better iOS compatibility 4999 video.addEventListener('canplay', loadHandler, { once: true }); 5000 video.addEventListener('loadedmetadata', function() { 5001 // Backup: mark as loaded even if canplay doesn't fire 5002 setTimeout(function() { 5003 if (!video.classList.contains('loaded')) { 5004 video.classList.add('loaded'); 5005 } 5006 }, 500); 5007 }, { once: true }); 5008 5009 // Load the video 5010 video.load(); 5011
5012 // Fallback timeout for slow connections 5013 setTimeout(function() { 5014 if (!video.classList.contains('loaded')) { 5015 video.classList.add('loaded'); 5016 } 5017 }, 5000); 5018 } 5019 5020 // Load banner video immediately (above the fold) - iOS compatible 5021 function loadBannerVideo() { 5022 var bannerVideo = document.getElementById('banner-video'); 5023 if (!bannerVideo) return; 5024 5025 // Cargar TODAS las fuentes (webm + mp4 fallback para Safari/iOS) 5026 bannerVideo.querySelectorAll('source[data-src]').forEach(function(source) { 5027 source.src = source.dataset.src; 5028 source.removeAttribute('data-src'); 5029 }); 5030 5031 // iOS specific: ensure playsinline is set 5032 bannerVideo.setAttribute('playsinline', ''); 5033 bannerVideo.setAttribute('webkit-playsinline', ''); // Legacy iOS 5034 5035 bannerVideo.load(); 5036 5037 // For iOS, autoplay only works if video is muted and inline 5038 var playPromise = bannerVideo.play(); 5039 if (playPromise !== undefined) { 5040 playPromise.then(function() { 5041 // Autoplay started successfully 5042 }).catch(function(e) { 5043 // On iOS, user may need to interact first 5044 // Video will show poster or first frame instead 5045 }); 5046 } 5047 } 5048 5049 // Handle iOS low power mode and background tab behavior 5050 function handleVisibilityChange() { 5051 if (document.visibilityState === 'visible') { 5052 // Page became visible - try to resume videos 5053 var videos = document.querySelectorAll('video.lazy-video.loaded[autoplay]'); 5054 videos.forEach(function(video) { 5055 if (video.paused && video.hasAttribute('muted')) { 5056 video.play().catch(function() {}); 5057 } 5058 }); 5059 } 5060 } 5061 5062 document.addEventListener('visibilitychange', handleVisibilityChange); 5063 5064 // Initialize on DOM ready 5065 function init() { 5066 // Load banner video immediately (critical above-fold content) 5067 loadBannerVideo(); 5068 5069 // Initialize lazy loading for other content 5070 initLazyImages(); 5071 initLazyVideos(); 5072 5073 // Re-check for dynamically added content (con debounce para evitar querySelectorAll 5074 // en cada mutación del player, artwork, track info, etc.) 5075 var mutationDebounce = null; 5076 const observer = new MutationObserver(function(mutations) { 5077 var hasNewContent = false; 5078 for (var i = 0; i < mutations.length; i++) { 5079 if (mutations[i].addedNodes.length) { 5080 hasNewContent = true; 5081 break; 5082 } 5083 } 5084 if (hasNewContent) { 5085 clearTimeout(mutationDebounce); 5086 mutationDebounce = setTimeout(function() { 5087 initLazyImages(); 5088 initLazyVideos(); 5089 }, 400); 5090 } 5091 }); 5092 5093 observer.observe(document.body, { 5094 childList: true, 5095 subtree: true 5096 }); 5097 } 5098 5099 // Run initialization 5100 if (document.readyState === 'loading') { 5101 document.addEventListener('DOMContentLoaded', init); 5102 } else { 5103 init(); 5104 } 5105})(); 5106 5107// ============================================================================ 5108// AI COMMENTS â injects last_comments into the live session card 5109// ============================================================================ 5110(function() { 5111 'use strict'; 5112 5113 const LAST_COMMENTS_URL = 'https://api.core.wxli.ai/nowplaying/last_comments'; 5114 const UPDATE_INTERVAL = 15000; 5115 let updateTimeout = null; 5116 5117 // Same speeds as the main player typewriter 5118 const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; 5119 const TYPE_SPEED = isIOS ? 7 : 4; // ms per character (faster typing) 5120 const MAX_CHARS_PER_FRAME = 12; 5121 5122 let commentAnimId = null; 5123 let labelAnimId = null; 5124 let lastCommentText = null; 5125 let lastCommentInjectedAt = null; 5126 5127 function getCommentTimeAgo(injectedAt) { 5128 if (!injectedAt) return ''; 5129 const diffInSeconds = Math.floor(Date.now() / 1000 - injectedAt); 5130 if (diffInSeconds < 60) return ''; 5131 if (diffInSeconds < 3600) return Math.floor(diffInSeconds / 60) + 'm ago'; 5132 if (diffInSeconds < 86400) return Math.floor(diffInSeconds / 3600) + 'h ago'; 5133 return Math.floor(diffInSeconds / 86400) + 'd ago'; 5134 } 5135 5136 function typewriterComment(el, text) { 5137 if (commentAnimId) { cancelAnimationFrame(commentAnimId); commentAnimId = null; } 5138 el.textContent = ''; 5139 var idx = 0, accumulated = 0, lastTs = null; 5140 function frame(ts) { 5141 if (lastTs === null) lastTs = ts; 5142 accumulated += ts - lastTs; 5143 lastTs = ts; 5144 var chars = Math.min(Math.floor(accumulated / TYPE_SPEED), MAX_CHARS_PER_FRAME); 5145 if (chars > 0) { 5146 accumulated = accumulated % TYPE_SPEED; 5147 idx = Math.min(idx + chars, text.length); 5148 el.textContent = text.slice(0, idx); 5149 } 5150 if (idx < text.length) { 5151 commentAnimId = requestAnimationFrame(frame); 5152 } else { 5153 commentAnimId = null; 5154 } 5155 } 5156 commentAnimId = requestAnimationFrame(frame); 5157 } 5158 5159 function typewriterLabel(el, text) { 5160 if (labelAnimId) { cancelAnimationFrame(labelAnimId); labelAnimId = null; } 5161 el.textContent = ''; 5162 var idx = 0, accumulated = 0, lastTs = null; 5163 function frame(ts) { 5164 if (lastTs === null) lastTs = ts; 5165 accumulated += ts - lastTs; 5166 lastTs = ts; 5167 var chars = Math.min(Math.floor(accumulated / TYPE_SPEED), MAX_CHARS_PER_FRAME); 5168 if (chars > 0) { 5169 accumulated = accumulated % TYPE_SPEED; 5170 idx = Math.min(idx + chars, text.length); 5171 el.textContent = text.slice(0, idx); 5172 } 5173 if (idx < text.length) { 5174 labelAnimId = requestAnimationFrame(frame); 5175 } else { 5176 labelAnimId = null; 5177 } 5178 } 5179 labelAnimId = requestAnimationFrame(frame); 5180 } 5181 5182 const DELETE_SPEED = 4; // faster than TYPE_SPEED for pre-clear delete 5183 const DELETE_MAX_CHARS = 12; 5184 5185 function typewriterDelete(el, onDone, fast) { 5186 if (commentAnimId) { cancelAnimationFrame(commentAnimId); commentAnimId = null; } 5187 var text = el.textContent; 5188 var idx = text.length; 5189 var speed = fast ? DELETE_SPEED : TYPE_SPEED; 5190 var maxChars = fast ? DELETE_MAX_CHARS : MAX_CHARS_PER_FRAME; 5191 var accumulated = 0, lastTs = null; 5192 function frame(ts) { 5193 if (lastTs === null) lastTs = ts; 5194 accumulated += ts - lastTs; 5195 lastTs = ts; 5196 var chars = Math.min(Math.floor(accumulated / speed), maxChars); 5197 if (chars > 0) { 5198 accumulated = accumulated % speed;
5199 idx = Math.max(0, idx - chars); 5200 el.textContent = text.slice(0, idx); 5201 } 5202 if (idx > 0) { 5203 commentAnimId = requestAnimationFrame(frame); 5204 } else { 5205 commentAnimId = null; 5206 if (onDone) onDone(); 5207 } 5208 } 5209 commentAnimId = requestAnimationFrame(frame); 5210 } 5211 5212 async function fetchAndDisplayComments() { 5213 try { 5214 const resp = await fetch(LAST_COMMENTS_URL, { 5215 headers: { 'X-WXLI-Session': wxliToken() }, 5216 cache: 'no-store' 5217 }); 5218 if (!resp.ok) return; 5219 const comments = await resp.json(); 5220 5221 const isPreClear = Array.isArray(comments) && comments.length > 0 && comments[0].pre_clear === true; 5222 const hasComment = Array.isArray(comments) && comments.length > 0 && comments[0].text; 5223 const latest = hasComment ? comments[0] : null; 5224 5225 // --- Live session card --- 5226 const cardContainer = document.querySelector('.is-live .ai-comment-text'); 5227 const liveCard = document.querySelector('.podcast-item.is-live'); 5228 if (cardContainer) { 5229 if (isPreClear) { 5230 // api-pooler confirmed a new comment is incoming â run both in parallel: 5231 // label rewrites immediately, body deletes fast 5232 const bodyEl = cardContainer.querySelector('.ai-comment-body'); 5233 const labelEl = cardContainer.querySelector('.ai-comment-voice'); 5234 const timeEl = cardContainer.querySelector('.ai-comment-time'); 5235 if (bodyEl && lastCommentText) { 5236 lastCommentText = null; 5237 lastCommentInjectedAt = null; 5238 if (timeEl) timeEl.textContent = ''; 5239 if (labelEl) typewriterLabel(labelEl, '\uD83E\uDDE0 Generating new comment...'); 5240 typewriterDelete(bodyEl, function() { bodyEl.textContent = ''; }, true); 5241 } 5242 } else if (!latest) { 5243 if (lastCommentText) { 5244 const bodyEl = cardContainer.querySelector('.ai-comment-body'); 5245 if (bodyEl && bodyEl.textContent) { 5246 typewriterDelete(bodyEl, function() { 5247 lastCommentText = null; 5248 lastCommentInjectedAt = null; 5249 cardContainer.style.display = 'none'; 5250 if (liveCard) liveCard.classList.remove('has-comment'); 5251 }); 5252 } else { 5253 lastCommentText = null; 5254 lastCommentInjectedAt = null; 5255 cardContainer.style.display = 'none'; 5256 if (liveCard) liveCard.classList.remove('has-comment'); 5257 } 5258 } else { 5259 cardContainer.style.display = 'none'; 5260 if (liveCard) liveCard.classList.remove('has-comment'); 5261 } 5262 } else { 5263 const fullText = '\u201C' + latest.text + '\u201D'; 5264 const timeAgoText = getCommentTimeAgo(latest.injected_at); 5265 cardContainer.innerHTML = ''; 5266 const label = document.createElement('span'); 5267 label.className = 'ai-comment-voice'; 5268 label.textContent = latest.voice ? 'Last comment from ' + latest.voice + ' \uD83D\uDDE3\uFE0F' : 'Last comment'; 5269 cardContainer.appendChild(label); 5270 const textEl = document.createElement('p'); 5271 textEl.className = 'ai-comment-body'; 5272 cardContainer.appendChild(textEl); 5273 const timeEl = document.createElement('p'); 5274 timeEl.className = 'ai-comment-time'; 5275 timeEl.style.cssText = 'margin: 6px 0 0; color: #999; font-size: 11px; text-align: right;'; 5276 if (timeAgoText) timeEl.textContent = timeAgoText; 5277 cardContainer.appendChild(timeEl); 5278 cardContainer.style.display = 'block'; 5279 if (liveCard) liveCard.classList.add('has-comment'); 5280 // Only animate if the text actually changed 5281 if (fullText !== lastCommentText) { 5282 lastCommentText = fullText; 5283 lastCommentInjectedAt = latest.injected_at || null; 5284 typewriterComment(textEl, fullText); 5285 } else { 5286 textEl.textContent = fullText; 5287 lastCommentInjectedAt = latest.injected_at || null; 5288 // Refresh time-ago in place 5289 if (timeAgoText) timeEl.textContent = timeAgoText; 5290 } 5291 } 5292 } 5293 5294 } catch (e) { 5295 // Silently fail â non-critical feature 5296 } 5297 } 5298 5299 function scheduleUpdate() { 5300 if (updateTimeout) clearTimeout(updateTimeout); 5301 updateTimeout = setTimeout(function() { 5302 fetchAndDisplayComments(); 5303 scheduleUpdate(); 5304 }, UPDATE_INTERVAL); 5305 } 5306 5307 // Delay initial load to let the schedule render first 5308 setTimeout(fetchAndDisplayComments, 2500); 5309 scheduleUpdate(); 5310 5311 document.addEventListener('visibilitychange', function() { 5312 if (document.hidden) { 5313 if (updateTimeout) { clearTimeout(updateTimeout); updateTimeout = null; } 5314 } else { 5315 fetchAndDisplayComments(); 5316 scheduleUpdate(); 5317 } 5318 }); 5319})();
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.