Skip to main content

Overview

The La Urban Radio Player features an intelligent lyrics system that automatically searches for and displays synchronized lyrics from LRCLIB. The system handles mid-song joins, adaptive timing compensation, and silent operation when lyrics aren’t available.

Architecture

LyricsManager Class

The core lyrics functionality is handled by the LyricsManager class in js/lyrics.js:
class LyricsManager {
    constructor() {
        this.lyrics = [];                // Array of {time, text} objects
        this.currentIndex = -1;          // Current displayed lyric
        this.timeOffset = 0;             // Initial song elapsed time
        this.songStartTimestamp = null;  // When song started
        this.useVirtualTime = false;     // Use Azura time vs audio time
        this.updateInterval = null;      // Update timer
        this.customDelay = null;         // Platform-specific delay
    }
}

Adaptive Stream Delay

The system automatically compensates for streaming latency with platform-specific delays:
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);

const LYRICS_CONFIG = {
    // iOS Safari has aggressive buffering
    STREAM_DELAY: isIOS ? 4.5 : 1.5,
    UPDATE_INTERVAL: 100  // Update every 100ms for smooth transitions
};
iOS Optimization: iPhone and iPad devices receive a 4.5-second delay compensation due to Safari’s aggressive audio buffering, while desktop and Android use 1.5 seconds.

Intelligent Search System

Silent Operation

The lyrics system operates silently to avoid cluttering the user experience:
When songs change automatically, the system searches silently:
// Called when song changes
if (currentSongId !== state.lastSongId) {
    // Clear previous lyrics immediately
    if (state.lyricsManager) {
        state.lyricsManager.clear();
    }
    
    // Search for new lyrics in silent mode (no console logs)
    fetchAndLoadLyrics(artist, title, duration, elapsed, true);
}
Silent mode behavior:
  • ✅ No console logs
  • ✅ No error messages if lyrics not found
  • ✅ Clean cover display when unavailable
  • ✅ Automatic retry on song change

LRCLIB Integration

Lyrics are fetched from the LRCLIB public API:
async function fetchAndLoadLyrics(artist, title, duration = null, elapsed = 0, silent = false) {
    const baseUrl = 'https://lrclib.net/api/get';
    const params = new URLSearchParams({
        artist_name: artist,
        track_name: title,
        ...(duration && { duration: Math.floor(duration) })
    });
    
    try {
        const response = await fetch(`${baseUrl}?${params}`);
        if (!response.ok) {
            if (!silent) logger.info('ℹ️ No hay letras disponibles');
            return;
        }
        
        const data = await response.json();
        
        if (data.syncedLyrics) {
            // Parse LRC format and load
            state.lyricsManager.loadFromLRC(data.syncedLyrics);
            if (!silent) logger.success('✅ Letras cargadas');
        }
    } catch (error) {
        if (!silent) logger.warn('Error fetching lyrics:', error);
    }
}

Synchronization System

Virtual Time Calculation

For mid-song joins, the system uses virtual time based on Azura’s elapsed data:
getCurrentTime() {
    if (this.useVirtualTime) {
        // Calculate time since song started
        const elapsed = (Date.now() - this.songStartTimestamp) / 1000;
        
        // Compensate for stream delay
        const delay = this.customDelay ?? LYRICS_CONFIG.STREAM_DELAY;
        
        // Virtual time = initial offset + elapsed - stream delay
        return Math.max(0, this.timeOffset + elapsed - delay);
    } else {
        // Use audio element time
        return this.audioElement ? this.audioElement.currentTime : 0;
    }
}
Song starts at 45s (user joins mid-song)

System captures:
- timeOffset = 45s (from Azura API)
- songStartTimestamp = Date.now()

After 10 seconds of playback:
- elapsed = 10s
- streamDelay = 1.5s (or 4.5s on iOS)

currentTime = 45 + 10 - 1.5 = 53.5s

Display lyric at 53.5s position

Real-Time Updates

Lyrics update smoothly with 100ms precision:
startVirtualTimeUpdate() {
    // Clear previous interval
    if (this.updateInterval) {
        clearInterval(this.updateInterval);
    }
    
    // Update every 100ms for smooth transitions
    this.updateInterval = setInterval(() => {
        if (this.isActive && this.lyrics.length > 0 && !document.hidden) {
            this.updateLyrics();
        }
    }, LYRICS_CONFIG.UPDATE_INTERVAL);
}

updateLyrics() {
    const currentTime = this.getCurrentTime();
    
    // Find current lyric line
    let newIndex = -1;
    for (let i = this.lyrics.length - 1; i >= 0; i--) {
        if (currentTime >= this.lyrics[i].time) {
            newIndex = i;
            break;
        }
    }
    
    // Display if changed
    if (newIndex !== this.currentIndex && newIndex !== -1) {
        this.currentIndex = newIndex;
        this.displayLyric(newIndex);
    }
}

LRC Format Parsing

Format Support

The system supports standard LRC format with timestamps:
[00:12.50]Primera línea de la canción
[00:17.80]Segunda línea aparece aquí
[00:23.30]Tercera línea continúa

Parser Implementation

loadFromLRC(lrcText) {
    const lines = lrcText.split('\n');
    const lyrics = [];
    
    // Regex: [mm:ss.xx] or [mm:ss]
    const timeRegex = /\[(\d{2}):(\d{2})\.?(\d{2,3})?\]/;
    
    for (const line of lines) {
        const match = timeRegex.exec(line);
        if (match) {
            const minutes = parseInt(match[1]);
            const seconds = parseInt(match[2]);
            const centiseconds = match[3] ? parseInt(match[3]) : 0;
            
            // Convert to total seconds
            const time = minutes * 60 + seconds + 
                        (centiseconds / (match[3]?.length === 3 ? 1000 : 100));
            
            const text = line.replace(timeRegex, '').trim();
            
            if (text) {
                lyrics.push({ time, text });
            }
        }
    }
    
    this.loadLyrics(lyrics);
}

