Skip to main content

Overview

La Urban Radio Player includes extensive mobile optimizations to handle the unique challenges of mobile browsers, particularly iOS Safari. This guide covers buffering strategies, touch controls, and platform-specific considerations.

The Mobile Challenge

Mobile browsers, especially iOS Safari, have aggressive policies that affect audio streaming:
FeatureDesktopiOS Safari
Autoplay✅ Allowed❌ Blocked without user interaction
Preload✅ Immediate⚠️ Requires minimum buffer
Web Audio API✅ Full support⚠️ Limited/CORS issues
Initial buffer~2-3s~5-10s (more aggressive)
Network latency~1-2s~3-5s (mobile/LTE/5G)

iOS-Specific Optimizations

Adaptive Stream Delay

The most significant issue on iOS is the delay between pressing play and hearing audio, plus lyrics being out of sync. This is solved with adaptive delay configuration:
// Detect iOS device
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);

// Adaptive stream delay
const LYRICS_CONFIG = {
    // iPhone/iPad need 4.5s delay due to aggressive Safari buffering
    // Desktop/Android only need 1.5s
    STREAM_DELAY: isIOS ? 4.5 : 1.5,
    UPDATE_INTERVAL: 100
};

Why iOS Needs More Delay

Desktop Latency

Total: ~1.5-2s
  • Network: ~50-100ms
  • Browser buffer: ~1-2s
  • Total latency: ~1.5-2s
✅ Default delay works

iOS Latency

Total: ~4-6s
  • Network: ~200-500ms
  • Safari buffer: ~3-5s (aggressive)
  • Processing: ~500ms extra
  • Total latency: ~4-6s
❌ Default delay insufficient

Guaranteed Buffer Strategy

Before playing audio on mobile devices, ensure sufficient buffer is loaded:
async function playAudio() {
    const isMobile = isMobileDevice();
    
    // Wait for adequate buffer on mobile
    if (isMobile && elements.audio.readyState < 3) {
        logger.info('📱 Waiting for buffer on iOS...');
        await waitForReadyState(3, 5000);
    }
    
    await elements.audio.play();
}

function waitForReadyState(minState, timeout) {
    return new Promise((resolve, reject) => {
        const startTime = Date.now();
        
        const checkState = () => {
            if (elements.audio.readyState >= minState) {
                logger.success(`✅ Buffer ready (state ${elements.audio.readyState})`);
                resolve();
            } else if (Date.now() - startTime > timeout) {
                logger.warn('⚠️ Timeout waiting for buffer, playing anyway');
                resolve();
            } else {
                setTimeout(checkState, 100);
            }
        };
        
        checkState();
    });
}

ReadyState Values

Understanding HTML5 Audio ready states:
1

HAVE_NOTHING (0)

No information about the media is available
2

HAVE_METADATA (1)

Metadata (duration, dimensions) has loaded
3

HAVE_CURRENT_DATA (2)

Data for current playback position available, but not enough to play
4

HAVE_FUTURE_DATA (3)

Data for current and future playback positions availableThis is the minimum state we wait for on iOS
5

HAVE_ENOUGH_DATA (4)

Enough data available to start playing without buffering

Mobile Device Detection

The application uses multiple methods to detect mobile devices:
function isMobileDevice() {
    return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
           (navigator.maxTouchPoints && navigator.maxTouchPoints > 2);
}
This detection is used to:
  • Disable Web Audio API visualizer (CORS issues)
  • Show/hide volume popup vs slider
  • Adjust buffer wait times
  • Modify touch target sizes

Touch Controls

Volume Control Adaptation

Mobile devices (≤400px width) use a vertical popup instead of a horizontal slider:

Desktop/Tablet

Horizontal Slider
.volume-slider-container {
    display: flex;
    min-width: 60px;
    max-width: 140px;
}

Mobile

Vertical Popup
@media (max-width: 400px) {
    .volume-slider-container {
        display: none;
    }
    
    .volume-popup {
        display: block;
        height: 130px;
    }
}

Volume Popup Implementation

// Toggle volume popup on mobile
elements.customMuteBtn.addEventListener('click', (e) => {
    const isMobile = window.innerWidth <= 400;
    
    if (isMobile) {
        const popup = elements.volumePopup;
        const isVisible = popup.classList.contains('show');
        
        if (isVisible) {
            popup.classList.remove('show');
        } else {
            popup.classList.add('show');
            
            // Auto-hide after 3 seconds
            setTimeout(() => {
                popup.classList.remove('show');
            }, 3000);
        }
    } else {
        // Desktop: toggle mute
        toggleMute();
    }
});

