function scrollText(input, n, s) { let spaces = '!'.repeat(n - 1); let fullText = spaces + input + spaces; let start = s - 1; let end = start + n; return fullText.substring(start, end); } document.addEventListener('DOMContentLoaded', () => { // DOM Elements const playButton = document.querySelector('.audio-player .play-button'); const slider = document.querySelector('.audio-player .volume-slider'); const modeSwitch = document.querySelector('.audio-player .switch input'); const canvas = document.querySelector('.audio-player .audio-spectrum'); const ctx = canvas.getContext('2d'); const ledText = document.querySelector('.audio-player .led-on'); // Radio channels const radioChannels = [ { name: '1159', url: 'https://1159.cl:8443/main.ogg' }, { name: 'anonradio', url: 'https://anonradio.net:8443/anonradio' }, { name: 'tilderadio', url: 'https://azuracast.tilderadio.org/radio/8000/radio.ogg' }, { name: 'texto-plano', url: 'http://texto-plano.xyz:8000/live.ogg' }, ]; let currentChannelIndex = 0; // Default to first channel (anonradio) let volumeLevel = 50; // Default volume level let metadataQueue = []; // Queue for metadata let ledInterval = null; let currentChannelTimeout = null; // Timeout for scheduling CURRENT_CHANNEL state // Audio and Player state let player = null; let audioFallback = null; let analyser = null; let audioContext = null; let frequencyData = null; // Grid dimensions (configurable) const numRows = 10; // Number of rows in the grid const numCols = 20; // Number of columns in the grid (must be an even number for frequency bands to be paired) const gapPercentage = 0.1; // Gap percentage between grid cells const bandGapPercentage = 0.25; // Gap percentage between pairs of frequency bands const columnGapPercentage = 0.1; // Gap percentage between columns inside a pair const inactiveColor = '#141f1c'; const activeColor = '#14eb8a'; // Enums for Player and Text States const PlayerState = { PLAYING: 'playing', LOADING: 'loading', PAUSED: 'paused' }; const TextState = { OFF: 'off', // New default state IDLE: 'idle', VOLUME_CHANGE: 'volume_change', CHANNEL_CHANGE: 'channel_change', LOADING: 'loading', METADATA: 'metadata', CURRENT_CHANNEL: 'current_channel', SCHEDULE: 'schedule', TIME: 'time' }; const SelectorState = { VOLUME: 'volume', CHANNEL: 'channel' }; let selectorMode = SelectorState.VOLUME; let playerState = PlayerState.PAUSED; // Initial player state let textState = TextState.OFF; // Set default text state to OFF let currentScrollIndex = 1; // For scrolling text let currentScrollText = ''; // Text being scrolled canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; // Helper Functions function setPlayPauseIcon(state) { const playButtonImg = playButton.querySelector("img"); switch (state) { case PlayerState.PLAYING: playButtonImg.src = "/icons/pixelarticons/svg/pause.svg"; break; case PlayerState.LOADING: playButtonImg.src = "/icons/pixelarticons/svg/loader.svg"; break; case PlayerState.PAUSED: default: playButtonImg.src = "/icons/pixelarticons/svg/play.svg"; break; } playButton.disabled = false; // Ensure button is enabled after state change } function resetUI() { setPlayPauseIcon(PlayerState.PAUSED); } function stopCurrentPlayback() { if (player && player.state === "playing") { player.stop(); } if (audioFallback) { audioFallback.pause(); audioFallback.src = ""; audioFallback.load(); audioFallback = null; } playerState = PlayerState.PAUSED; setTextState(TextState.OFF); // Set text state to OFF when playback is stopped } // Text State Handling function setTextState(state) { clearInterval(ledInterval); // Clear any ongoing interval clearTimeout(currentChannelTimeout); // Clear any existing timeout for CURRENT_CHANNEL textState = state; currentScrollIndex = 1; // Reset scroll index for new text if (state === TextState.OFF) { // Clear the display ledText.textContent = '!!!!!!!!'; } else if (state === TextState.LOADING) { // Blink "loading" text ledInterval = setInterval(() => { ledText.textContent = ledText.textContent === "!!!!!!!!" ? "loading" : "!!!!!!!!"; // Blinking effect }, 500); } else if (state === TextState.VOLUME_CHANGE) { // Show volume in the format "vol: xx" ledText.textContent = `vol: ${volumeLevel.toString().padStart(2, '0')}`; setTimeout(() => setTextState(TextState.IDLE), 1000); // Revert to idle after 1 second } else if (state === TextState.CHANNEL_CHANGE) { // Show the current channel (first 4 letters) const channelName = radioChannels[currentChannelIndex].name.substring(0, 4); ledText.textContent = `ch: ${channelName}`; setTimeout(() => setTextState(TextState.IDLE), 1000); // Revert to idle after 1 second } else if (state === TextState.METADATA || state === TextState.CURRENT_CHANNEL) { // Start scrolling text currentScrollText = state === TextState.METADATA ? metadataQueue[0] : radioChannels[currentChannelIndex].name; startScrollingText(currentScrollText); } else if (state === TextState.IDLE) { ledText.textContent = '!!!!!!!!'; // Clear the display when idle // Schedule the CURRENT_CHANNEL state after 10 seconds if there's no metadata if (metadataQueue.length === 0) { currentChannelTimeout = setTimeout(() => { setTextState(TextState.CURRENT_CHANNEL); }, 10000); // 10 seconds delay to show the current channel name } } } // Scroll Function function startScrollingText(text) { const n = 8; // LED display size ledInterval = setInterval(() => { // Scroll the text using the scrollText function ledText.textContent = scrollText(text, n, currentScrollIndex); currentScrollIndex++; // Check if scrolling is done if (currentScrollIndex > text.length + n) { clearInterval(ledInterval); if (textState === TextState.METADATA) { metadataQueue.shift(); // Remove the current metadata from the queue } setTextState(TextState.IDLE); // Revert to idle after scrolling is complete } }, 250); // Speed of scrolling } // Metadata Queue Handling function handleMetadata(metadata) { metadataQueue.push(metadata); if (textState === TextState.IDLE && metadataQueue.length > 0) { setTextState(TextState.METADATA); // Show metadata if the player is idle } } // Initialize Player Function function initializePlayer(streamUrl) { stopCurrentPlayback(); // Ensure any current playback is stopped resetUI(); setTextState(TextState.LOADING); // Set text to loading state setPlayPauseIcon(PlayerState.LOADING); // Set button to loading state playerState = PlayerState.LOADING; const onMetadata = (metadata) => { if (metadata && metadata.StreamTitle) { handleMetadata(metadata.StreamTitle); // Queue the metadata to be displayed } setPlayPauseIcon(PlayerState.PLAYING); // Update the button icon as soon as playback starts playerState = PlayerState.PLAYING; // Update state to playing setTextState(TextState.IDLE); // Reset text to idle once loading completes }; const onError = (error) => { console.error('Icecast playback failed:', error); fallbackToAudio(streamUrl); // Handle fallback if Icecast fails }; player = new IcecastMetadataPlayer(streamUrl, { onMetadata, onError }); if (player.audioElement) { setupAudioContext(player.audioElement); player.play().then(() => { setPlayPauseIcon(PlayerState.PLAYING); // Ensure the button updates after playback starts playerState = PlayerState.PLAYING; // Set state to playing setTextState(TextState.IDLE); // Set text state to idle when playback starts }).catch(() => { setPlayPauseIcon(PlayerState.PAUSED); // Reset icon if play fails playerState = PlayerState.PAUSED; }); } } // Fallback to Audio Function function fallbackToAudio(streamUrl) { audioFallback = new Audio(streamUrl); audioFallback.volume = volumeLevel / 100; setPlayPauseIcon(PlayerState.LOADING); playerState = PlayerState.LOADING; audioFallback.addEventListener('playing', () => { setPlayPauseIcon(PlayerState.PLAYING); // Update icon once fallback starts playing playerState = PlayerState.PLAYING; setTextState(TextState.IDLE); // Set the text status to IDLE when fallback starts playing }); audioFallback.addEventListener('canplay', () => { audioFallback.play().then(() => { console.log('Fallback playback started'); setPlayPauseIcon(PlayerState.PLAYING); // Ensure the icon updates correctly playerState = PlayerState.PLAYING; setTextState(TextState.IDLE); // Set the text status to IDLE when playback starts }).catch(() => { setPlayPauseIcon(PlayerState.PAUSED); playerState = PlayerState.PAUSED; setTextState(TextState.OFF); // In case of failure, reset the text state }); }); audioFallback.addEventListener('error', () => { console.error('Fallback audio failed.'); setPlayPauseIcon(PlayerState.PAUSED); playerState = PlayerState.PAUSED; setTextState(TextState.OFF); // In case of an error, set text state to OFF }); } // Setup Audio Context function setupAudioContext(stream) { try { audioContext = new (window.AudioContext || window.webkitAudioContext)(); const source = audioContext.createMediaElementSource(stream); analyser = audioContext.createAnalyser(); analyser.fftSize = 64; frequencyData = new Uint8Array(analyser.frequencyBinCount); source.connect(analyser); analyser.connect(audioContext.destination); drawSpectrum(); // Start drawing the spectrum visualization } catch (e) { console.warn("Audio context failed:", e); analyser = null; } } // Draw Spectrum Function function drawSpectrum() { if (!analyser || !frequencyData) return; analyser.getByteFrequencyData(frequencyData); const availableWidth = canvas.width; const numPairs = numCols / 2; const totalUnits = numCols + numPairs * columnGapPercentage + (numPairs - 1) * bandGapPercentage; const barWidth = availableWidth / totalUnits; const columnGapWidth = barWidth * columnGapPercentage; const bandGapWidth = barWidth * bandGapPercentage; const rowHeight = canvas.height / numRows; // Dynamic row height based on configurable number of rows const gapHeight = rowHeight * gapPercentage; ctx.clearRect(0, 0, canvas.width, canvas.height); let barPosition = 0; for (let pairIndex = 0; pairIndex < numPairs; pairIndex++) { const frequencyIndex = pairIndex; let frequencyValue = frequencyData[frequencyIndex]; const numActiveRows = Math.floor((frequencyValue / 255) * numRows); // First bar in pair for (let j = 0; j < numRows; j++) { const color = j < numActiveRows ? activeColor : inactiveColor; // Use configurable colors ctx.fillStyle = color; ctx.fillRect( barPosition, (numRows - j - 1) * rowHeight + gapHeight / 2, barWidth, rowHeight - gapHeight ); } barPosition += barWidth; // Add column gap barPosition += columnGapWidth; // Second bar in pair for (let j = 0; j < numRows; j++) { const color = j < numActiveRows ? activeColor : inactiveColor; // Use configurable colors ctx.fillStyle = color; ctx.fillRect( barPosition, (numRows - j - 1) * rowHeight + gapHeight / 2, barWidth, rowHeight - gapHeight ); } barPosition += barWidth; // Add band gap after each pair except the last one if (pairIndex < numPairs - 1) { barPosition += bandGapWidth; } } requestAnimationFrame(drawSpectrum); // Keep animating } // Grid Initialization Function function drawGrid() { const availableWidth = canvas.width; const numPairs = numCols / 2; const totalUnits = numCols + numPairs * columnGapPercentage + (numPairs - 1) * bandGapPercentage; const barWidth = availableWidth / totalUnits; const columnGapWidth = barWidth * columnGapPercentage; const bandGapWidth = barWidth * bandGapPercentage; const rowHeight = canvas.height / numRows; // Dynamic row height based on configurable number of rows const gapHeight = rowHeight * gapPercentage; ctx.clearRect(0, 0, canvas.width, canvas.height); let barPosition = 0; for (let pairIndex = 0; pairIndex < numPairs; pairIndex++) { // First bar in pair for (let j = 0; j < numRows; j++) { ctx.fillStyle = inactiveColor; // Use configurable inactive color ctx.fillRect( barPosition, (numRows - j - 1) * rowHeight + gapHeight / 2, barWidth, rowHeight - gapHeight ); } barPosition += barWidth; // Add column gap barPosition += columnGapWidth; // Second bar in pair for (let j = 0; j < numRows; j++) { ctx.fillStyle = inactiveColor; // Use configurable inactive color ctx.fillRect( barPosition, (numRows - j - 1) * rowHeight + gapHeight / 2, barWidth, rowHeight - gapHeight ); } barPosition += barWidth; // Add band gap after each pair except the last one if (pairIndex < numPairs - 1) { barPosition += bandGapWidth; } } } function updateModeSelector() { selectorMode = modeSwitch.checked; if (modeSwitch.checked) { console.log("Switched to Volume Mode"); selectorMode = SelectorState.VOLUME; slider.value = volumeLevel; } else { console.log("Switched to Channel Mode"); selectorMode = SelectorState.CHANNEL; slider.value = (currentChannelIndex / (radioChannels.length - 1)) * 100; } } slider.addEventListener('input', () => { const sliderValue = slider.value; if (selectorMode === SelectorState.VOLUME) { volumeLevel = sliderValue; if (player && player.audioElement) { player.audioElement.volume = volumeLevel / 100; } if (audioFallback) { audioFallback.volume = volumeLevel / 100; } setTextState(TextState.VOLUME_CHANGE); } else { currentChannelIndex = Math.floor((sliderValue / 100) * radioChannels.length); setTextState(TextState.CHANNEL_CHANGE); } }); updateModeSelector(); modeSwitch.addEventListener('change', () => { updateModeSelector(); }); playButton.addEventListener('click', () => { if (playerState === PlayerState.PLAYING) { stopCurrentPlayback(); setPlayPauseIcon(PlayerState.PAUSED); setTextState(TextState.OFF); // Set to OFF when paused } else { const selectedChannelUrl = radioChannels[currentChannelIndex].url; setTextState(TextState.LOADING); initializePlayer(selectedChannelUrl); } }); // Ensure the grid is initialized on page load drawGrid(); // Turn the LED display OFF initially setTextState(TextState.OFF); });