1// === Option B: Simplified Buffering Display & Pause Countdown === 2 3let audioCtx, sourceNode, delayNode, gainNode, mediaElement; 4let playing = false; 5let isMuted = false; 6let bufferInterval = null; 7let pauseCountdownInterval = null; 8let reconnectAttempts = 0; 9let reconnectTimer = null; 10let stableTimer = null; 11let stationNameFetched = false; 12const MAX_RECONNECT = 6; 13const STABLE_PLAY_MS = 15000; 14 15const delaySlider = document.getElementById("delaySlider"); 16const delayStatus = document.getElementById("delayStatus"); 17const resetBtn = document.getElementById("resetBtn"); 18const streamUrlField = document.getElementById("streamUrl"); 19const playerStatus = document.getElementById("playerStatus"); 20const playPauseBtn = document.getElementById("playPauseBtn"); 21const volumeSlider = document.getElementById("volumeSlider"); 22const volumeIcon = document.getElementById("volumeIcon"); 23const volumeContainer = document.getElementById("volumeContainer"); 24const feedbackToast = document.getElementById("feedbackToast"); 25const loadBtn = document.getElementById("loadBtn"); 26const stationNameEl = document.getElementById("stationName"); 27const volumeTooltip = document.getElementById("volumeTooltip"); 28const howToLink = document.getElementById("howToLink"); 29const howToModal = document.getElementById("howToModal"); 30const closeHowToModalBtn = document.getElementById("closeHowToModal"); 31const streamTooltip = streamUrlField.nextElementSibling; 32streamTooltip.textContent = streamUrlField.value || streamUrlField.placeholder; 33 34 35// --- UI Helpers --- 36function setPlayerStatus(msg, mode = "") { 37 playerStatus.textContent = msg; 38 playerStatus.className = "player-status"; 39 if (mode) playerStatus.classList.add(mode); 40} 41 42function showFeedback(message) { 43 if (!feedbackToast) return; 44 feedbackToast.textContent = message; 45 feedbackToast.classList.add("show"); 46 clearTimeout(showFeedback.timeout); 47 showFeedback.timeout = setTimeout( 48 () => feedbackToast.classList.remove("show"), 49 700 50 ); 51} 52 53function updateDelayDisplay(value) { 54 const v = Number(value); 55 if (v === 0) { 56 delayStatus.innerHTML = 57 '<span style="color:red;font-weight:bold">ð´ LIVE</span>'; 58 } else { 59 delayStatus.textContent = `${v} sec delay`; 60 } 61} 62 63function openHowToModal() { 64 if (!howToModal) return; 65 howToModal.hidden = false; 66 document.body.style.overflow = "hidden"; 67 closeHowToModalBtn?.focus(); 68} 69 70function closeHowToModal() { 71 if (!howToModal) return; 72 howToModal.hidden = true; 73 document.body.style.overflow = ""; 74} 75 76// --- Station Name --- 77function setStationName(name) { 78 if (!stationNameEl) return; 79 if (name) { 80 stationNameEl.textContent = name; 81 stationNameEl.classList.add("visible"); 82 } else { 83 stationNameEl.textContent = ""; 84 stationNameEl.classList.remove("visible"); 85 } 86} 87 88async function fetchStationName(url) { 89 try { 90 const controller = new AbortController(); 91 const res = await fetch(url, { 92 headers: { "Icy-MetaData": "1" }, 93 signal: controller.signal, 94 cache: "no-store", 95 }); 96 controller.abort(); 97 const name = res.headers.get("icy-name"); 98 if (name && name.trim()) return name.trim(); 99 } catch {} 100 101 try { 102 const res = await fetch( 103 `https://de1.api.radio-browser.info/json/stations/byurl/${encodeURIComponent(url)}` 104 ); 105 if (!res.ok) return null; 106 const stations = await res.json(); 107 if (Array.isArray(stations) && stations.length > 0 && stations[0].name) { 108 return stations[0].name.trim() || null; 109 } 110 } catch {} 111 112 return null; 113} 114 115// --- Volume --- 116function setVolume(value) { 117 if (gainNode) gainNode.gain.value = value; 118 localStorage.setItem("volume", value); 119} 120 121function updateVolumeIcon(volume) { 122 if (isMuted || volume == 0) { 123 volumeIcon.innerHTML = 124 '<path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16 9l5 6M21 9l-5 6" stroke="white" stroke-width="2" fill="none"/>'; 125 } else { 126 volumeIcon.innerHTML = 127 '<path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v8.06c1.48-.74 2.5-2.26 2.5-4.03z"/>'; 128 } 129} 130 131volumeContainer.addEventListener("click", (e) => { 132 if (e.target === volumeSlider) return; 133 isMuted = !isMuted; 134 updateVolumeIcon(volumeSlider.value); 135 setVolume(isMuted ? 0 : volumeSlider.value);
136 showFeedback(isMuted ? "ð Muted" : "ð Unmuted"); 137}); 138 139volumeSlider.addEventListener("input", () => { 140 isMuted = volumeSlider.value == 0; 141 updateVolumeIcon(volumeSlider.value); 142 setVolume(volumeSlider.value); 143 showFeedback(`Volume: ${(volumeSlider.value * 100).toFixed(0)}%`); 144 updateVolumeTooltip(); 145}); 146 147// --- Volume Tooltip --- 148function updateVolumeTooltip() { 149 if (!volumeSlider || !volumeTooltip) return; 150 const min = parseFloat(volumeSlider.min); 151 const max = parseFloat(volumeSlider.max); 152 const val = parseFloat(volumeSlider.value); 153 const percent = (val - min) / (max - min); 154 const sliderWidth = volumeSlider.offsetWidth; 155 const thumbWidth = 14; 156 const offset = percent * (sliderWidth - thumbWidth) + thumbWidth / 2; 157 volumeTooltip.style.left = `${offset}px`; 158 volumeTooltip.textContent = `${Math.round(val * 100)}%`; 159} 160window.addEventListener("load", updateVolumeTooltip); 161 162// --- Buffering Display (simplified) --- 163function startBufferingDisplay() { 164 if (!delayNode || !mediaElement) return; 165 166 if (bufferInterval) clearInterval(bufferInterval); 167 168 setPlayerStatus("Bufferingâ¦", "loading"); 169 170 bufferInterval = setInterval(() => { 171 const delayTime = delayNode.delayTime.value; 172 if (delayTime <= 0) { 173 clearInterval(bufferInterval); 174 bufferInterval = null; 175 setPlayerStatus("Playing LIVE (no delay)", "playing"); 176 return; 177 } 178 179 // stop buffering message if playback has reached the delay 180 // (approximation: can be removed if you want "always buffering" until user interaction) 181 // For simplicity, here we just clear after a short interval 182 clearInterval(bufferInterval); 183 bufferInterval = null; 184 setPlayerStatus(`Playing (${delayTime}s delay)`, "playing"); 185 }, 300); 186} 187 188// --- Pause Countdown --- 189function startPauseCountdown() { 190 if (!delayNode) return; 191 192 clearInterval(pauseCountdownInterval); 193 let countdown = Math.ceil(delayNode.delayTime.value); 194 pauseCountdownInterval = setInterval(() => { 195 if (countdown <= 0) { 196 clearInterval(pauseCountdownInterval); 197 pauseCountdownInterval = null; 198 setPlayerStatus("Paused", "paused"); 199 } else { 200 setPlayerStatus(`Pausing in ${countdown}sâ¦`, "loading"); 201 countdown--; 202 } 203 }, 1000); 204} 205 206// --- Load and Initialize Stream --- 207async function loadStream(isReconnect = false) { 208 const url = streamUrlField.value.trim(); 209 if (!url) { 210 setPlayerStatus("Please enter a valid stream URL", "error"); 211 alert("Please enter a valid stream URL"); 212 return; 213 } 214 215 clearTimeout(reconnectTimer); 216 clearTimeout(stableTimer); 217 if (!isReconnect) { 218 reconnectAttempts = 0; 219 stationNameFetched = false; 220 setStationName(null); 221 } 222 showFeedback("Loading streamâ¦"); 223 setPlayerStatus("Loadingâ¦", "loading"); 224 225 if (audioCtx) await audioCtx.close(); 226 audioCtx = new AudioContext(); 227 228 // Safe max delay 229 let maxDelayTime = audioCtx.maxDelayTime; 230 if (typeof maxDelayTime !== "number" || !isFinite(maxDelayTime)) 231 maxDelayTime = 180; 232 const safeMaxDelay = Math.min(180, maxDelayTime - 0.001); 233 234 // Safe initial delay 235 let initialDelay = Number(delaySlider.value); 236 if (!isFinite(initialDelay) || initialDelay < 0) initialDelay = 0; 237 238 mediaElement = new Audio(url); 239 mediaElement.crossOrigin = "anonymous"; 240 mediaElement.autoplay = true; 241 mediaElement.preload = "auto"; 242 243 sourceNode = audioCtx.createMediaElementSource(mediaElement); 244 delayNode = audioCtx.createDelay(safeMaxDelay); 245 delayNode.delayTime.value = initialDelay; 246 247 gainNode = audioCtx.createGain(); 248 gainNode.gain.value = Number(volumeSlider.value) || 1; 249 250 sourceNode.connect(delayNode).connect(gainNode).connect(audioCtx.destination); 251 252 mediaElement.addEventListener("playing", () => { 253 clearTimeout(stableTimer); 254 stableTimer = setTimeout(() => { reconnectAttempts = 0; }, STABLE_PLAY_MS); 255 setPlayerStatus( 256 delayNode.delayTime.value === 0 257 ? "Playing LIVE (no delay)" 258 : `Playing (${delayNode.delayTime.value}s delay)`, 259 "playing" 260 ); 261 if (!stationNameFetched) { 262 stationNameFetched = true; 263 fetchStationName(url).then(name => { if (name) setStationName(name); }); 264 } 265 }); 266 267 mediaElement.addEventListener("waiting", () => { 268 setPlayerStatus("Bufferingâ¦", "loading"); 269 }); 270 271 mediaElement.addEventListener("error", () => { 272 clearTimeout(stableTimer); 273 if (!playing) return; 274 const url = streamUrlField.value.trim(); 275 fetch(url, { method: "HEAD", cache: "no-store" }) 276 .then((res) => { 277 if (res.status >= 400 && res.status < 500) { 278 playing = false;
279 fadeIcon("play"); 280 setPlayerStatus("Stream expired â reload a fresh URL", "error"); 281 return; 282 } 283 scheduleReconnect(); 284 }) 285 .catch(scheduleReconnect); 286 }); 287 288 function scheduleReconnect() { 289 if (reconnectAttempts >= MAX_RECONNECT) { 290 setPlayerStatus("Stream unavailable â try reloading", "error"); 291 return; 292 } 293 reconnectAttempts++; 294 const delay = Math.min(2000 * reconnectAttempts, 15000); 295 setPlayerStatus(`Stream interrupted â reconnecting in ${delay / 1000}sâ¦`, "loading"); 296 clearTimeout(reconnectTimer); 297 reconnectTimer = setTimeout(() => { 298 if (playing) loadStream(true); 299 }, delay); 300 } 301 302 try { 303 await audioCtx.resume(); 304 await mediaElement.play(); 305 playing = true; 306 fadeIcon("pause"); 307 showFeedback("â¶ï¸ Playing"); 308 localStorage.setItem("streamUrl", url); 309 } catch (err) { 310 console.error(err); 311 if (err.name === "NotAllowedError") { 312 setPlayerStatus("Click Load to play stream"); 313 } else { 314 setPlayerStatus("Playback failed â check stream or CORS", "error"); 315 } 316 } 317} 318 319// --- Play/Pause --- 320async function togglePlayback() { 321 if (!playing) { 322 await loadStream(); 323 } else { 324 startPauseCountdown(); 325 mediaElement.pause(); 326 playing = false; 327 fadeIcon("play"); 328 showFeedback("â¸ï¸ Paused"); 329 } 330} 331 332// --- Delay Slider On-the-fly --- 333delaySlider.addEventListener("input", () => { 334 let value = Number(delaySlider.value); 335 if (!isFinite(value) || value < 0) value = 0; 336 updateDelayDisplay(value); 337 if (delayNode) { 338 delayNode.delayTime.value = value; 339 startBufferingDisplay(); 340 } 341 localStorage.setItem("delay", value); 342 setPlayerStatus( 343 value === 0 ? "Playing LIVE (no delay)" : `Playing (${value}s delay)`, 344 "playing" 345 ); 346}); 347 348// --- Reset to LIVE --- 349resetBtn.addEventListener("click", () => { 350 delaySlider.value = 0; 351 updateDelayDisplay(0); 352 if (delayNode) delayNode.delayTime.value = 0; 353 setPlayerStatus("ð´ LIVE (no delay)", "live"); 354 showFeedback("LIVE (0 delay)"); 355}); 356 357// --- Buttons --- 358playPauseBtn.addEventListener("click", togglePlayback); 359loadBtn.addEventListener("click", togglePlayback); 360 361// --- Keyboard shortcuts --- 362window.addEventListener("keydown", (e) => { 363 if (e.key === "Escape" && howToModal && !howToModal.hidden) { 364 closeHowToModal(); 365 howToLink?.focus(); 366 return; 367 } 368 if (e.target.tagName === "INPUT") return; 369 switch (e.key.toLowerCase()) { 370 case " ": 371 e.preventDefault(); 372 togglePlayback(); 373 break; 374 case "arrowup": 375 e.preventDefault(); 376 volumeSlider.value = Math.min( 377 1, 378 parseFloat(volumeSlider.value) + 0.05 379 ).toFixed(2); 380 setVolume(volumeSlider.value); 381 updateVolumeIcon(volumeSlider.value); 382 updateVolumeTooltip(); 383 showFeedback(`Volume: ${(volumeSlider.value * 100).toFixed(0)}%`); 384 break; 385 case "arrowdown": 386 e.preventDefault(); 387 volumeSlider.value = Math.max( 388 0, 389 parseFloat(volumeSlider.value) - 0.05 390 ).toFixed(2); 391 setVolume(volumeSlider.value); 392 updateVolumeIcon(volumeSlider.value); 393 updateVolumeTooltip(); 394 showFeedback(`Volume: ${(volumeSlider.value * 100).toFixed(0)}%`); 395 break; 396 case "arrowleft": 397 e.preventDefault(); 398 delaySlider.value = Math.max(0, parseInt(delaySlider.value) - 1); 399 if (delayNode) delayNode.delayTime.value = delaySlider.value; 400 updateDelayDisplay(delaySlider.value); 401 startBufferingDisplay(); 402 break; 403 case "arrowright": 404 e.preventDefault(); 405 delaySlider.value = Math.min(180, parseInt(delaySlider.value) + 1); 406 if (delayNode) delayNode.delayTime.value = delaySlider.value; 407 updateDelayDisplay(delaySlider.value); 408 startBufferingDisplay(); 409 break; 410 case "l": 411 e.preventDefault(); 412 loadBtn.click(); 413 break; 414 case "m": 415 e.preventDefault(); 416 isMuted = !isMuted; 417 updateVolumeIcon(volumeSlider.value); 418 setVolume(isMuted ? 0 : volumeSlider.value); 419 showFeedback(isMuted ? "ð Muted" : "ð Unmuted"); 420 break; 421 case "g": 422 e.preventDefault(); 423 resetBtn.click(); 424 break; 425 } 426}); 427 428// --- Play/Pause icon fade --- 429function fadeIcon(state) { 430 playPauseBtn.classList.add("fade-out"); 431 setTimeout(() => { 432 playPauseBtn.innerHTML = 433 state === "pause" 434 ? '<div class="icon pause"><span></span><span></span></div>' 435 : '<div class="icon play"></div>'; 436 playPauseBtn.classList.remove("fade-out"); 437 playPauseBtn.classList.add("fade-in"); 438 setTimeout(() => playPauseBtn.classList.remove("fade-in"), 150); 439 }, 120); 440} 441 442//abn 443 444howToLink?.addEventListener("click", (e) => { 445 e.preventDefault(); 446 openHowToModal(); 447}); 448 449closeHowToModalBtn?.addEventListener("click", () => { 450 closeHowToModal(); 451 howToLink?.focus(); 452}); 453 454howToModal?.addEventListener("click", (e) => { 455 if (e.target === howToModal) { 456 closeHowToModal(); 457 } 458}); 459 460// --- On page load --- 461window.addEventListener("DOMContentLoaded", () => { 462 const queryParams = new URLSearchParams(window.location.search); 463 const streamFromQuery = queryParams.get("stream") || queryParams.get("url"); 464 const savedUrl = localStorage.getItem("streamUrl"); 465 const savedDelay = localStorage.getItem("delay"); 466 const savedVol = localStorage.getItem("volume"); 467 if (streamFromQuery) { 468 streamUrlField.value = streamFromQuery; 469 localStorage.setItem("streamUrl", streamFromQuery); 470 } else if (savedUrl) { 471 streamUrlField.value = savedUrl; 472 } 473 if (savedDelay) { 474 delaySlider.value = savedDelay; 475 updateDelayDisplay(savedDelay); 476 } 477 if (savedVol) volumeSlider.value = savedVol; 478 setPlayerStatus("Ready â enter a stream URL"); 479 480 // Load main title SVG 481 const titleEl = document.getElementById("title"); 482 if (!titleEl) return; 483 fetch("rdtitle.svg") 484 .then((response) => response.text()) 485 .then((svgText) => { 486 const parser = new DOMParser(); 487 const svgDoc = parser.parseFromString(svgText, "image/svg+xml"); 488 const svgElement = svgDoc.documentElement; 489 svgElement.removeAttribute("width"); 490 svgElement.removeAttribute("height"); 491 titleEl.appendChild(svgElement); 492 }) 493 .catch((err) => console.error("SVG load error:", err)); 494}); 495 496document.getElementById("copyright-year").textContent = new Date().getFullYear() || 2026;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.