Visual Display

Overlay Structure

Lyrics display over the album cover with a subtle backdrop:
<!-- Darkening backdrop -->
<div id="lyricsBackdrop" class="lyrics-backdrop"></div>

<!-- Lyrics overlay -->
<div id="lyricsOverlay" class="lyrics-overlay">
    <div id="lyricsPrevious" class="lyrics-line previous"></div>
    <div id="lyricsCurrent" class="lyrics-line active"></div>
</div>

Display Logic

displayLyric(index) {
    const current = this.lyrics[index];
    const previous = index > 0 ? this.lyrics[index - 1] : null;
    
    // Update current line with re-animation
    this.lyricsCurrent.classList.remove('active');
    setTimeout(() => {
        this.lyricsCurrent.textContent = current.text;
        this.lyricsCurrent.classList.add('active');
    }, 50);
    
    // Update previous line
    if (previous) {
        this.lyricsPrevious.textContent = previous.text;
        this.lyricsPrevious.style.opacity = '1';
    } else {
        this.lyricsPrevious.style.opacity = '0';
    }
}

Smooth Transitions

CSS handles the visual animations:
.lyrics-line {
    transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
    opacity: 0;
    transform: translateY(20px);
}

.lyrics-line.active {
    opacity: 1;
    transform: translateY(0);
    text-shadow: 0 2px 20px rgba(0, 0, 0, 0.8);
}

.lyrics-line.previous {
    opacity: 0.4;
    font-size: 0.9em;
}

Auto-Cleanup System

Song Change Detection

Lyrics automatically clear when songs change:
// In updateSongInfo() function
if (currentSongId && currentSongId !== state.lastSongId) {
    logger.dev('🎵 Nueva canción detectada');
    
    // 1. Clear old lyrics immediately
    if (state.lyricsManager) {
        state.lyricsManager.clear();
    }
    
    // 2. Search for new lyrics (silent mode)
    const artist = data.now_playing.song.artist;
    const title = data.now_playing.song.title;
    const duration = data.now_playing.duration;
    const elapsed = data.now_playing.elapsed || 0;
    
    fetchAndLoadLyrics(artist, title, duration, elapsed, true);
    
    state.lastSongId = currentSongId;
}
Instant Cleanup: The clear() method is called immediately when a song change is detected, ensuring no lingering lyrics from previous songs.

Visibility Handling

Lyrics system reconnects when tab becomes visible:
document.addEventListener('visibilitychange', () => {
    if (!document.hidden) {
        logger.dev('🎵 Reconectando sistema de letras...');
        if (this.lyrics.length > 0) {
            this.isActive = true;
            if (this.useVirtualTime) {
                this.startVirtualTimeUpdate();
            }
            this.updateLyrics();
            this.show();
        }
    }
});

Developer Tools

Console Commands

The system exposes helpful console commands for debugging:
// Search lyrics for currently playing song
searchCurrentSongLyrics()

// Output includes:
// - Artist and title
// - Elapsed time and duration
// - Number of lyric lines found
// - Synchronization status

Edge Cases & Resilience

Scenarios Handled

ScenarioBehavior
Mid-song joinLyrics start at correct position using elapsed
No lyrics availableClean cover, no error messages (silent mode)
Network errorSilent failure, retry on next song
Tab backgroundedPause updates, resume when visible
iOS bufferingApply 4.5s delay compensation
Song skipImmediate cleanup, new search

Example: Mid-Song Join

Complex Synchronization: When users join mid-song, the system must calculate virtual time correctly:
// User joins at 67 seconds into song
const elapsed = 67;
const songStartTimestamp = Date.now();

// After 10 seconds of listening
const now = Date.now();
const playbackTime = (now - songStartTimestamp) / 1000; // 10s

// Virtual time calculation
const currentTime = elapsed + playbackTime - STREAM_DELAY;
// = 67 + 10 - 1.5 = 75.5 seconds

// Display lyric at 75.5s mark

Performance Optimization

Efficient Updates

  1. 100ms interval balances smoothness with CPU usage
  2. Only updates when visible - pauses when tab is hidden
  3. Minimal DOM manipulation - only updates when lyrics change
  4. GPU acceleration - uses CSS transforms for animations

Memory Management

clear(keepActive = true) {
    this.lyrics = [];
    this.currentIndex = -1;
    this.timeOffset = 0;
    this.songStartTimestamp = null;
    this.useVirtualTime = false;
    this.customDelay = null;
    
    // Stop update interval to free resources
    this.stopVirtualTimeUpdate();
    
    // Clear displayed text
    if (this.lyricsCurrent) this.lyricsCurrent.textContent = '';
    if (this.lyricsPrevious) this.lyricsPrevious.textContent = '';
}

Configuration Reference

// Global configuration
const LYRICS_CONFIG = {
    STREAM_DELAY: isIOS ? 4.5 : 1.5,  // Platform-specific delay
    UPDATE_INTERVAL: 100               // 100ms = 10 updates/second
};

// Custom delay per-song (optional)
state.lyricsManager.loadLyrics(lyrics, startOffset, customDelay);

Build docs developers (and LLMs) love