class RadioWaveApp {
constructor() {
this.audio = document.getElementById('audio-player');
this.stations = [];
this.currentStationIndex = -1;
this.discoverStations = this.getDiscoverStations();
this.audioContext = null;
this.analyser = null;
this.source = null;
this.sleepTimer = null;
this.mediaRecorder = null;
this.recordedChunks = [];
this.eqBands = [];
this.loadStations();
this.loadTheme();
this.loadColors();
this.initUI();
this.bindEvents();
this.renderStations();
this.renderDiscoverStations();
}
initUI() {
// Cache all UI elements
this.ui = {
stationList: document.getElementById('station-list'),
discoverList: document.getElementById('discover-list'),
playPauseBtn: document.getElementById('play-pause-btn'),
nextBtn: document.getElementById('next-station-btn'),
prevBtn: document.getElementById('prev-station-btn'),
recordBtn: document.getElementById('record-btn'),
volumeSlider: document.getElementById('volume-slider'),
playerStationName: document.getElementById('player-station-name'),
playerStationGenre: document.getElementById('player-station-genre'),
stationArt: document.getElementById('station-art'),
searchInput: document.getElementById('search-input'),
genreFilter: document.getElementById('genre-filter'),
themeToggle: document.getElementById('theme-toggle'),
visualizer: document.getElementById('visualizer'),
// Modals
stationModal: document.getElementById('station-modal'),
settingsModal: document.getElementById('settings-modal'),
sleepTimerModal: document.getElementById('sleep-timer-modal'),
importExportModal: document.getElementById('import-export-modal'),
equalizerModal: document.getElementById('equalizer-modal'),
// Modal Triggers
addStationBtn: document.getElementById('add-station-btn'),
settingsBtn: document.getElementById('settings-btn'),
sleepTimerBtn: document.getElementById('sleep-timer-btn'),
importExportBtn: document.getElementById('import-export-btn'),
equalizerBtn: document.getElementById('equalizer-btn'),
// Tabs
sidebarTabs: document.querySelectorAll('.tab-link'),
tabContents: document.querySelectorAll('.tab-content'),
// Recommendations
recommendationsList: document.getElementById('recommendations-list'),
// EQ
eqBandsContainer: document.getElementById('eq-bands'),
eqPresetsSelect: document.getElementById('eq-presets-select'),
resetEqBtn: document.getElementById('reset-eq-btn'),
};
this.canvasCtx = this.ui.visualizer.getContext('2d');
}
bindEvents() {
// Player controls
this.ui.playPauseBtn.addEventListener('click', () => this.togglePlayPause());
this.ui.nextBtn.addEventListener('click', () => this.playNext());
this.ui.prevBtn.addEventListener('click', () => this.playPrevious());
this.ui.recordBtn.addEventListener('click', () => this.toggleRecording());
this.ui.volumeSlider.addEventListener('input', (e) => this.setVolume(e.target.value));
// Station list interactions
this.ui.stationList.addEventListener('click', (e) => this.handleStationListClick(e));
this.ui.discoverList.addEventListener('click', (e) => this.handleDiscoverListClick(e));
// Search and filter
this.ui.searchInput.addEventListener('input', (e) => this.renderStations(e.target.value, this.ui.genreFilter.value));
this.ui.genreFilter.addEventListener('change', (e) => this.renderStations(this.ui.searchInput.value, e.target.value));
// Theme and settings
this.ui.themeToggle.addEventListener('change', () => this.toggleTheme());
this.bindModalEvents();
this.bindColorPickerEvents();
this.bindTimerEvents();
this.bindImportExportEvents();
this.bindEqualizerEvents();
// Tabs
this.ui.sidebarTabs.forEach(tab => {
tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
});
// Audio events
this.audio.addEventListener('play', () => this.ui.playPauseBtn.innerHTML = '');
this.audio.addEventListener('pause', () => this.ui.playPauseBtn.innerHTML = '');
// Keyboard shortcuts
document.addEventListener('keydown', (e) => this.handleKeyPress(e));
}
bindModalEvents() {
const modals = document.querySelectorAll('.modal');
const closeBtns = document.querySelectorAll('.close-btn');
this.ui.addStationBtn.addEventListener('click', () => this.openStationModal());
this.ui.settingsBtn.addEventListener('click', () => this.ui.settingsModal.classList.add('active'));
this.ui.equalizerBtn.addEventListener('click', () => this.ui.equalizerModal.classList.add('active'));
this.ui.sleepTimerBtn.addEventListener('click', () => this.ui.sleepTimerModal.classList.add('active'));
this.ui.importExportBtn.addEventListener('click', () => this.ui.importExportModal.classList.add('active'));
closeBtns.forEach(btn => btn.addEventListener('click', () => this.closeAllModals()));
modals.forEach(modal => modal.addEventListener('click', (e) => {
if (e.target === modal) this.closeAllModals();
}));
document.getElementById('station-form').addEventListener('submit', (e) => this.saveStation(e));
}
closeAllModals() {
document.querySelectorAll('.modal').forEach(m => m.classList.remove('active'));
}
/* --- Station Management --- */
loadStations() {
const savedStations = localStorage.getItem('radioWaveStations');
this.stations = savedStations ? JSON.parse(savedStations) : this.getDefaultStations();
this.updateGenreFilter();
}
saveStations() {
localStorage.setItem('radioWaveStations', JSON.stringify(this.stations));
this.updateGenreFilter();
this.renderStations();
}
renderStations(searchTerm = '', genreTerm = 'all') {
this.ui.stationList.innerHTML = '';
const filteredStations = this.stations.filter(station => {
const matchesSearch = station.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesGenre = genreTerm === 'all' || station.genre.toLowerCase() === genreTerm.toLowerCase();
return matchesSearch && matchesGenre;
});
if (filteredStations.length === 0) {
this.ui.stationList.innerHTML = '
No stations found.
';
return;
}
filteredStations.forEach((station, index) => {
const isPlaying = this.currentStationIndex === this.stations.indexOf(station) && !this.audio.paused;
const item = document.createElement('div');
item.className = `station-item ${isPlaying ? 'playing' : ''}`;
item.dataset.id = station.id;
item.innerHTML = `
${station.name}
${station.genre || 'No Genre'}
`;
this.ui.stationList.appendChild(item);
});
}
handleStationListClick(e) {
const stationItem = e.target.closest('.station-item');
if (!stationItem) return;
const stationId = stationItem.dataset.id;
const stationIndex = this.stations.findIndex(s => s.id == stationId);
if (e.target.closest('.edit-btn')) {
this.openStationModal(stationId);
} else if (e.target.closest('.delete-btn')) {
if (confirm('Are you sure you want to delete this station?')) {
this.stations.splice(stationIndex, 1);
this.saveStations();
}
} else {
this.playStation(stationIndex);
}
}
openStationModal(stationId = null) {
const form = document.getElementById('station-form');
const modalTitle = document.getElementById('modal-title');
form.reset();
document.getElementById('station-id').value = '';
if (stationId) {
const station = this.stations.find(s => s.id == stationId);
modalTitle.textContent = 'Edit Station';
document.getElementById('station-id').value = station.id;
document.getElementById('station-name').value = station.name;
document.getElementById('station-url').value = station.url;
document.getElementById('station-genre').value = station.genre;
} else {
modalTitle.textContent = 'Add Station';
}
this.ui.stationModal.classList.add('active');
}
saveStation(e) {
e.preventDefault();
const id = document.getElementById('station-id').value;
const newStation = {
id: id || Date.now(),
name: document.getElementById('station-name').value,
url: document.getElementById('station-url').value,
genre: document.getElementById('station-genre').value,
};
if (id) {
const index = this.stations.findIndex(s => s.id == id);
this.stations[index] = newStation;
} else {
this.stations.push(newStation);
}
this.saveStations();
this.closeAllModals();
}
updateGenreFilter() {
const genres = ['all', ...new Set(this.stations.map(s => s.genre).filter(g => g))];
this.ui.genreFilter.innerHTML = genres.map(g => ``).join('');
}
/* --- Player --- */
playStation(index) {
if (index < 0 || index >= this.stations.length) return;
this.currentStationIndex = index;
const station = this.stations[index];
this.audio.src = station.url;
this.audio.play().catch(e => {
alert("Failed to play station. The stream might be offline or blocked.");
console.error("Playback Error:", e);
});
this.updatePlayerUI(station);
this.renderStations();
this.setupAudioPipeline();
this.updateRecommendations();
}
togglePlayPause() {
if (this.audio.paused) {
if (this.currentStationIndex === -1 && this.stations.length > 0) {
this.playStation(0);
} else {
this.audio.play();
}
} else {
this.audio.pause();
}
this.renderStations();
}
playNext() {
let nextIndex = this.currentStationIndex + 1;
if (nextIndex >= this.stations.length) nextIndex = 0;
this.playStation(nextIndex);
}
playPrevious() {
let prevIndex = this.currentStationIndex - 1;
if (prevIndex < 0) prevIndex = this.stations.length - 1;
this.playStation(prevIndex);
}
setVolume(value) {
this.audio.volume = value;
}
updatePlayerUI(station) {
this.ui.playerStationName.textContent = station.name;
this.ui.playerStationGenre.textContent = station.genre || '---';
this.ui.stationArt.src = 'assets/images/default-art.png';
}
/* --- Audio Pipeline (Visualizer & EQ) --- */
setupAudioPipeline() {
if (this.audioContext) return; // Already set up
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.source = this.audioContext.createMediaElementSource(this.audio);
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256;
this.initEqualizer();
// Connect nodes: source -> EQ -> analyser -> destination
let lastNode = this.source;
this.eqBands.forEach(band => {
lastNode.connect(band);
lastNode = band;
});
lastNode.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
this.drawVisualizer();
} catch (e) {
console.error('Web Audio API setup failed.', e);
this.ui.visualizer.style.display = 'none';
this.ui.equalizerBtn.style.display = 'none';
}
}
drawVisualizer() {
requestAnimationFrame(() => this.drawVisualizer());
if (!this.analyser) return;
const bufferLength = this.analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
this.analyser.getByteFrequencyData(dataArray);
this.canvasCtx.fillStyle = '#000';
this.canvasCtx.fillRect(0, 0, this.ui.visualizer.width, this.ui.visualizer.height);
const barWidth = (this.ui.visualizer.width / bufferLength) * 2.5;
let barHeight;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
barHeight = dataArray[i];
const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim();
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim();
const gradient = this.canvasCtx.createLinearGradient(0, this.ui.visualizer.height, 0, this.ui.visualizer.height - barHeight);
gradient.addColorStop(0, primaryColor);
gradient.addColorStop(1, accentColor);
this.canvasCtx.fillStyle = gradient;
this.canvasCtx.fillRect(x, this.ui.visualizer.height - barHeight / 2, barWidth, barHeight / 2);
x += barWidth + 1;
}
}
/* --- Equalizer --- */
initEqualizer() {
const frequencies = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000];
this.ui.eqBandsContainer.innerHTML = '';
frequencies.forEach((freq, i) => {
const band = this.audioContext.createBiquadFilter();
band.type = 'peaking';
band.frequency.value = freq;
band.Q.value = 1;
band.gain.value = 0;
this.eqBands.push(band);
const bandElement = document.createElement('div');
bandElement.className = 'eq-band';
bandElement.innerHTML = `
`;
this.ui.eqBandsContainer.appendChild(bandElement);
});
}
bindEqualizerEvents() {
this.ui.eqBandsContainer.addEventListener('input', (e) => {
if (e.target.type === 'range') {
const index = e.target.dataset.index;
const value = e.target.value;
this.eqBands[index].gain.value = value;
this.ui.eqPresetsSelect.value = 'custom';
}
});
this.ui.eqPresetsSelect.addEventListener('change', (e) => this.applyEQPreset(e.target.value));
this.ui.resetEqBtn.addEventListener('click', () => this.applyEQPreset('flat'));
}
applyEQPreset(preset) {
const presets = {
'flat': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
'bass-boost': [6, 5, 4, 2, 0, 0, 0, 0, 0, 0],
'rock': [4, 2, -2, -3, -1, 2, 4, 5, 5, 6],
'pop': [-1, -2, 0, 2, 4, 4, 2, 0, -1, -2],
'vocal-booster': [0, 0, 0, -2, -2, 4, 4, 2, 0, 0],
'custom': null
};
const values = presets[preset];
if (!values) return;
this.ui.eqPresetsSelect.value = preset;
const sliders = this.ui.eqBandsContainer.querySelectorAll('input[type="range"]');
sliders.forEach((slider, i) => {
slider.value = values[i];
this.eqBands[i].gain.value = values[i];
});
}
/* --- Recording --- */
toggleRecording() {
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
this.mediaRecorder.stop();
this.ui.recordBtn.classList.remove('recording');
this.ui.recordBtn.title = 'Start Recording';
} else {
if (!this.source) {
alert('Please start playing a station first.');
return;
}
try {
const dest = this.audioContext.createMediaStreamDestination();
this.analyser.connect(dest); // Record post-EQ and post-analyser
this.mediaRecorder = new MediaRecorder(dest.stream);
this.recordedChunks = [];
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) this.recordedChunks.push(e.data);
};
this.mediaRecorder.onstop = () => {
const blob = new Blob(this.recordedChunks, { type: 'audio/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `radiowave-recording-${new Date().toISOString()}.webm`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
};
this.mediaRecorder.start();
this.ui.recordBtn.classList.add('recording');
this.ui.recordBtn.title = 'Stop Recording';
} catch (e) {
alert('Recording failed to start. Your browser may not support it.');
console.error('Recording Error:', e);
}
}
}
/* --- Keyboard Shortcuts --- */
handleKeyPress(e) {
// Ignore key presses in input fields
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
switch (e.code) {
case 'Space':
e.preventDefault();
this.togglePlayPause();
break;
case 'ArrowRight':
this.playNext();
break;
case 'ArrowLeft':
this.playPrevious();
break;
case 'ArrowUp':
e.preventDefault();
this.setVolume(Math.min(1, this.audio.volume + 0.05));
this.ui.volumeSlider.value = this.audio.volume;
break;
case 'ArrowDown':
e.preventDefault();
this.setVolume(Math.max(0, this.audio.volume - 0.05));
this.ui.volumeSlider.value = this.audio.volume;
break;
}
}
/* --- Theme & Colors --- */
loadTheme() {
const theme = localStorage.getItem('radioWaveTheme') || 'dark';
document.body.className = theme + '-theme';
this.ui.themeToggle.checked = theme === 'dark';
}
toggleTheme() {
const isDark = this.ui.themeToggle.checked;
const theme = isDark ? 'dark' : 'light';
document.body.className = theme + '-theme';
localStorage.setItem('radioWaveTheme', theme);
}
bindColorPickerEvents() {
const root = document.documentElement;
const colorInputs = {
'--primary-color': document.getElementById('primary-color'),
'--accent-color': document.getElementById('accent-color'),
'--light-text': document.getElementById('text-color-light'),
'--dark-text': document.getElementById('text-color-dark'),
};
for (const [prop, input] of Object.entries(colorInputs)) {
input.addEventListener('input', (e) => {
root.style.setProperty(prop, e.target.value);
this.saveColors();
});
}
document.getElementById('reset-colors-btn').addEventListener('click', () => this.resetColors());
}
saveColors() {
const rootStyles = getComputedStyle(document.documentElement);
const colors = {
primary: rootStyles.getPropertyValue('--primary-color').trim(),
accent: rootStyles.getPropertyValue('--accent-color').trim(),
textLight: rootStyles.getPropertyValue('--light-text').trim(),
textDark: rootStyles.getPropertyValue('--dark-text').trim(),
};
localStorage.setItem('radioWaveColors', JSON.stringify(colors));
}
loadColors() {
const savedColors = JSON.parse(localStorage.getItem('radioWaveColors'));
if (savedColors) {
const root = document.documentElement;
root.style.setProperty('--primary-color', savedColors.primary);
root.style.setProperty('--accent-color', savedColors.accent);
root.style.setProperty('--light-text', savedColors.textLight);
root.style.setProperty('--dark-text', savedColors.textDark);
document.getElementById('primary-color').value = savedColors.primary;
document.getElementById('accent-color').value = savedColors.accent;
document.getElementById('text-color-light').value = savedColors.textLight;
document.getElementById('text-color-dark').value = savedColors.textDark;
}
}
resetColors() {
localStorage.removeItem('radioWaveColors');
window.location.reload();
}
/* --- Advanced Features --- */
switchTab(tabId) {
this.ui.tabContents.forEach(content => content.classList.remove('active'));
this.ui.sidebarTabs.forEach(tab => tab.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
document.querySelector(`[data-tab='${tabId}']`).classList.add('active');
}
bindTimerEvents() {
const timerDisplay = document.getElementById('timer-display');
document.querySelectorAll('.timer-option').forEach(btn => {
btn.addEventListener('click', () => {
const minutes = parseInt(btn.dataset.minutes, 10);
this.setSleepTimer(minutes);
timerDisplay.textContent = `Stops in ${minutes} minutes.`;
});
});
document.getElementById('cancel-timer-btn').addEventListener('click', () => {
this.cancelSleepTimer();
timerDisplay.textContent = 'Timer not set';
});
}
setSleepTimer(minutes) {
this.cancelSleepTimer();
this.sleepTimer = setTimeout(() => {
this.audio.pause();
document.getElementById('timer-display').textContent = 'Timer finished';
}, minutes * 60 * 1000);
}
cancelSleepTimer() {
if (this.sleepTimer) {
clearTimeout(this.sleepTimer);
this.sleepTimer = null;
}
}
bindImportExportEvents() {
document.getElementById('export-btn').addEventListener('click', () => this.exportStations());
document.getElementById('import-btn').addEventListener('click', () => document.getElementById('import-file-input').click());
document.getElementById('import-file-input').addEventListener('change', (e) => this.importStations(e));
}
exportStations() {
const dataStr = JSON.stringify(this.stations, null, 2);
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
const exportFileDefaultName = 'radiowave_stations.json';
let linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
}
importStations(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedStations = JSON.parse(e.target.result);
if (Array.isArray(importedStations)) {
this.stations = importedStations;
this.saveStations();
this.closeAllModals();
alert('Stations imported successfully!');
} else {
alert('Invalid file format.');
}
} catch (err) {
alert('Error reading file: ' + err.message);
}
};
reader.readAsText(file);
}
/* --- Discovery & Recommendations --- */
renderDiscoverStations() {
this.ui.discoverList.innerHTML = '';
this.discoverStations.forEach(station => {
const item = document.createElement('div');
item.className = 'discover-item';
item.dataset.name = station.name;
item.dataset.url = station.url;
item.dataset.genre = station.genre;
item.innerHTML = `
${station.name}
${station.genre}
`;
this.ui.discoverList.appendChild(item);
});
}
handleDiscoverListClick(e) {
const item = e.target.closest('.discover-item');
if (!item) return;
const newStation = {
id: Date.now(),
name: item.dataset.name,
url: item.dataset.url,
genre: item.dataset.genre,
};
if (this.stations.some(s => s.url === newStation.url)) {
alert('This station is already in your list.');
return;
}
this.stations.push(newStation);
this.saveStations();
alert(`'${newStation.name}' has been added to your stations!`);
this.switchTab('my-stations-tab');
}
updateRecommendations() {
this.ui.recommendationsList.innerHTML = '';
if (this.currentStationIndex === -1) return;
const currentGenre = this.stations[this.currentStationIndex].genre;
if (!currentGenre) return;
const recommendations = this.discoverStations.filter(s => s.genre.toLowerCase() === currentGenre.toLowerCase() && !this.stations.some(myS => myS.url === s.url)).slice(0, 3);
if (recommendations.length === 0) {
this.ui.recommendationsList.innerHTML = 'No recommendations right now.
';
return;
}
recommendations.forEach(station => {
const item = document.createElement('div');
item.className = 'rec-item';
item.dataset.name = station.name;
item.dataset.url = station.url;
item.dataset.genre = station.genre;
item.innerHTML = `${station.name}
${station.genre}
`;
item.addEventListener('click', () => {
if (confirm(`Add '${station.name}' to your stations?`)) {
this.handleDiscoverListClick({ target: item });
}
});
this.ui.recommendationsList.appendChild(item);
});
}
/* --- Default Data --- */
getDefaultStations() {
return [
{ id: 1, name: 'Lofi Girl', url: 'https://stream.lofigirl.com/lofi', genre: 'Lofi' },
{ id: 2, name: 'BBC Radio 1', url: 'http://stream.live.vc.bbcmedia.co.uk/bbc_radio_one', genre: 'Pop' },
{ id: 3, name: 'KEXP 90.3 FM', url: 'https://kexp-mp3-128.streamguys1.com/kexp128.mp3', genre: 'Indie' },
];
}
getDiscoverStations() {
return [
{ name: 'NTS Radio', url: 'http://stream-relay-geo.ntslive.net/stream', genre: 'Electronic' },
{ name: 'Dublab', url: 'https://dublab.out.airtime.pro/dublab_128.mp3', genre: 'Eclectic' },
{ name: 'Worldwide FM', url: 'https://worldwidefm.out.airtime.pro/worldwidefm_128.mp3', genre: 'World' },
{ name: 'The Lot Radio', url: 'https://thelot.out.airtime.pro/thelot_128.mp3', genre: 'Dance' },
{ name: 'Rinse FM', url: 'https://rinse.fm/streams/rinse.mp3', genre: 'Grime' },
{ name: 'Jazz24', url: 'https://jazz24.streamguys1.com/jazz24.mp3', genre: 'Jazz' },
{ name: 'SomaFM: Groove Salad', url: 'http://ice1.somafm.com/groovesalad-128-mp3', genre: 'Ambient' },
{ name: 'Classical KUSC', url: 'https://kusc.streamguys1.com/kusc-128.mp3', genre: 'Classical' },
{ name: 'Rock FM', url: 'https://live.rockfm.ro/rockfm.aacp', genre: 'Rock' },
{ name: 'Radio Paradise', url: 'http://stream.radioparadise.com/mp3-128', genre: 'Rock' },
];
}
}
document.addEventListener('DOMContentLoaded', () => {
new RadioWaveApp();
});