PageSourceSearch

https://wxli.ai/assets/js/wxli-player.js

js wxli.ai collected 2026-10-05 13:20:11 UTC 207,790 bytes, 5,319 lines download raw bytes

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, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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.