Fix wedged player with reconnect button and volume preservation

- Add reconnect button (🔄) to frameset player bar
- Recreate audio element on reconnect to fix wedged MediaElementSource
- Properly close and reinitialize AudioContext for spectrum analyzer
- Preserve volume and muted state when reconnecting
- Show status messages for connection issues
- Reduce Now Playing update interval to 5 seconds
- Add front-page.js to player-content.ctml for Now Playing updates
- Create About pages (about.ctml and about-content.ctml)
- Add About link to navigation in both modes
This commit is contained in:
Glenn Thompson 2025-12-07 20:42:16 +03:00
parent 8fd0b06b69
commit 2ed92ba003
12 changed files with 2134 additions and 85 deletions

View File

@ -930,6 +930,20 @@
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
:default-stream-encoding "audio/aac"))
;; About page (non-frameset mode)
(define-page about-page #@"/about" ()
"About Asteroid Radio"
(clip:process-to-string
(load-template "about")
:title "About - Asteroid Radio"))
;; About content (for frameset mode)
(define-page about-content #@"/about-content" ()
"About page content (displayed in content frame)"
(clip:process-to-string
(load-template "about-content")
:title "About - Asteroid Radio"))
(define-api asteroid/status () ()
"Get server status"
(api-output `(("status" . "running")

View File

@ -7,6 +7,12 @@
(ps:ps*
'(progn
;; Stream connection state
(defvar *stream-error-count* 0)
(defvar *last-play-attempt* 0)
(defvar *is-reconnecting* false)
(defvar *reconnect-timeout* nil)
;; Stream quality configuration
(defun get-stream-config (stream-base-url encoding)
(let ((config (ps:create
@ -137,6 +143,249 @@
(ps:chain local-storage (remove-item "useFrameset"))
(setf (ps:@ window location href) "/asteroid/"))
;; Stream status UI functions
(defun show-stream-status (message status-type)
"Show a status message to the user. status-type: 'error', 'warning', 'success', 'info'"
(let ((indicator (ps:chain document (get-element-by-id "stream-status-indicator"))))
(when indicator
(setf (ps:@ indicator inner-text) message)
(setf (ps:@ indicator style display) "block")
(setf (ps:@ indicator style background)
(cond
((= status-type "error") "#550000")
((= status-type "warning") "#554400")
((= status-type "success") "#005500")
(t "#003355")))
(setf (ps:@ indicator style border)
(cond
((= status-type "error") "1px solid #ff0000")
((= status-type "warning") "1px solid #ffaa00")
((= status-type "success") "1px solid #00ff00")
(t "1px solid #00aaff"))))))
(defun hide-stream-status ()
"Hide the status indicator"
(let ((indicator (ps:chain document (get-element-by-id "stream-status-indicator"))))
(when indicator
(setf (ps:@ indicator style display) "none"))))
(defun show-reconnect-button ()
"Show the reconnect button"
(let ((btn (ps:chain document (get-element-by-id "reconnect-btn"))))
(when btn
(setf (ps:@ btn style display) "inline-block"))))
(defun hide-reconnect-button ()
"Hide the reconnect button"
(let ((btn (ps:chain document (get-element-by-id "reconnect-btn"))))
(when btn
(setf (ps:@ btn style display) "none"))))
;; Recreate audio element to fix wedged state
(defun recreate-audio-element ()
"Recreate the audio element entirely to fix wedged MediaElementSource"
(let* ((container (ps:chain document (get-element-by-id "audio-container")))
(old-audio (ps:chain document (get-element-by-id "live-audio")))
(stream-base-url (ps:chain document (get-element-by-id "stream-base-url")))
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac"))
(config (get-stream-config (ps:@ stream-base-url value) stream-quality)))
(when (and container old-audio)
;; Reset spectrum analyzer before removing audio
(when (ps:@ window |resetSpectrumAnalyzer|)
(ps:chain window (reset-spectrum-analyzer)))
;; Remove old audio element
(ps:chain old-audio (pause))
(setf (ps:@ old-audio src) "")
(ps:chain old-audio (remove))
;; Create new audio element
(let ((new-audio (ps:chain document (create-element "audio"))))
(setf (ps:@ new-audio id) "live-audio")
(setf (ps:@ new-audio controls) t)
(setf (ps:@ new-audio crossorigin) "anonymous")
(setf (ps:@ new-audio style width) "100%")
(setf (ps:@ new-audio style margin) "10px 0")
;; Create source element
(let ((source (ps:chain document (create-element "source"))))
(setf (ps:@ source id) "audio-source")
(setf (ps:@ source src) (ps:@ config url))
(setf (ps:@ source type) (ps:@ config type))
(ps:chain new-audio (append-child source)))
;; Add to container
(ps:chain container (append-child new-audio))
;; Re-attach event listeners
(attach-audio-event-listeners new-audio)
(ps:chain console (log "Audio element recreated"))
new-audio))))
;; Main reconnect function
(defun reconnect-stream ()
"Reconnect the stream - called by user or automatically"
(when *is-reconnecting*
(return))
(setf *is-reconnecting* t)
(show-stream-status "🔄 Reconnecting to stream..." "info")
(hide-reconnect-button)
;; Clear any pending reconnect timeout
(when *reconnect-timeout*
(clear-timeout *reconnect-timeout*)
(setf *reconnect-timeout* nil))
(let ((audio-element (ps:chain document (get-element-by-id "live-audio"))))
(if audio-element
;; Try simple reload first
(progn
(ps:chain audio-element (pause))
(ps:chain audio-element (load))
;; Resume AudioContext if suspended
(when (ps:@ window |resetSpectrumAnalyzer|)
(ps:chain window (reset-spectrum-analyzer)))
;; Try to play after a short delay
(set-timeout
(lambda ()
(ps:chain audio-element (play)
(then (lambda ()
(setf *stream-error-count* 0)
(setf *is-reconnecting* false)
(show-stream-status "✓ Stream reconnected!" "success")
(set-timeout hide-stream-status 3000)
;; Reinitialize spectrum analyzer
(when (ps:@ window |initSpectrumAnalyzer|)
(set-timeout
(lambda ()
(ps:chain window (init-spectrum-analyzer)))
500))))
(catch (lambda (err)
(ps:chain console (log "Simple reconnect failed, recreating audio element:" err))
;; Simple reload failed, recreate the audio element
(let ((new-audio (recreate-audio-element)))
(when new-audio
(set-timeout
(lambda ()
(ps:chain new-audio (play)
(then (lambda ()
(setf *stream-error-count* 0)
(setf *is-reconnecting* false)
(show-stream-status "✓ Stream reconnected!" "success")
(set-timeout hide-stream-status 3000)))
(catch (lambda (err2)
(setf *is-reconnecting* false)
(incf *stream-error-count*)
(show-stream-status "❌ Could not reconnect. Click play to try again." "error")
(show-reconnect-button)
(ps:chain console (log "Reconnect failed:" err2))))))
500)))))))
500))
;; No audio element found, try to recreate
(let ((new-audio (recreate-audio-element)))
(if new-audio
(set-timeout
(lambda ()
(ps:chain new-audio (play)
(then (lambda ()
(setf *is-reconnecting* false)
(show-stream-status "✓ Stream connected!" "success")
(set-timeout hide-stream-status 3000)))
(catch (lambda (err)
(setf *is-reconnecting* false)
(show-stream-status "❌ Could not connect. Click play to try again." "error")
(show-reconnect-button)))))
500)
(progn
(setf *is-reconnecting* false)
(show-stream-status "❌ Could not create audio player. Please reload the page." "error")))))))
;; Attach event listeners to audio element
(defun attach-audio-event-listeners (audio-element)
"Attach all necessary event listeners to an audio element"
;; Error handler
(ps:chain audio-element
(add-event-listener "error"
(lambda (err)
(incf *stream-error-count*)
(ps:chain console (log "Stream error:" err))
(if (< *stream-error-count* 3)
;; Auto-retry for first few errors
(progn
(show-stream-status (+ "⚠️ Stream error. Reconnecting... (attempt " *stream-error-count* ")") "warning")
(setf *reconnect-timeout*
(set-timeout reconnect-stream 3000)))
;; Too many errors, show manual reconnect
(progn
(show-stream-status "❌ Stream connection lost. Click Reconnect to try again." "error")
(show-reconnect-button))))))
;; Stalled handler
(ps:chain audio-element
(add-event-listener "stalled"
(lambda ()
(ps:chain console (log "Stream stalled"))
(show-stream-status "⚠️ Stream stalled. Attempting to recover..." "warning")
(setf *reconnect-timeout*
(set-timeout
(lambda ()
;; Only reconnect if still stalled
(when (ps:@ audio-element paused)
(reconnect-stream)))
5000)))))
;; Waiting handler (buffering)
(ps:chain audio-element
(add-event-listener "waiting"
(lambda ()
(ps:chain console (log "Stream buffering..."))
(show-stream-status "⏳ Buffering..." "info"))))
;; Playing handler - clear any error states
(ps:chain audio-element
(add-event-listener "playing"
(lambda ()
(setf *stream-error-count* 0)
(hide-stream-status)
(hide-reconnect-button)
(when *reconnect-timeout*
(clear-timeout *reconnect-timeout*)
(setf *reconnect-timeout* nil)))))
;; Pause handler - track when paused for long pause detection
(ps:chain audio-element
(add-event-listener "pause"
(lambda ()
(setf *last-play-attempt* (ps:chain |Date| (now))))))
;; Play handler - detect long pauses that need reconnection
(ps:chain audio-element
(add-event-listener "play"
(lambda ()
(let ((pause-duration (- (ps:chain |Date| (now)) *last-play-attempt*)))
;; If paused for more than 30 seconds, reconnect to get fresh stream
(when (> pause-duration 30000)
(ps:chain console (log "Long pause detected, reconnecting for fresh stream..."))
(reconnect-stream))))))
;; Spectrum analyzer hooks
(when (ps:@ window |initSpectrumAnalyzer|)
(ps:chain audio-element (add-event-listener "play"
(lambda () (ps:chain window (init-spectrum-analyzer))))))
(when (ps:@ window |stopSpectrumAnalyzer|)
(ps:chain audio-element (add-event-listener "pause"
(lambda () (ps:chain window (stop-spectrum-analyzer)))))))
(defun redirect-when-frame ()
(let* ((path (ps:@ window location pathname))
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self))))
@ -164,80 +413,10 @@
;; Update now playing
(update-now-playing)
;; Auto-reconnect on stream errors
;; Attach event listeners to audio element
(let ((audio-element (ps:chain document (get-element-by-id "live-audio"))))
(when audio-element
(ps:chain audio-element
(add-event-listener
"error"
(lambda (err)
(ps:chain console (log "Stream error, attempting reconnect in 3 seconds..." err))
(set-timeout
(lambda ()
(ps:chain audio-element (load))
(ps:chain (ps:chain audio-element (play))
(catch (lambda (err)
(ps:chain console (log "Reconnect failed:" err))))))
3000))))
(ps:chain audio-element
(add-event-listener
"stalled"
(lambda ()
(ps:chain console (log "Stream stalled, reloading..."))
(ps:chain audio-element (load))
(ps:chain (ps:chain audio-element (play))
(catch (lambda (err)
(ps:chain console (log "Reload failed:" err))))))))
(let ((pause-timestamp nil)
(is-reconnecting false)
(needs-reconnect false)
(pause-reconnect-threshold 10000))
(ps:chain audio-element
(add-event-listener "pause"
(lambda ()
(setf pause-timestamp (ps:chain |Date| (now)))
(ps:chain console (log "Stream paused at:" pause-timestamp)))))
(ps:chain audio-element
(add-event-listener "play"
(lambda ()
(when (and (not is-reconnecting)
pause-timestamp
(> (- (ps:chain |Date| (now)) pause-timestamp) pause-reconnect-threshold))
(setf needs-reconnect true)
(ps:chain console (log "Long pause detected, will reconnect when playing starts...")))
(setf pause-timestamp nil))))
(ps:chain audio-element
(add-event-listener "playing"
(lambda ()
(when (and needs-reconnect (not is-reconnecting))
(setf is-reconnecting true)
(setf needs-reconnect false)
(ps:chain console (log "Reconnecting stream after long pause to clear stale buffers..."))
(ps:chain audio-element (pause))
(when (ps:@ window |resetSpectrumAnalyzer|)
(ps:chain window (reset-spectrum-analyzer)))
(ps:chain audio-element (load))
(set-timeout
(lambda ()
(ps:chain audio-element (play)
(catch (lambda (err)
(ps:chain console (log "Reconnect play failed:" err)))))
(when (ps:@ window |initSpectrumAnalyzer|)
(ps:chain window (init-spectrum-analyzer))
(ps:chain console (log "Spectrum analyzer reinitialized after reconnect")))
(setf is-reconnecting false))
200))))))))
(attach-audio-event-listeners audio-element)))
;; Check frameset preference
(let ((path (ps:@ window location pathname))
@ -249,8 +428,8 @@
(redirect-when-frame)))))
;; Update now playing every 10 seconds
(set-interval update-now-playing 10000)
;; Update now playing every 5 seconds
(set-interval update-now-playing 5000)
;; Listen for messages from popout window
(ps:chain window

View File

@ -33,9 +33,16 @@
(when *animation-id*
(cancel-animation-frame *animation-id*)
(setf *animation-id* nil))
;; Close the old AudioContext if it exists
(when *audio-context*
(ps:try
(ps:chain *audio-context* (close))
(:catch (e)
(ps:chain console (log "Error closing AudioContext:" e)))))
(setf *audio-context* nil)
(setf *analyser* nil)
(setf *media-source* nil)
(setf *current-audio-element* nil)
(ps:chain console (log "Spectrum analyzer reset for reconnection")))
(defun init-spectrum-analyzer ()

1462
static/asteroid.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1041,6 +1041,20 @@
:flex 1
:min-width "300px")
(.persistent-reconnect-btn
:background transparent
:color "#00ff00"
:border "1px solid #00ff00"
:padding "5px 10px"
:cursor "pointer"
:font-family #(main-font)
:font-size "0.85em"
:white-space nowrap
:margin-right "5px")
((:and .persistent-reconnect-btn :hover)
:background "#2a3441")
(.persistent-disable-btn
:background transparent
:color "#00ff00"

106
template/about-content.ctml Normal file
View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>About - Asteroid Radio</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
</head>
<body>
<div class="container">
<header>
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
<span>ABOUT ASTEROID RADIO</span>
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
</h1>
<nav class="nav">
<a href="/asteroid/content" target="_self">Home</a>
<a href="/asteroid/player-content" target="_self">Player</a>
<a href="/asteroid/about-content" target="_self">About</a>
<a href="/asteroid/status" target="_self">Status</a>
<a href="/asteroid/profile" target="_self" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" target="_self" data-show-if-admin>Admin</a>
<a href="/asteroid/login" target="_self" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" target="_self" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
</nav>
</header>
<main style="max-width: 800px; margin: 0 auto; padding: 20px;">
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🎵 Asteroid Music for Hackers</h2>
<p style="line-height: 1.6;">
Asteroid Radio is a community-driven internet radio station born from the SystemCrafters community.
We celebrate the intersection of music, technology, and hacker culture—broadcasting for those who
appreciate both great code and great music.
</p>
<p style="line-height: 1.6;">
We met through a shared set of technical biases and a love for building systems from first principles.
Asteroid Radio embodies that ethos: <strong>music for hackers, built by hackers</strong>.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🛠️ Built with Common Lisp</h2>
<p style="line-height: 1.6;">
This entire platform is built using <strong>Common Lisp</strong>, demonstrating the power and elegance
of Lisp for modern web applications. We use:
</p>
<ul style="line-height: 1.8; margin-left: 20px;">
<li><strong><a href="https://codeberg.org/shirakumo/radiance" style="color: #00ff00;">Radiance</a></strong> - Web application framework</li>
<li><strong><a href="https://codeberg.org/shinmera/clip" style="color: #00ff00;">Clip</a></strong> - HTML5-compliant template engine</li>
<li><strong><a href="https://codeberg.org/shinmera/LASS" style="color: #00ff00;">LASS</a></strong> - Lisp Augmented Style Sheets</li>
<li><strong><a href="https://gitlab.common-lisp.net/parenscript/parenscript" style="color: #00ff00;">ParenScript</a></strong> - Lisp-to-JavaScript compiler</li>
<li><strong><a href="https://icecast.org/" style="color: #00ff00;">Icecast</a></strong> - Streaming media server</li>
<li><strong><a href="https://www.liquidsoap.info/" style="color: #00ff00;">Liquidsoap</a></strong> - Audio stream generation</li>
</ul>
<p style="line-height: 1.6;">
By building in Common Lisp, we're doubling down on our technical values and creating features
for "our people"—those who appreciate the elegance of Lisp and the power of understanding your tools deeply.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">📖 Open Source & AGPL Licensed</h2>
<p style="line-height: 1.6;">
Asteroid Radio is <strong>free and open source software</strong>, licensed under the
<strong><a href="https://www.gnu.org/licenses/agpl-3.0.en.html" style="color: #00ff00;">GNU Affero General Public License v3.0 (AGPL)</a></strong>.
</p>
<p style="line-height: 1.6;">
The source code is available at:
<a href="https://github.com/Fade/asteroid" style="color: #00ff00; font-weight: bold;">https://github.com/Fade/asteroid</a>
</p>
<p style="line-height: 1.6;">
We believe in transparency, collaboration, and the freedom to study, modify, and share the software we use.
The AGPL ensures that improvements to Asteroid Radio remain free and available to everyone.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🎧 Features</h2>
<ul style="line-height: 1.8; margin-left: 20px;">
<li><strong>Live Streaming</strong> - Multiple quality options (AAC, MP3)</li>
<li><strong>Persistent Player</strong> - Frameset mode for uninterrupted playback while browsing</li>
<li><strong>Spectrum Analyzer</strong> - Real-time audio visualization with customizable themes</li>
<li><strong>Track Library</strong> - Browse and search the music collection</li>
<li><strong>User Profiles</strong> - Track your listening history</li>
<li><strong>Admin Tools</strong> - Manage tracks, users, and playlists</li>
</ul>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🤝 Community</h2>
<p style="line-height: 1.6;">
We're part of the <strong><a href="https://systemcrafters.net/" style="color: #00ff00;">SystemCrafters</a></strong>
community—a group of developers, hackers, and enthusiasts who believe in building systems from first principles,
understanding our tools deeply, and sharing knowledge freely.
</p>
<p style="line-height: 1.6;">
Join us in celebrating the intersection of great music and great code!
</p>
</section>
</main>
</div>
</body>
</html>

109
template/about.ctml Normal file
View File

@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>About - Asteroid Radio</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/asteroid/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
</head>
<body>
<div class="container">
<header>
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
<span>ABOUT ASTEROID RADIO</span>
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
</h1>
<nav class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/player">Player</a>
<a href="/asteroid/about">About</a>
<a href="/asteroid/status">Status</a>
<a href="/asteroid/profile" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" data-show-if-admin>Admin</a>
<a href="/asteroid/login" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
</nav>
</header>
<main style="max-width: 800px; margin: 0 auto; padding: 20px;">
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🎵 Asteroid Music for Hackers</h2>
<p style="line-height: 1.6;">
Asteroid Radio is a community-driven internet radio station born from the SystemCrafters community.
We celebrate the intersection of music, technology, and hacker culture—broadcasting for those who
appreciate both great code and great music.
</p>
<p style="line-height: 1.6;">
We met through a shared set of technical biases and a love for building systems from first principles.
Asteroid Radio embodies that ethos: <strong>music for hackers, built by hackers</strong>.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🛠️ Built with Common Lisp</h2>
<p style="line-height: 1.6;">
This entire platform is built using <strong>Common Lisp</strong>, demonstrating the power and elegance
of Lisp for modern web applications. We use:
</p>
<ul style="line-height: 1.8; margin-left: 20px;">
<li><strong><a href="https://codeberg.org/shirakumo/radiance" style="color: #00ff00;">Radiance</a></strong> - Web application framework</li>
<li><strong><a href="https://codeberg.org/shinmera/clip" style="color: #00ff00;">Clip</a></strong> - HTML5-compliant template engine</li>
<li><strong><a href="https://codeberg.org/shinmera/LASS" style="color: #00ff00;">LASS</a></strong> - Lisp Augmented Style Sheets</li>
<li><strong><a href="https://gitlab.common-lisp.net/parenscript/parenscript" style="color: #00ff00;">ParenScript</a></strong> - Lisp-to-JavaScript compiler</li>
<li><strong><a href="https://icecast.org/" style="color: #00ff00;">Icecast</a></strong> - Streaming media server</li>
<li><strong><a href="https://www.liquidsoap.info/" style="color: #00ff00;">Liquidsoap</a></strong> - Audio stream generation</li>
</ul>
<p style="line-height: 1.6;">
By building in Common Lisp, we're doubling down on our technical values and creating features
for "our people"—those who appreciate the elegance of Lisp and the power of understanding your tools deeply.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">📖 Open Source & AGPL Licensed</h2>
<p style="line-height: 1.6;">
Asteroid Radio is <strong>free and open source software</strong>, licensed under the
<strong><a href="https://www.gnu.org/licenses/agpl-3.0.en.html" style="color: #00ff00;">GNU Affero General Public License v3.0 (AGPL)</a></strong>.
</p>
<p style="line-height: 1.6;">
The source code is available at:
<a href="https://github.com/Fade/asteroid" style="color: #00ff00; font-weight: bold;">https://github.com/Fade/asteroid</a>
</p>
<p style="line-height: 1.6;">
We believe in transparency, collaboration, and the freedom to study, modify, and share the software we use.
The AGPL ensures that improvements to Asteroid Radio remain free and available to everyone.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🎧 Features</h2>
<ul style="line-height: 1.8; margin-left: 20px;">
<li><strong>Live Streaming</strong> - Multiple quality options (AAC, MP3)</li>
<li><strong>Persistent Player</strong> - Frameset mode for uninterrupted playback while browsing</li>
<li><strong>Spectrum Analyzer</strong> - Real-time audio visualization with customizable themes</li>
<li><strong>Track Library</strong> - Browse and search the music collection</li>
<li><strong>User Profiles</strong> - Track your listening history</li>
<li><strong>Admin Tools</strong> - Manage tracks, users, and playlists</li>
</ul>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🤝 Community</h2>
<p style="line-height: 1.6;">
We're part of the <strong><a href="https://systemcrafters.net/" style="color: #00ff00;">SystemCrafters</a></strong>
community—a group of developers, hackers, and enthusiasts who believe in building systems from first principles,
understanding our tools deeply, and sharing knowledge freely.
</p>
<p style="line-height: 1.6;">
Join us in celebrating the intersection of great music and great code!
</p>
</section>
</main>
</div>
</body>
</html>

View File

@ -28,10 +28,17 @@
<span class="now-playing-mini" id="mini-now-playing">Loading...</span>
<button id="reconnect-btn" onclick="reconnectStream()" class="persistent-reconnect-btn" title="Reconnect if audio stops working">
🔄
</button>
<button onclick="disableFramesetMode()" class="persistent-disable-btn">
✕ Disable
</button>
</div>
<!-- Status indicator for connection issues -->
<div id="stream-status" style="display: none; background: #550000; color: #ff6666; padding: 4px 10px; text-align: center; font-size: 0.85em;"></div>
<script>
// Configure audio element for better streaming
@ -134,6 +141,145 @@
// Redirect parent window to regular view
window.parent.location.href = '/asteroid/';
}
// Show status message
function showStatus(message, isError) {
const status = document.getElementById('stream-status');
if (status) {
status.textContent = message;
status.style.display = 'block';
status.style.background = isError ? '#550000' : '#005500';
status.style.color = isError ? '#ff6666' : '#66ff66';
if (!isError) {
setTimeout(() => { status.style.display = 'none'; }, 3000);
}
}
}
function hideStatus() {
const status = document.getElementById('stream-status');
if (status) {
status.style.display = 'none';
}
}
// Reconnect stream - recreates audio element to fix wedged state
function reconnectStream() {
console.log('Reconnecting stream...');
showStatus('🔄 Reconnecting...', false);
const container = document.querySelector('.persistent-player');
const oldAudio = document.getElementById('persistent-audio');
const streamBaseUrl = document.getElementById('stream-base-url').value;
const streamQuality = localStorage.getItem('stream-quality') || 'aac';
const config = getStreamConfig(streamBaseUrl, streamQuality);
if (!container || !oldAudio) {
showStatus('❌ Could not reconnect - reload page', true);
return;
}
// Save current volume and muted state
const savedVolume = oldAudio.volume;
const savedMuted = oldAudio.muted;
console.log('Saving volume:', savedVolume, 'muted:', savedMuted);
// Reset spectrum analyzer if it exists
if (window.resetSpectrumAnalyzer) {
window.resetSpectrumAnalyzer();
}
// Stop and remove old audio
oldAudio.pause();
oldAudio.src = '';
oldAudio.load();
// Create new audio element
const newAudio = document.createElement('audio');
newAudio.id = 'persistent-audio';
newAudio.controls = true;
newAudio.preload = 'metadata';
newAudio.crossOrigin = 'anonymous';
// Restore volume and muted state
newAudio.volume = savedVolume;
newAudio.muted = savedMuted;
// Create source
const source = document.createElement('source');
source.id = 'audio-source';
source.src = config.url;
source.type = config.type;
newAudio.appendChild(source);
// Replace old audio with new
oldAudio.replaceWith(newAudio);
// Re-attach event listeners
attachAudioListeners(newAudio);
// Try to play
setTimeout(() => {
newAudio.play()
.then(() => {
console.log('Reconnected successfully');
showStatus('✓ Reconnected!', false);
// Reinitialize spectrum analyzer - try in this frame first
if (window.initSpectrumAnalyzer) {
setTimeout(() => window.initSpectrumAnalyzer(), 500);
}
// Also try in content frame (where spectrum canvas usually is)
try {
const contentFrame = window.parent.frames['content-frame'];
if (contentFrame && contentFrame.initSpectrumAnalyzer) {
setTimeout(() => {
if (contentFrame.resetSpectrumAnalyzer) {
contentFrame.resetSpectrumAnalyzer();
}
contentFrame.initSpectrumAnalyzer();
console.log('Spectrum analyzer reinitialized in content frame');
}, 600);
}
} catch(e) {
console.log('Could not reinit spectrum in content frame:', e);
}
})
.catch(err => {
console.log('Reconnect play failed:', err);
showStatus('Click play to start stream', false);
});
}, 300);
}
// Attach event listeners to audio element
function attachAudioListeners(audioElement) {
audioElement.addEventListener('waiting', function() {
console.log('Audio buffering...');
});
audioElement.addEventListener('playing', function() {
console.log('Audio playing');
hideStatus();
});
audioElement.addEventListener('error', function(e) {
console.error('Audio error:', e);
showStatus('⚠️ Stream error - click 🔄 to reconnect', true);
});
audioElement.addEventListener('stalled', function() {
console.log('Audio stalled');
showStatus('⚠️ Stream stalled - click 🔄 if no audio', true);
});
}
// Attach listeners to initial audio element
document.addEventListener('DOMContentLoaded', function() {
const audioElement = document.getElementById('persistent-audio');
if (audioElement) {
attachAudioListeners(audioElement);
}
});
</script>
</body>
</html>

View File

@ -51,13 +51,14 @@
</div>
<nav class="nav">
<a href="/asteroid/content" target="content-frame">Home</a>
<a href="/asteroid/player-content" target="content-frame">Player</a>
<a href="/asteroid/status" target="content-frame">Status</a>
<a href="/asteroid/profile" target="content-frame" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" target="content-frame" data-show-if-admin>Admin</a>
<a href="/asteroid/login" target="content-frame" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" target="content-frame" data-show-if-logged-out>Register</a>
<a href="/asteroid/content" target="_self">Home</a>
<a href="/asteroid/player-content" target="_self">Player</a>
<a href="/asteroid/about-content" target="_self">About</a>
<a href="/asteroid/status" target="_self">Status</a>
<a href="/asteroid/profile" target="_self" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" target="_self" data-show-if-admin>Admin</a>
<a href="/asteroid/login" target="_self" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" target="_self" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
</nav>
</header>

View File

@ -53,6 +53,7 @@
<nav class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/player">Player</a>
<a href="/asteroid/about">About</a>
<a href="/asteroid/status">Status</a>
<a href="/asteroid/profile" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" data-show-if-admin>Admin</a>
@ -84,6 +85,9 @@
<p><strong class="live-stream-label" class="live-stream-label">BROADCASTING:</strong> <span id="stream-status" style="">Asteroid music for Hackers</span></p>
<div style="display: flex; gap: 10px; justify-content: end; margin-bottom: 20px;">
<button id="reconnect-btn" class="btn btn-warning" onclick="reconnectStream()" style="font-size: 0.9em; display: none;">
🔄 Reconnect Stream
</button>
<button id="popout-btn" class="btn btn-info" onclick="openPopoutPlayer()" style="font-size: 0.9em;">
🗗 Pop Out Player
</button>
@ -92,10 +96,15 @@
</button>
</div>
<audio id="live-audio" controls crossorigin="anonymous" style="width: 100%; margin: 10px 0;">
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
Your browser does not support the audio element.
</audio>
<!-- Stream connection status -->
<div id="stream-status-indicator" style="display: none; padding: 8px; margin-bottom: 10px; border-radius: 4px; text-align: center;"></div>
<div id="audio-container">
<audio id="live-audio" controls crossorigin="anonymous" style="width: 100%; margin: 10px 0;">
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
Your browser does not support the audio element.
</audio>
</div>
</div>
<div id="now-playing" class="now-playing"></div>

View File

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
<script src="/asteroid/static/js/front-page.js"></script>
<script src="/asteroid/static/js/player.js"></script>
<script src="/api/asteroid/spectrum-analyzer.js"></script>
</head>

View File

@ -9,6 +9,7 @@
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
<script src="/asteroid/static/js/front-page.js"></script>
<script src="/asteroid/static/js/player.js"></script>
</head>
<body>