Skip to content

Instantly share code, notes, and snippets.

@antonl-dev
Last active June 25, 2025 20:41
Show Gist options
  • Select an option

  • Save antonl-dev/5506ab017789be64479eb575e0f6dd82 to your computer and use it in GitHub Desktop.

Select an option

Save antonl-dev/5506ab017789be64479eb575e0f6dd82 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RadioSurf</title>
<link rel="icon" href="data:,">
<style>
:root {
--bg-color: #1c1c1e;
--player-bg-color: #2c2c2e;
--text-color: #f2f2f7;
--text-color-secondary: #8e8e93;
--control-bg: rgba(255, 255, 255, 0.1);
--control-bg-hover: rgba(255, 255, 255, 0.2);
--input-bg-color: #3a3a3c;
--border-color: #3a3a3c;
--scrollbar-track-color: #2c2c2e;
--scrollbar-thumb-color: #555;
--scrollbar-thumb-hover-color: #666;
--link-color: #0a84ff;
--default-icon-color: #8e8e93;
}
/* Applied to <html> tag when in light mode */
html.light-mode {
--bg-color: #f2f2f7;
--player-bg-color: #ffffff;
--text-color: #000000;
--text-color-secondary: #3c3c43;
--control-bg: rgba(0, 0, 0, 0.05);
--control-bg-hover: rgba(0, 0, 0, 0.1);
--input-bg-color: #e5e5ea;
--border-color: #c7c7cc;
--scrollbar-track-color: #e5e5ea;
--scrollbar-thumb-color: #aeaeb2;
--scrollbar-thumb-hover-color: #8e8e93;
--link-color: #007aff;
--default-icon-color: #5f5f5f;
}
body,
html {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
/* This will now correctly use themed --bg-color for both html and body */
color: var(--text-color);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
}
/* Padding moved from body,html to just body to ensure html background covers all */
body {
padding: 1rem 0;
}
.radio-player {
background-color: var(--player-bg-color);
border-radius: 24px;
padding: 2rem;
width: 90%;
max-width: 350px;
box-shadow: 0 10px 30px rgba(0, 0, 0, .2);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.station-icon-container {
width: 120px;
height: 120px;
border-radius: 18px;
background-color: var(--input-bg-color);
border: 1px solid var(--border-color);
box-shadow: 0 4px 15px rgba(0, 0, 0, .2);
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.station-icon-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.station-icon-container svg {
width: 60%;
height: 60%;
fill: var(--default-icon-color);
}
.station-info {
line-height: 1.3;
}
.station-info h2 {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-color);
}
.station-info h2 a {
color: var(--link-color);
margin-left: 0.5rem;
font-size: 0.8em;
text-decoration: none;
}
.station-info h2 a:hover {
text-decoration: underline;
}
.station-info p {
margin: .2rem 0 0 0;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
#status {
color: var(--text-color-secondary);
font-style: italic;
height: 18px;
font-size: 0.85rem;
}
.controls {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
width: 100%;
}
.controls button {
background-color: var(--control-bg);
border: none;
color: var(--text-color);
border-radius: 50%;
cursor: pointer;
transition: background-color .2s ease;
display: flex;
justify-content: center;
align-items: center;
}
.controls button:hover {
background-color: var(--control-bg-hover);
}
.controls .btn-secondary {
width: 45px;
height: 45px;
font-size: 1.3rem;
}
.controls .btn-primary {
width: 60px;
height: 60px;
font-size: 1.8rem;
}
.settings-panel {
margin-top: 0.8rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
width: 100%;
}
.search-controls-container {
display: flex;
gap: 0.5rem;
width: 100%;
max-width: 320px;
/* Limit width of search controls */
justify-content: center;
align-items: center;
}
.search-controls-container select {
padding: 0.5rem;
border-radius: 8px;
border: 1px solid var(--border-color);
background-color: var(--input-bg-color);
color: var(--text-color);
font-size: 0.8rem;
height: calc(1rem + 1rem + 2px);
/* Match input height */
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%238e8e93%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.4-5.4-12.8z%22/%3E%3C/svg%3E');
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 0.65em auto;
padding-right: 1.8rem;
flex-shrink: 0;
}
html.light-mode .search-controls-container select {
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%235f5f5f%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.4-5.4-12.8z%22/%3E%3C/svg%3E');
}
.input-with-suggestions {
position: relative;
flex-grow: 1;
display: flex;
/* Aligns input to fill space */
}
.input-with-suggestions input[type="text"] {
width: 100%;
padding: 0.5rem;
border-radius: 8px;
border: 1px solid var(--border-color);
background-color: var(--input-bg-color);
color: var(--text-color);
font-size: 0.9rem;
box-sizing: border-box;
/* Include padding and border in the element's total width and height */
}
.search-controls-container button {
padding: 0.5rem 0.8rem;
border-radius: 8px;
border: none;
background-color: var(--control-bg);
color: var(--text-color);
cursor: pointer;
font-size: 0.9rem;
transition: background-color .2s ease;
height: calc(1rem + 1rem + 2px);
/* Match input height */
flex-shrink: 0;
}
.search-controls-container button:hover {
background-color: var(--control-bg-hover);
}
#currentSearchMode {
font-size: 0.8rem;
color: var(--text-color-secondary);
margin-top: 0.3rem;
height: 16px;
}
.suggestions-container {
display: none;
position: absolute;
background-color: var(--player-bg-color);
border: 1px solid var(--border-color);
border-radius: 0 0 8px 8px;
border-top: none;
width: 100%;
max-height: 150px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 15px rgba(0, 0, 0, .3);
top: calc(100% - 1px);
/* Position directly below input, slightly overlapping border */
left: 0;
box-sizing: border-box;
}
.suggestion-item {
padding: 0.6rem 0.8rem;
cursor: pointer;
color: var(--text-color);
font-size: 0.9rem;
text-align: left;
}
.suggestion-item:hover {
background-color: var(--control-bg);
}
.suggestion-item.selected {
background-color: var(--control-bg-hover);
font-weight: 600;
}
.suggestions-container::-webkit-scrollbar {
width: 6px;
}
.suggestions-container::-webkit-scrollbar-track {
background: var(--scrollbar-track-color);
border-radius: 3px;
}
.suggestions-container::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-color);
border-radius: 3px;
}
.suggestions-container::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover-color);
}
.mode-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.mode-toggle label {
color: var(--text-color-secondary);
}
.mode-toggle input[type="checkbox"] {
appearance: none;
width: 40px;
height: 20px;
background-color: var(--input-bg-color);
border-radius: 10px;
position: relative;
cursor: pointer;
outline: none;
transition: background-color .2s;
}
.mode-toggle input[type="checkbox"]::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: var(--player-bg-color);
top: 2px;
left: 2px;
transition: transform .2s ease-in-out;
}
.mode-toggle input[type="checkbox"]:checked {
background-color: #34c759;
}
.mode-toggle input[type="checkbox"]:checked::before {
transform: translateX(20px);
}
/* Specific adjustments for toggle thumb visibility, now referencing html state */
html.light-mode .mode-toggle input[type="checkbox"]::before {
/* UNCHECKED toggle in Light Mode */
background-color: #8e8e93;
}
html.light-mode .mode-toggle input[type="checkbox"]:checked::before {
/* CHECKED toggle in Light Mode */
background-color: var(--player-bg-color);
}
html:not(.light-mode) .mode-toggle input[type="checkbox"]::before {
/* UNCHECKED toggle in Dark Mode */
background-color: #f2f2f7;
}
html:not(.light-mode) .mode-toggle input[type="checkbox"]:checked::before {
/* CHECKED toggle in Dark Mode */
background-color: #f2f2f7;
}
</style>
</head>
<body>
<div class="radio-player">
<div class="station-icon-container">
<img id="stationIcon" src="" alt="Station Icon" style="display:none;">
<svg id="defaultStationIcon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M6.51,3.31A10.43,10.43,0,0,0,3.31,6.51a.75.75,0,0,0,0,1.08L5.22,9.5a.75.75,0,0,0,1.08,0l1.9-1.91a.76.76,0,0,0,0-1.08L6.51,3.31ZM12,6a6,6,0,1,0,6,6A6,6,0,0,0,12,6Zm0,10.5A4.5,4.5,0,1,1,16.5,12,4.51,4.51,0,0,1,12,16.5ZM20.69,6.51A10.43,10.43,0,0,0,17.49,3.31L15.6,5.22a.76.76,0,0,0,0,1.08l1.91,1.9a.75.75,0,0,0,1.08,0l1.9-1.91A.75.75,0,0,0,20.69,6.51ZM3.31,17.49a10.43,10.43,0,0,0,3.2,3.2l1.91-1.9a.75.75,0,0,0,0-1.08L6.51,15.6a.76.76,0,0,0-1.08,0l-1.9,1.91A.75.75,0,0,0,3.31,17.49ZM15.6,18.78l1.9,1.91a10.43,10.43,0,0,0,3.2-3.2.75.75,0,0,0,0-1.08L18.78,14.5a.75.75,0,0,0-1.08,0l-1.91,1.91A.76.76,0,0,0,15.6,18.78Z M4,12a.75.75,0,0,0-.75-.75A10.46,10.46,0,0,0,2.1,15H2a.75.75,0,0,0,0,1.5h.1A10.46,10.46,0,0,0,3.25,20a.75.75,0,0,0,.68-.52.74.74,0,0,0-.18-.73A8.94,8.94,0,0,1,4,12Z M20,12a8.94,8.94,0,0,1-.25,2.25.74.74,0,0,0-.18-.73.75.75,0,0,0,.68.52A10.46,10.46,0,0,0,21.9,16.5H22a.75.75,0,0,0,0-1.5h-.1A10.46,10.46,0,0,0,20.75,11.25.75.75,0,0,0,20,12Z" />
</svg>
</div>
<div class="station-info">
<h2 id="stationName">RadioSurf <a id="stationHomepageLink" href="#" target="_blank" title="Visit station homepage" style="display:none;">🔗</a></h2>
<p id="stationCountry">Ready to explore?</p>
</div>
<div id="status">Press Play or search.</div>
<div class="controls">
<button id="prevBtn" class="btn-secondary">«</button>
<button id="playPauseBtn" class="btn-primary">▶</button>
<button id="nextBtn" class="btn-secondary">»</button>
</div>
<div class="settings-panel">
<div id="currentSearchMode">Surfing: Random</div>
<div class="search-controls-container">
<select id="searchTypeSelect">
<option value="tag" selected>Genre</option>
<option value="country">Country</option>
<option value="language">Language</option>
<option value="name">Name</option>
</select>
<div class="input-with-suggestions">
<input type="text" id="searchInput" placeholder="e.g., rock" autocomplete="off">
<div id="suggestionsContainer" class="suggestions-container"></div>
</div>
<button id="searchBtn">Search</button>
<button id="randomBtn">Random</button>
</div>
<div class="mode-toggle">
<input type="checkbox" id="modeSwitch">
<label for="modeSwitch" id="modeLabel">Data Saver Mode</label>
</div>
<div class="theme-toggle mode-toggle">
<input type="checkbox" id="themeSwitch">
<label for="themeSwitch" id="themeLabel">☀️ Light Mode</label>
</div>
</div>
</div>
<div id="player-container" style="display: none;">
<audio class="audio-pool-player" preload="none" crossorigin="anonymous" muted></audio>
<audio class="audio-pool-player" preload="none" crossorigin="anonymous" muted></audio>
<audio class="audio-pool-player" preload="none" crossorigin="anonymous" muted></audio>
</div>
<script>
const stationIconImg = document.getElementById('stationIcon');
const defaultStationIconSvg = document.getElementById('defaultStationIcon');
const stationName = document.getElementById('stationName');
const stationCountry = document.getElementById('stationCountry');
const stationHomepageLink = document.getElementById('stationHomepageLink');
const statusDiv = document.getElementById('status');
const prevBtn = document.getElementById('prevBtn');
const playPauseBtn = document.getElementById('playPauseBtn');
const nextBtn = document.getElementById('nextBtn');
const modeSwitch = document.getElementById('modeSwitch');
const modeLabel = document.getElementById('modeLabel');
const themeSwitch = document.getElementById('themeSwitch');
const themeLabel = document.getElementById('themeLabel');
const searchTypeSelect = document.getElementById('searchTypeSelect');
const searchInput = document.getElementById('searchInput');
const searchBtn = document.getElementById('searchBtn');
const randomBtn = document.getElementById('randomBtn');
const currentSearchModeDiv = document.getElementById('currentSearchMode');
const suggestionsContainer = document.getElementById('suggestionsContainer');
const playerPool = Array.from(document.querySelectorAll('.audio-pool-player'));
let stations = [];
let stationListIndex = -1;
let activePlayerIndex = 0;
let isPlaying = false;
let audioContext;
let isPerformanceMode = false;
let lastClickedStationUUID = null;
let API_BASE_URL = '';
let availableApiServers = [];
let currentApiServerIndex = -1;
let currentSearchTerm = null;
let currentSearchType = 'tag';
let currentSearchOffset = 0;
const STATIONS_LIMIT = 50;
let currentSuggestionIndex = -1;
let debounceSuggestionTimer;
// --- ALL FUNCTION DEFINITIONS START HERE ---
function applyTheme(isDarkActive) {
if(isDarkActive) {
document.documentElement.classList.remove('light-mode');
themeLabel.textContent = "🌙 Dark Mode";
} else {
document.documentElement.classList.add('light-mode');
themeLabel.textContent = "☀️ Light Mode";
}
}
function loadSavedTheme() {
const savedTheme = localStorage.getItem('radioSurfTheme');
if(savedTheme === 'light') {
themeSwitch.checked = false;
applyTheme(false);
} else {
themeSwitch.checked = true;
applyTheme(true);
}
}
function get_radiobrowser_base_urls_via_discovery() {
return new Promise((resolve, reject) => {
var request = new XMLHttpRequest();
const discoveryUrl = 'https://all.api.radio-browser.info/json/servers';
console.log("Attempting to fetch server list from:", discoveryUrl);
request.open('GET', discoveryUrl, true);
request.onload = function() {
if(request.status >= 200 && request.status < 300) {
try {
var items = JSON.parse(request.responseText).map(x => "https://" + x.name);
if(items.length === 0) {
reject("Discovery returned empty server list.");
return;
}
resolve(items);
} catch (e) {
reject(`Failed to parse server list from ${discoveryUrl}: ${e}`);
}
} else {
reject(`API discovery HTTP error: ${request.status} ${request.statusText} from ${discoveryUrl}`);
}
};
request.onerror = function() {
reject(`API discovery network error for ${discoveryUrl}`);
};
request.send();
});
}
async function initializeApiServer() {
try {
availableApiServers = await get_radiobrowser_base_urls_via_discovery();
for(let i = availableApiServers.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[availableApiServers[i], availableApiServers[j]] = [availableApiServers[j], availableApiServers[i]];
}
currentApiServerIndex = 0;
API_BASE_URL = availableApiServers[currentApiServerIndex];
console.log("Using API Server via discovery:", API_BASE_URL);
statusDiv.textContent = "API discovery successful.";
return true;
} catch (error) {
console.error("Dynamic server discovery failed:", error);
statusDiv.textContent = "API discovery failed. Using fallbacks...";
availableApiServers = ["https://de1.api.radio-browser.info", "https://nl1.api.radio-browser.info", "https://fr1.api.radio-browser.info", "https://at1.api.radio-browser.info", "https://us1.api.radio-browser.info", "https://ca1.api.radio-browser.info", "https://fi1.api.radio-browser.info", "https://de2.api.radio-browser.info"];
for(let i = availableApiServers.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[availableApiServers[i], availableApiServers[j]] = [availableApiServers[j], availableApiServers[i]];
}
currentApiServerIndex = 0;
API_BASE_URL = availableApiServers[currentApiServerIndex];
console.warn("Using fallback API Server:", API_BASE_URL);
return API_BASE_URL ? true : false;
}
}
function tryNextApiServer() {
if(availableApiServers.length === 0 || currentApiServerIndex === -1) return false;
currentApiServerIndex++;
if(currentApiServerIndex >= availableApiServers.length) {
console.error("Exhausted all available API servers.");
statusDiv.textContent = "Error: All API servers failed.";
return false;
}
API_BASE_URL = availableApiServers[currentApiServerIndex];
console.warn("Retrying with next API Server:", API_BASE_URL);
return true;
}
async function fetchSuggestions(query) { // query is already lowercase
const searchType = searchTypeSelect.value;
if(!API_BASE_URL || query.length < 2) {
hideSuggestions();
return;
}
let SUGGESTIONS_URL;
let mapFn = item => (item.name ? item.name.toLowerCase() : '');
if(searchType === 'tag') {
SUGGESTIONS_URL = `${API_BASE_URL}/json/tags/${encodeURIComponent(query)}?limit=7&hidebroken=true&order=stationcount&reverse=true`;
} else if(searchType === 'country') {
SUGGESTIONS_URL = `${API_BASE_URL}/json/countries/${encodeURIComponent(query)}?limit=7&hidebroken=true&order=stationcount&reverse=true`;
} else if(searchType === 'language') {
SUGGESTIONS_URL = `${API_BASE_URL}/json/languages/${encodeURIComponent(query)}?limit=7&hidebroken=true&order=stationcount&reverse=true`;
} else if(searchType === 'name') {
SUGGESTIONS_URL = `${API_BASE_URL}/json/stations/search?name=${encodeURIComponent(query)}&hidebroken=true&limit=7&order=clickcount&reverse=true`;
} else {
hideSuggestions();
return;
}
try {
const response = await fetch(SUGGESTIONS_URL, {
headers: {
'User-Agent': 'RadioSurfApp/1.0'
}
});
if(!response.ok) throw new Error(`API error for suggestions: ${response.status}`);
const results = await response.json();
renderSuggestions(results.map(mapFn).filter(name => name && name.trim() !== '').filter((v, i, a) => a.indexOf(v) === i)); // filter unique
} catch (error) {
console.warn(`Failed to fetch ${searchType} suggestions for "${query}":`, error);
hideSuggestions();
}
}
function renderSuggestions(items) { // items are already lowercase strings
suggestionsContainer.innerHTML = '';
if(items.length === 0) {
hideSuggestions();
return;
}
items.forEach((itemText, index) => {
const item = document.createElement('div');
item.classList.add('suggestion-item');
item.textContent = itemText; // itemText is already lowercase
item.dataset.index = index;
item.addEventListener('mousedown', () => {
searchInput.value = itemText; // itemText is already lowercase
hideSuggestions();
searchBtn.click();
});
suggestionsContainer.appendChild(item);
});
currentSuggestionIndex = -1;
suggestionsContainer.style.display = 'block';
}
function hideSuggestions() {
suggestionsContainer.style.display = 'none';
currentSuggestionIndex = -1;
}
function handleSuggestionNavigation(e) {
if(suggestionsContainer.style.display !== 'block' || suggestionsContainer.children.length === 0) {
if(e.key === 'Enter' && searchInput.value.trim()) {
searchBtn.click();
hideSuggestions();
}
return;
}
const items = suggestionsContainer.querySelectorAll('.suggestion-item');
if(!items.length) return;
if(e.key === 'ArrowDown') {
e.preventDefault();
currentSuggestionIndex = (currentSuggestionIndex + 1) % items.length;
updateSuggestionHighlight(items);
} else if(e.key === 'ArrowUp') {
e.preventDefault();
currentSuggestionIndex = (currentSuggestionIndex - 1 + items.length) % items.length;
updateSuggestionHighlight(items);
} else if(e.key === 'Enter') {
e.preventDefault();
if(currentSuggestionIndex > -1 && items[currentSuggestionIndex]) {
searchInput.value = items[currentSuggestionIndex].textContent; // textContent is already lowercase
hideSuggestions();
searchBtn.click();
} else if(searchInput.value.trim()) {
searchBtn.click();
hideSuggestions();
}
} else if(e.key === 'Escape') {
hideSuggestions();
}
}
function updateSuggestionHighlight(items) {
items.forEach((item, index) => {
if(index === currentSuggestionIndex) {
item.classList.add('selected');
item.scrollIntoView({
block: 'nearest',
inline: 'nearest'
});
} else {
item.classList.remove('selected');
}
});
}
async function fetchStations() {
if(!API_BASE_URL) {
statusDiv.textContent = 'Initializing API...';
const success = await initializeApiServer();
if(!success) {
console.error("API_BASE_URL not set and initialization failed.");
statusDiv.textContent = "Error: API init failed.";
return;
}
}
statusDiv.textContent = 'Finding stations...';
let STATIONS_URL;
const searchActive = currentSearchType && currentSearchTerm;
if(searchActive) {
let paramName = currentSearchType; // 'tag', 'country', 'language', 'name'
STATIONS_URL = `${API_BASE_URL}/json/stations/search?${paramName}=${encodeURIComponent(currentSearchTerm)}&order=clickcount&reverse=true&hidebroken=true&limit=${STATIONS_LIMIT}&offset=${currentSearchOffset}`;
let displayTerm = currentSearchTerm.charAt(0).toUpperCase() + currentSearchTerm.slice(1);
let displayType = currentSearchType.charAt(0).toUpperCase() + currentSearchType.slice(1);
currentSearchModeDiv.textContent = `Surfing by ${displayType}: ${displayTerm}`;
} else {
STATIONS_URL = `${API_BASE_URL}/json/stations/search?order=random&hidebroken=true&limit=${STATIONS_LIMIT}&offset=0`;
currentSearchModeDiv.textContent = 'Surfing: Random';
}
try {
const response = await fetch(STATIONS_URL, {
headers: {
'User-Agent': 'RadioSurfApp/1.0'
}
});
if(!response.ok) throw new Error(`API error: ${response.status} ${response.statusText} from ${STATIONS_URL}`);
const fetchedStations = (await response.json()).filter(s => s.url_resolved && (s.hls !== 1));
if(currentSearchOffset > 0 && searchActive) {
stations = stations.concat(fetchedStations);
} else {
stations = fetchedStations;
}
if(stations.length === 0 && currentSearchOffset === 0) {
statusDiv.textContent = searchActive ? `No non-HLS for ${currentSearchType} "${currentSearchTerm}".` : 'No non-HLS stations found.';
} else if(fetchedStations.length === 0 && currentSearchOffset > 0 && searchActive) {
statusDiv.textContent = `No more non-HLS for ${currentSearchType} "${currentSearchTerm}".`;
} else if(stations.length > 0) {
statusDiv.textContent = 'Stations loaded!';
}
console.log(`Loaded ${fetchedStations.length} non-HLS stations. Total non-HLS: ${stations.length}. Mode: ${searchActive ? currentSearchType + ':' + currentSearchTerm : 'Random'}, Offset: ${currentSearchOffset}. Server: ${API_BASE_URL}`);
preloadNextStations();
} catch (error) {
console.error(`Fetch error from ${API_BASE_URL}:`, error);
if(tryNextApiServer()) {
fetchStations();
} else {
statusDiv.textContent = 'Error finding stations (all servers tried).';
}
}
}
async function reportStationClick(stationUUID) {
if(!stationUUID || !API_BASE_URL) return;
if(lastClickedStationUUID === stationUUID && isPlaying) return;
console.log(`Click: ${stationUUID} to ${API_BASE_URL}`);
const CLICK_URL = `${API_BASE_URL}/json/url/${stationUUID}`;
try {
const c = new AbortController();
const t = setTimeout(() => c.abort(), 5000);
const r = await fetch(CLICK_URL, {
method: 'GET',
headers: {
'User-Agent': 'RadioSurfApp/1.0'
},
signal: c.signal
});
clearTimeout(t);
if(r.ok) {
const d = await r.json();
if(d.ok) {
console.log(`Clicked: ${d.name}`);
lastClickedStationUUID = stationUUID;
} else {
console.warn('Click not "ok":', d.message);
}
} else {
console.warn(`Click fail ${stationUUID}: ${r.status} ${r.statusText}`);
}
} catch (e) {
if(e.name === 'AbortError') {
console.warn('Click timeout.');
} else {
console.warn('Click error:', e);
}
}
}
function countryCodeToEmoji(countryCode) {
if(!countryCode || countryCode.length !== 2) {
return '';
}
const OFFSET = 0x1F1E6 - 'A'.charCodeAt(0);
const codePoints = Array.from(countryCode.toUpperCase()).map(char => char.charCodeAt(0) + OFFSET);
try {
return String.fromCodePoint(...codePoints);
} catch (e) {
console.warn("Error creating flag emoji for code:", countryCode, e);
return '';
}
}
function updateUI(station) {
if(!station) {
stationName.childNodes[0].nodeValue = "RadioSurf ";
stationHomepageLink.style.display = 'none';
stationCountry.textContent = "Ready to explore?";
stationIconImg.style.display = 'none';
defaultStationIconSvg.style.display = 'block';
return;
}
stationName.childNodes[0].nodeValue = station.name + " ";
if(station.homepage) {
stationHomepageLink.href = station.homepage;
stationHomepageLink.style.display = 'inline';
} else {
stationHomepageLink.style.display = 'none';
}
let countryInfo = station.country || 'Unknown';
let countryCode = station.countrycode;
let flagEmoji = countryCodeToEmoji(countryCode);
let bitrateInfo = station.bitrate ? `${station.bitrate} kbps` : '';
let displayText = "";
if(flagEmoji) {
displayText += `${flagEmoji} `;
}
displayText += countryInfo;
if(bitrateInfo) {
displayText += ` - ${bitrateInfo}`;
}
stationCountry.textContent = displayText.trim();
if(station.favicon) {
stationIconImg.src = station.favicon;
stationIconImg.style.display = 'block';
defaultStationIconSvg.style.display = 'none';
} else {
stationIconImg.style.display = 'none';
defaultStationIconSvg.style.display = 'block';
}
stationIconImg.onerror = () => {
stationIconImg.style.display = 'none';
defaultStationIconSvg.style.display = 'block';
};
}
function resetPlayer(player) {
player.pause();
player.removeAttribute('src');
player.load();
player.muted = true;
}
function preloadNextStations() {
const nextPlayerIndex = (activePlayerIndex + 1) % playerPool.length;
const stationForNextPlayer = stationListIndex + 1;
if(stationForNextPlayer < stations.length) {
const playerToPreload = playerPool[nextPlayerIndex];
if(playerToPreload.src !== stations[stationForNextPlayer].url_resolved) {
playerToPreload.src = stations[stationForNextPlayer].url_resolved;
playerToPreload.load();
}
}
if(isPerformanceMode) {
const thirdPlayerIndex = (activePlayerIndex + 2) % playerPool.length;
const stationForThirdPlayer = stationListIndex + 2;
if(stationForThirdPlayer < stations.length) {
const playerToPreload = playerPool[thirdPlayerIndex];
if(playerToPreload.src !== stations[stationForThirdPlayer].url_resolved) {
playerToPreload.src = stations[stationForThirdPlayer].url_resolved;
playerToPreload.load();
}
}
}
}
function playPauseToggle() {
if(!audioContext) {
audioContext = new(window.AudioContext || window.webkitAudioContext)();
if(audioContext.state === 'suspended') {
audioContext.resume();
}
}
const cP = playerPool[activePlayerIndex];
if(isPlaying) {
cP.pause();
} else {
if(stationListIndex === -1 && API_BASE_URL) {
playNext();
} else if(stationListIndex !== -1) {
cP.play().catch(e => console.warn("Play error (toggle):", e));
} else {
console.warn("API not ready.");
statusDiv.textContent = "Initializing API...";
}
}
}
async function playNext() {
if(!API_BASE_URL) {
const s = await initializeApiServer();
if(!s) {
statusDiv.textContent = "API Error.";
return;
}
}
const currentPlayer = playerPool[activePlayerIndex];
resetPlayer(currentPlayer);
activePlayerIndex = (activePlayerIndex + 1) % playerPool.length;
stationListIndex++;
lastClickedStationUUID = null;
if(stationListIndex >= stations.length || stations.length === 0) {
stationListIndex = -1; // Reset index
if(currentSearchType && currentSearchTerm) {
currentSearchOffset += STATIONS_LIMIT; // Paginate for active search
} else {
currentSearchOffset = 0; // Reset offset for random search
}
await fetchStations(); // Fetch more stations with updated offset or for random
if(stations.length > 0) {
stationListIndex = 0;
} // Start from the beginning of new list
else {
statusDiv.textContent = (currentSearchType && currentSearchTerm) ? `No more for ${currentSearchType} "${currentSearchTerm}".` : "No stations.";
updateUI(null);
return;
}
}
const newActivePlayer = playerPool[activePlayerIndex];
let currentStation = stations[stationListIndex];
if(!currentStation) {
console.error("!currentStation after list update and index check.");
statusDiv.textContent = "Error loading station.";
currentSearchType = null;
currentSearchTerm = null;
currentSearchOffset = 0;
await fetchStations();
if(stations.length > 0) stationListIndex = 0;
else {
updateUI(null);
return;
}
currentStation = stations[stationListIndex];
if(!currentStation) {
updateUI(null);
return;
}
}
updateUI(currentStation);
if(newActivePlayer.src !== currentStation.url_resolved) {
newActivePlayer.src = currentStation.url_resolved;
}
newActivePlayer.muted = false;
newActivePlayer.play().catch(e => {
console.warn(`Play error (next station ${currentStation.name}):`, e);
});
preloadNextStations();
}
async function playPrevious() {
if(!API_BASE_URL) {
const s = await initializeApiServer();
if(!s) {
statusDiv.textContent = "API Error.";
return;
}
}
if(stationListIndex > 0) {
const currentPlayer = playerPool[activePlayerIndex];
resetPlayer(currentPlayer);
activePlayerIndex = (activePlayerIndex - 1 + playerPool.length) % playerPool.length;
stationListIndex--;
lastClickedStationUUID = null;
const newActivePlayer = playerPool[activePlayerIndex];
const station = stations[stationListIndex];
updateUI(station);
if(newActivePlayer.src !== station.url_resolved) {
newActivePlayer.src = station.url_resolved;
}
newActivePlayer.muted = false;
newActivePlayer.play().catch(e => console.warn(`Play error (prev station ${station.name}):`, e));
preloadNextStations();
}
}
function handleSearchOrClear(isRandomSearch = false) {
const searchValue = searchInput.value.trim().toLowerCase(); // Always get lowercase
if(isRandomSearch) {
currentSearchType = null;
currentSearchTerm = null;
searchInput.value = "";
searchTypeSelect.value = "tag";
searchInput.placeholder = "e.g., rock";
} else {
if(!searchValue) {
statusDiv.textContent = "Please enter a search term.";
return;
}
currentSearchType = searchTypeSelect.value;
currentSearchTerm = searchValue; // Already lowercase
}
hideSuggestions();
stationListIndex = -1;
stations = [];
playPauseBtn.innerHTML = '▶';
isPlaying = false;
playerPool.forEach(pL => resetPlayer(pL));
updateUI(null);
currentSearchOffset = 0;
statusDiv.textContent = currentSearchTerm ? `Searching ${currentSearchType} "${currentSearchTerm}"...` : `Random surf...`;
fetchStations().then(() => {
if(stations.length > 0) {
playNext();
} else {
statusDiv.textContent = currentSearchTerm ? `No non-HLS for ${currentSearchType} "${currentSearchTerm}".` : 'No stations found.';
updateUI(null);
}
});
}
// --- ALL FUNCTION DEFINITIONS END HERE ---
// --- ASYNC STARTUP FUNCTION ---
async function startApp() {
loadSavedTheme();
searchInput.placeholder = "e.g., rock";
currentSearchType = 'tag'; // Default search type
const r = await initializeApiServer();
if(!r) {
console.error("App start fail: API init failed.");
updateUI(null);
} else {
updateUI(null);
}
}
// --- END ASYNC STARTUP FUNCTION ---
// --- EVENT LISTENERS (attach AFTER functions are defined AND startApp has been called) ---
themeSwitch.addEventListener('change', () => {
const isDarkNow = themeSwitch.checked;
applyTheme(isDarkNow);
localStorage.setItem('radioSurfTheme', isDarkNow ? 'dark' : 'light');
});
modeSwitch.addEventListener('change', () => {
isPerformanceMode = modeSwitch.checked;
modeLabel.textContent = isPerformanceMode ? "Performance Mode" : "Data Saver Mode";
console.log("Mode:", isPerformanceMode ? "Performance" : "Data Saver");
if(stations.length > 0 && stationListIndex !== -1) {
preloadNextStations();
}
});
searchBtn.addEventListener('click', () => handleSearchOrClear(false));
randomBtn.addEventListener('click', () => handleSearchOrClear(true));
searchInput.addEventListener('input', () => {
clearTimeout(debounceSuggestionTimer);
const query = searchInput.value.trim().toLowerCase(); // Normalize for fetching
if(query) {
debounceSuggestionTimer = setTimeout(() => fetchSuggestions(query), 300);
} else {
hideSuggestions();
}
});
searchInput.addEventListener('keydown', handleSuggestionNavigation);
searchInput.addEventListener('blur', () => {
setTimeout(() => {
if(!suggestionsContainer.contains(document.activeElement) && !searchInput.contains(document.activeElement)) {
hideSuggestions();
}
}, 150);
});
searchInput.addEventListener('focus', () => {
const query = searchInput.value.trim().toLowerCase();
if(query && (suggestionsContainer.children.length > 0 || query.length >= 2)) {
fetchSuggestions(query);
}
});
searchTypeSelect.addEventListener('change', () => {
const selectedType = searchTypeSelect.value;
currentSearchType = selectedType; // Update global type
if(selectedType === 'tag') {
searchInput.placeholder = "e.g., rock";
} else if(selectedType === 'country') {
searchInput.placeholder = "e.g., france";
} else if(selectedType === 'language') {
searchInput.placeholder = "e.g., spanish";
} else if(selectedType === 'name') {
searchInput.placeholder = "e.g., bbc radio";
}
searchInput.value = ""; // Clear input on type change
hideSuggestions();
// Optionally, if you want suggestions to appear immediately if a default query was relevant:
// const query = searchInput.value.trim().toLowerCase();
// if (query.length >= 2) {
// fetchSuggestions(query);
// }
});
playerPool.forEach((p, i) => {
p.addEventListener('playing', () => {
if(i === activePlayerIndex) {
isPlaying = true;
playPauseBtn.innerHTML = '❚❚';
statusDiv.textContent = 'Playing';
if(stations[stationListIndex] && stations[stationListIndex].stationuuid !== lastClickedStationUUID) {
reportStationClick(stations[stationListIndex].stationuuid);
}
}
});
p.addEventListener('pause', () => {
if(i === activePlayerIndex) {
isPlaying = false;
playPauseBtn.innerHTML = '▶';
statusDiv.textContent = 'Paused';
}
});
p.addEventListener('waiting', () => {
if(i === activePlayerIndex) {
statusDiv.textContent = 'Connecting...';
}
});
p.addEventListener('error', (e) => {
if(i === activePlayerIndex) {
console.error(`Audio Error active player (src: ${p.src || 'empty'}):`, p.error, e);
statusDiv.textContent = 'Stream error. Next...';
setTimeout(playNext, 1500);
} else {
console.warn(`Error background player ${i} (src: ${p.src || 'empty'}):`, p.error, e);
}
});
});
playPauseBtn.addEventListener('click', playPauseToggle);
nextBtn.addEventListener('click', playNext);
prevBtn.addEventListener('click', playPrevious);
document.addEventListener('click', function(event) {
const isClickInsideInput = searchInput.contains(event.target);
const isClickInsideSuggestions = suggestionsContainer.contains(event.target);
const isClickInsideSearchBtn = searchBtn.contains(event.target);
const isClickInsideSelect = searchTypeSelect.contains(event.target);
const isClickInsideRandomBtn = randomBtn.contains(event.target);
if(!isClickInsideInput && !isClickInsideSuggestions && !isClickInsideSearchBtn && !isClickInsideSelect && !isClickInsideRandomBtn) {
hideSuggestions();
}
});
// --- END EVENT LISTENERS ---
// CALL STARTUP FUNCTION (MUST BE LAST OR AFTER ALL DEPENDENCIES ARE MET)
startApp();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment