1159cl/js/audio-player.js
Mario1159 59267ad2da
Some checks failed
Release / test (push) Failing after 14s
Release / push-docker-image (push) Has been skipped
Change recipe
2024-12-10 22:25:19 -03:00

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: 'https://1159.cl:8000/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);
});