Touch Target Sizes

All interactive elements meet the minimum 44x44px touch target size:
.control-btn.play-pause {
    width: 48px;
    height: 48px;
    font-size: 1.3rem;
}

.action-btn {
    min-height: 44px;
    padding: 12px 16px;
}

Responsive Layout

Viewport Configuration

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
Key properties:
  • maximum-scale=1.0 - Prevents zoom on double-tap
  • user-scalable=no - Disables pinch-to-zoom
  • viewport-fit=cover - Uses full screen on notched devices

Safe Area Insets

Support for iPhone notches and home indicators:
@media screen and (max-width: 768px) {
    body {
        padding: env(safe-area-inset-top) 
                 env(safe-area-inset-right) 
                 env(safe-area-inset-bottom) 
                 env(safe-area-inset-left);
    }
}

Responsive Breakpoints

Extra Small

≤320px
  • Single column layout
  • Compact controls
  • Minimal padding

Small

≤400px
  • Volume popup shown
  • Horizontal slider hidden
  • Responsive player width

Medium

401-500px
  • Two-column buttons
  • Compact slider
  • Standard controls

Layout Adaptation

/* Extra small devices (iPhone SE) */
@media (max-width: 375px) {
    .player-container {
        width: calc(100vw - 30px);
        padding: 18px 15px;
        margin: 15px auto;
    }
    
    .logo {
        margin-top: 0;
        margin-bottom: 12px;
        max-width: 80%;
    }
    
    .action-buttons {
        grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
    }
}

/* Small devices */
@media (max-width: 400px) {
    .custom-controls {
        gap: 8px;
        padding: 8px 10px;
    }
    
    .volume-slider-container {
        display: none;
    }
    
    .volume-popup {
        display: block !important;
    }
}

/* Landscape mode */
@media (max-height: 600px) and (orientation: landscape) {
    body {
        overflow-y: auto;
        justify-content: flex-start;
        padding: 10px 0;
    }
    
    .player-container {
        margin: 10px auto;
        min-width: 350px !important;
    }
}

Performance Optimizations

Disable Visualizer on Mobile

The Web Audio API visualizer is disabled on mobile to avoid CORS issues and improve performance:
if (isMobileDevice()) {
    logger.warn('📱 Mobile device - Visualizer disabled');
    logger.info('Audio will work perfectly, but without reactive visual effects');
    
    state.isVisualizerActive = true;
    
    // Use CSS animation instead
    if (elements.logo) {
        elements.logo.classList.add('active');
        elements.logo.style.animation = 'pulse 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite';
    }
    return;
}

GPU Acceleration

All animations use translate3d and will-change for GPU acceleration:
.logo {
    transform: translate3d(0, 0, 0);
    backface-visibility: hidden;
    perspective: 1000px;
    will-change: transform, filter;
}

.background-overlay {
    transform: translate3d(0, 0, 0);
    will-change: opacity;
}

Prevent Bounce and Zoom

body {
    -webkit-overflow-scrolling: touch;
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
    -webkit-tap-highlight-color: transparent;
}

Buffering Analysis

Latency Comparison

Desktop (Ethernet/WiFi):
  • Network latency: ~50-100ms
  • Browser buffer: ~1-2s
  • Total: ~1.5-2s → LYRICS_CONFIG.STREAM_DELAY = 1.5s ✅
iPhone (LTE/5G/WiFi):
  • Network latency: ~200-500ms
  • Safari iOS buffer: ~3-5s (more aggressive)
  • Mobile processing: ~500ms extra
  • Total: ~4-6s → LYRICS_CONFIG.STREAM_DELAY = 1.5s ❌ (needs 4.5s)

Debug Script for Mobile Testing

