448 lines
17 KiB
JavaScript
448 lines
17 KiB
JavaScript
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: 'http://1159.cl:25565/stream' },
|
|
{ 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);
|
|
});
|