Last active
June 25, 2025 20:41
-
-
Save antonl-dev/5506ab017789be64479eb575e0f6dd82 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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