Use this script in Safari’s console to measure actual delay:
window.debugStreamTiming = async function() {
    console.log('🔍 Starting timing analysis...');
    
    const audio = document.querySelector('audio');
    const startTime = Date.now();
    
    // Capture events
    const events = [];
    
    ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 
     'canplaythrough', 'playing'].forEach(event => {
        audio.addEventListener(event, () => {
            const elapsed = Date.now() - startTime;
            events.push({ event, elapsed, readyState: audio.readyState });
            console.log(`📊 ${event}: +${elapsed}ms (readyState: ${audio.readyState})`);
        }, { once: true });
    });
    
    // Get Azura time BEFORE playing
    const azuraResponse = await fetch('https://azura.laurban.cl/api/nowplaying/laurban');
    const azuraData = await azuraResponse.json();
    const azuraElapsedBefore = azuraData.now_playing.elapsed;
    console.log(`⏱️ Azura elapsed BEFORE: ${azuraElapsedBefore}s`);
    
    // Play
    await audio.play();
    
    // Wait 2 seconds
    await new Promise(r => setTimeout(r, 2000));
    
    // Get Azura time AFTER
    const azuraResponse2 = await fetch('https://azura.laurban.cl/api/nowplaying/laurban');
    const azuraData2 = await azuraResponse2.json();
    const azuraElapsedAfter = azuraData2.now_playing.elapsed;
    console.log(`⏱️ Azura elapsed AFTER: ${azuraElapsedAfter}s`);
    
    const realDelay = azuraElapsedAfter - azuraElapsedBefore - 2.0;
    console.log(`📊 ACTUAL STREAM DELAY: ${realDelay.toFixed(2)}s`);
    console.log(`💡 LYRICS_CONFIG.STREAM_DELAY should be: ${realDelay.toFixed(1)}s`);
    
    return { events, realDelay };
};

iOS Safari Restrictions

Why iOS is Restrictive

Battery Saving

Safari limits buffer to avoid excessive battery drain from preloading

Data Saving

Prevents consuming mobile data unnecessarily

Security

Autoplay blocked to prevent invasive advertising

Performance

CPU/RAM limitations on mobile devices

Autoplay Policy

iOS requires user interaction before playing audio:
// ❌ This won't work on iOS without user interaction
elements.audio.play();

// ✅ Must be triggered by user event
playButton.addEventListener('click', () => {
    elements.audio.play();
});

Testing on Mobile Devices

Remote Debugging

Requirements:
  • Mac with Safari
  • iPhone/iPad with Safari
  • Lightning/USB-C cable
Steps:
  1. Connect device to Mac
  2. Enable “Web Inspector” on iOS (Settings → Safari → Advanced)
  3. Open Safari on Mac → Develop → [Your Device] → [Page]

Mobile Testing Checklist

iOS Testing:
  • Audio plays after tapping play button
  • No delay >2s after tapping play
  • Lyrics synchronize correctly (within 1s)
  • Volume popup appears and functions
  • Player fits within viewport
  • No horizontal scrolling
  • Handles interruptions (calls, Siri)
  • Resumes after backgrounding app
Android Testing:
  • Audio plays smoothly
  • Controls respond to touch
  • Volume slider works (if shown)
  • Lyrics display correctly
  • No memory leaks on long sessions
  • Works in Chrome and Firefox
General Mobile:
  • Touch targets ≥44px
  • No accidental zoom on tap
  • Smooth animations (60fps)
  • Fast initial load (under 3 seconds)
  • Works offline after first load

Network Conditions

Connection Type Detection

function getOptimalStreamDelay() {
    const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
    const connection = navigator.connection || 
                      navigator.mozConnection || 
                      navigator.webkitConnection;
    
    if (isMobile) {
        if (connection) {
            const effectiveType = connection.effectiveType;
            if (effectiveType === '4g') return 3.5;
            if (effectiveType === '3g') return 5.0;
            if (effectiveType === '2g') return 7.0;
        }
        return 4.0; // Default mobile
    }
    return 1.5; // Desktop
}

Adaptive Bitrate (Future Enhancement)

Currently, the stream uses a fixed bitrate. A future enhancement could implement adaptive bitrate based on connection speed:
// Proposed implementation
if (connection && connection.effectiveType === '2g') {
    // Use low bitrate stream
    streamUrl = CONFIG.STREAM_URL_LOW;
} else if (connection && connection.effectiveType === '3g') {
    // Use medium bitrate stream
    streamUrl = CONFIG.STREAM_URL_MEDIUM;
} else {
    // Use high quality stream
    streamUrl = CONFIG.STREAM_URL;
}

Best Practices

Do’s ✅

  • Always wait for user interaction before playing audio
  • Use adaptive delays based on device type
  • Provide visual feedback during buffering
  • Test on real devices, not just emulators
  • Optimize for touch interactions
  • Use GPU-accelerated animations
  • Implement proper error handling
  • Cache API responses to reduce load

Don’ts ❌

  • Don’t assume desktop behavior on mobile
  • Don’t use fixed delays for all devices
  • Don’t rely on Web Audio API on mobile
  • Don’t ignore safe area insets
  • Don’t use touch targets smaller than 44px
  • Don’t enable zoom on audio controls
  • Don’t forget to handle interruptions
  • Don’t skip mobile-specific testing

Next Steps

Build docs developers (and LLMs) love