diff --git a/asteroid.lisp b/asteroid.lisp
index 8a53086..1bfac2f 100644
--- a/asteroid.lisp
+++ b/asteroid.lisp
@@ -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")
diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp
index 572d818..4441e78 100644
--- a/parenscript/front-page.lisp
+++ b/parenscript/front-page.lisp
@@ -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
diff --git a/parenscript/spectrum-analyzer.lisp b/parenscript/spectrum-analyzer.lisp
index e520b76..0d95643 100644
--- a/parenscript/spectrum-analyzer.lisp
+++ b/parenscript/spectrum-analyzer.lisp
@@ -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 ()
diff --git a/static/asteroid.css b/static/asteroid.css
new file mode 100644
index 0000000..2b7e936
--- /dev/null
+++ b/static/asteroid.css
@@ -0,0 +1,1462 @@
+@import url("https://fonts.googleapis.com/css2?family=VT323&display=swap");
+
+body{
+ font-family: VT323, monospace;
+ font-weight: 400;
+ font-style: normal;
+ background: #0a0a0a;
+ color: #00ffff;
+ margin: 0;
+ padding: 20px;
+ box-sizing: border-box;
+}
+
+body select{
+ font-family: VT323, monospace;
+}
+
+
+
+body header .page-title{
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 15px;
+ margin: 0;
+}
+
+body header .page-subtitle{
+ color: #4488FF;
+ display: flex;
+ justify-content: center;
+ font-style: italic;
+ margin: 0;
+ margin-top: .8rem;
+ margin-bottom: 4rem;
+}
+
+body .container{
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+body strong{
+ letter-spacing: 0.08rem;
+}
+
+body h1{
+ color: #4488ff;
+ text-align: center;
+ font-size: 2.5em;
+ margin-bottom: 30px;
+}
+
+body h2{
+ color: #4488ff;
+}
+
+body .status{
+ background: #1a2332;
+ padding: 20px;
+ border: 1px solid #2a3441;
+ margin: 20px 0;
+}
+
+body .panel{
+ background: #1a2332;
+ padding: 20px;
+ border: 1px solid #2a3441;
+ margin: 20px 0;
+}
+
+body .nav{
+ margin: 20px 0;
+ display: flex;
+ gap: 5px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+body .nav a{
+ color: #00ffff;
+ text-decoration: none;
+ margin: 0;
+ padding: 10px 20px;
+ border: 1px solid #2a3441;
+ background: #1a2332;
+ min-width: 100px;
+ text-align: center;
+ border-sizing: border-box;
+ letter-spacing: 0.08rem;
+ cursor: pointer;
+ display: inline-block;
+}
+
+body .nav a:first-child{
+ margin-left: 0;
+}
+
+body .nav a:hover{
+ text-decoration: underline;
+ text-underline-offset: 5px;
+ background: #2a3441;
+ color: #00ff00;
+}
+
+body .nav .btn-logout{
+ background: #2a3441;
+ border-color: #3a4551;
+ color: #ff9999;
+}
+
+body .nav .btn-logout:hover{
+ background: #3a4551;
+ border-color: #4a5561;
+ color: #ffaaaa;
+}
+
+body [data-show-if-logged-in]{
+ display: none;
+}
+
+body [data-show-if-logged-out]{
+ display: none;
+}
+
+body [data-show-if-admin]{
+ display: none;
+}
+
+body .controls{
+ margin: 20px 0;
+}
+
+body .controls button{
+ background: #1a2332;
+ color: #00ffff;
+ border: 1px solid #2a3441;
+ padding: 10px 20px;
+ margin: 5px;
+ cursor: pointer;
+}
+
+body .controls button:hover{
+ background: #2a3441;
+}
+
+body button{
+ background: #2a3441;
+ color: #00ffff;
+ border: 1px solid #3a4551;
+ padding: 10px 20px;
+ margin: 5px;
+ cursor: pointer;
+}
+
+body button:hover{
+ background: #3a4551;
+}
+
+body .now-playing{
+ background: #1a2332;
+ padding: 20px;
+ border: 1px solid #2a3441;
+ margin: 20px 0;
+ font-size: 1.5em;
+ color: #4488ff;
+ overflow: auto;
+}
+
+body .recently-played-panel{
+ background: #1a2332;
+ border: 1px solid #2a3441;
+ border-radius: 8px;
+ padding: 20px;
+ margin: 20px 0;
+}
+
+body .recently-played-panel h3{
+ margin: 0 0 15px 0;
+ color: #00ff00;
+ font-size: 1.2em;
+ font-weight: 600;
+}
+
+body .recently-played-panel .recently-played-list{
+ min-height: 100px;
+}
+
+body .recently-played-panel .recently-played-list .loading,
+body .recently-played-panel .recently-played-list .no-tracks,
+body .recently-played-panel .recently-played-list .error{
+ text-align: center;
+ color: #888;
+ padding: 20px;
+ font-style: italic;
+}
+
+body .recently-played-panel .recently-played-list .error{
+ color: #ff4444;
+}
+
+body .recently-played-panel .recently-played-list .track-link{
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: #00ffff;
+ text-decoration: none;
+ font-weight: 500;
+ font-size: 1em;
+ -moz-transition: all 0.2s;
+ -o-transition: all 0.2s;
+ -webkit-transition: all 0.2s;
+ -ms-transition: all 0.2s;
+ transition: all 0.2s;
+}
+
+body .recently-played-panel .recently-played-list .track-link .external-icon{
+ width: 14px;
+ height: 14px;
+ margin-left: 2px;
+ vertical-align: middle;
+}
+
+body .recently-played-panel .recently-played-list .track-link:hover{
+ color: #00ff00;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
+body .recently-played-panel .recently-played-list .track-link:visited{
+ color: #4488ff;
+}
+
+body .recently-played-panel .recently-played-list .track-list{
+ list-style: none;
+ padding: 0 12px;
+ margin: 0;
+ border: none;
+ max-height: none;
+ overflow-y: visible;
+}
+
+body .recently-played-panel .recently-played-list .track-item{
+ padding: 10px 0;
+ border-bottom: 1px solid #2a3441;
+ -moz-transition: background-color 0.2s;
+ -o-transition: background-color 0.2s;
+ -webkit-transition: background-color 0.2s;
+ -ms-transition: background-color 0.2s;
+ transition: background-color 0.2s;
+}
+
+body .recently-played-panel .recently-played-list .track-item LAST-CHILD{
+ border-bottom: none;
+}
+
+body .recently-played-panel .recently-played-list .track-item HOVER{
+ background-color: #2a3441;
+}
+
+body .recently-played-panel .recently-played-list.track-info{
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: auto auto;
+ gap: 2px 20px;
+ align-items: center;
+}
+
+body .recently-played-panel .recently-played-list.track-title{
+ grid-column: 1;
+ grid-row: 1;
+}
+
+body .recently-played-panel .recently-played-list.track-artist{
+ color: #4488ff;
+ font-size: 0.9em;
+ grid-column: 1;
+ grid-row: 2;
+}
+
+body .recently-played-panel .recently-played-list.track-time{
+ color: #888;
+ font-size: 0.85em;
+ grid-column: 2;
+ grid-row: 1;
+ text-align: right;
+}
+
+body .back{
+ color: #00ffff;
+ text-decoration: none;
+ margin-bottom: 20px;
+ display: inline-block;
+}
+
+body .back:hover{
+ text-decoration: underline;
+}
+
+body .player{
+ background: #1a2332;
+ padding: 40px;
+ border: 1px solid #2a3441;
+ margin: 40px auto;
+ max-width: 600px;
+}
+
+
+
+body .player .controls button{
+ padding: 15px 30px;
+ margin: 10px;
+ font-size: 1.2em;
+}
+
+body .player-section{
+ background: #1a2332;
+ padding: 25px;
+ border: 1px solid #2a3441;
+ margin: 20px 0;
+ border-radius: 5px;
+}
+
+body .player-section .live-stream{
+ overflow: auto;
+}
+
+body .live-stream{
+ margin-top: 2rem;
+ font-size: 1.1rem;
+ color: #4488FF;
+}
+
+body .live-stream .live-stream-label{
+ font-size: 1.2rem;
+ color: #00FFFF;
+}
+
+body .live-stream code{
+ font-size: 0.9rem;
+}
+
+body .live-stream .frame-enable-message{
+ color: #00FFFF;
+}
+
+body .live-stream .live-stream-quality{
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+body .live-stream .live-stream-quality label{
+ margin-right: 10px;
+}
+
+body .live-stream .live-stream-quality select{
+ background: transparent;
+ color: #00ff00;
+ border: 1px solid #00ff00;
+ letter-spacing: 0.08rem;
+ font-size: 0.95rem;
+ min-width: 220px;
+ width: fit-content;
+ padding: 5px;
+}
+
+body .live-stream .live-stream-quality select:hover{
+ background: #2a3441;
+}
+
+body .track-browser{
+ margin: 15px 0;
+}
+
+body .search-input{
+ width: 100%;
+ padding: 12px;
+ background: #0a0a0a;
+ color: #00ffff;
+ border: 1px solid #2a3441;
+ font-family: Courier New, monospace;
+ font-size: 14px;
+ margin-bottom: 15px;
+ box-sizing: border-box;
+}
+
+body .sort-select{
+ padding: 0.25rem;
+ margin-right: 10px;
+}
+
+body .track-list{
+ max-height: 400px;
+ overflow-y: auto;
+ border: 1px solid #2a3441;
+ background: #0a0a0a;
+}
+
+body .track-item{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 15px;
+ border-bottom: 1px solid #2a3441;
+ -moz-transition: background-color 0.2s;
+ -o-transition: background-color 0.2s;
+ -webkit-transition: background-color 0.2s;
+ -ms-transition: background-color 0.2s;
+ transition: background-color 0.2s;
+}
+
+body .track-info{
+ flex: 1;
+}
+
+body .track-info .track-title{
+ color: #00ffff;
+ font-weight: bold;
+ margin-bottom: 4px;
+}
+
+body .track-info .track-meta{
+ color: #888;
+ font-size: 0.9em;
+}
+
+body .track-actions{
+ display: flex;
+ gap: 8px;
+}
+
+body .audio-player{
+ text-align: center;
+}
+
+
+
+body @-moz-documenturl-prefix() audio{
+ background-color: red;
+}
+
+body audio::-webkit-media-controls-panel{
+ border: 1px solid;
+ border-color: #1a2332;
+ background-color: #1a2332;
+}
+
+body audio::-webkit-media-controls-current-time-display,
+body audio::-webkit-media-controls-time-remaining-display{
+ color: #fff;
+}
+
+body audio::-webkit-media-controls-enclosure{
+ border-radius: 0;
+}
+
+body audio::-webkit-media-controls-mute-button,
+body audio::-webkit-media-controls-play-button,
+body audio::-webkit-media-controls-volume-slider,
+body audio::-webkit-media-controls-timeline,
+body audio::-webkit-media-controls-toggle-closed-captions-button,
+body audio::-webkit-media-controls-fullscreen-button,
+body audio::-webkit-media-controls-timeline,
+body audio::-webkit-media-controls-overlay-enclosure{
+ -moz-filter: invert(1);
+ -o-filter: invert(1);
+ -webkit-filter: invert(1);
+ -ms-filter: invert(1);
+ filter: invert(1);
+}
+
+body .track-art{
+ font-size: 3em;
+ margin-right: 20px;
+ color: #4488ff;
+}
+
+
+
+body .track-details .track-title{
+ font-size: 1.4em;
+ color: #00ffff;
+ margin-bottom: 5px;
+}
+
+body .track-details .track-artist{
+ font-size: 1.1em;
+ color: #4488ff;
+ margin-bottom: 3px;
+}
+
+body .track-details .track-album{
+ color: #888;
+}
+
+body .player-controls{
+ margin: 20px 0;
+ display: flex;
+ justify-content: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+body .player-info{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 15px;
+ padding: 10px;
+ background: #0a0a0a;
+ border: 1px solid #2a3441;
+ border-radius: 3px;
+}
+
+body .time-display{
+ color: #00ffff;
+ font-family: Courier New, monospace;
+}
+
+body .volume-control{
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+body .volume-control label{
+ color: #4488ff;
+}
+
+body .volume-slider{
+ width: 100px;
+ height: 5px;
+ background: #2a3441;
+ outline: none;
+ border-radius: 3px;
+}
+
+body .btn{
+ background: #2a3441;
+ color: #00ffff;
+ border: 1px solid #3a4551;
+ padding: 8px 16px;
+ margin: 3px;
+ cursor: pointer;
+ font-family: Courier New, monospace;
+ font-size: 14px;
+ border-radius: 3px;
+ -moz-transition: all 0.2s;
+ -o-transition: all 0.2s;
+ -webkit-transition: all 0.2s;
+ -ms-transition: all 0.2s;
+ transition: all 0.2s;
+}
+
+body .btn:hover{
+ background: #3a4551;
+ border-color: #3a4551;
+}
+
+
+
+body .btn .btn-primary{
+ background: #0066cc;
+ border-color: #0088ff;
+}
+
+body .btn .btn-primary:hover{
+ background: #0088ff;
+}
+
+body .btn .btn-secondary{
+ background: #444;
+ border-color: #2a3441;
+}
+
+body .btn .btn-secondary:hover{
+ background: #666;
+}
+
+body .btn .btn-sm{
+ padding: 4px 8px;
+ font-size: 12px;
+}
+
+body .btn .btn.active{
+ background: #4488ff;
+ border-color: #5599ff;
+ color: #000;
+}
+
+body .playlist-controls{
+ margin-bottom: 15px;
+ display: flex;
+ gap: 10px;
+ align-items: center;
+}
+
+body .playlist-input{
+ flex: 1;
+ padding: 8px 12px;
+ background: #0a0a0a;
+ color: #00ffff;
+ border: 1px solid #2a3441;
+ font-family: Courier New, monospace;
+ box-sizing: border-box;
+}
+
+body .playlist-list{
+ border: 1px solid #2a3441;
+ background: #0a0a0a;
+ min-height: 100px;
+ padding: 10px;
+}
+
+body .queue-controls{
+ margin-bottom: 15px;
+ display: flex;
+ gap: 10px;
+}
+
+body .play-queue{
+ border: 1px solid #2a3441;
+ background: #0a0a0a;
+ min-height: 150px;
+ max-height: 300px;
+ overflow-y: auto;
+ padding: 10px;
+}
+
+body .queue-item{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 10px;
+ border-bottom: 1px solid #2a3441;
+ margin-bottom: 5px;
+}
+
+body .queue-item:last-child{
+ border-bottom: none;
+ margin-bottom: 0;
+}
+
+body .queue-position{
+ background: #00ff00;
+ color: #000;
+ padding: 4px 8px;
+ border-radius: 3px;
+ font-weight: bold;
+ margin-right: 10px;
+ min-width: 30px;
+ text-align: center;
+ display: inline-block;
+}
+
+body .queue-track-info{
+ flex: 1;
+ margin-right: 10px;
+}
+
+body .queue-track-info.track-title{
+ font-weight: bold;
+ margin-bottom: 2px;
+}
+
+body .queue-track-info.track-artist{
+ font-size: 0.9em;
+ color: #888;
+}
+
+body .queue-actions{
+ margin-top: 20px;
+ padding: 15px;
+ background: #0a0a0a;
+ border: 1px solid #2a3441;
+ border-radius: 4px;
+}
+
+body .queue-list{
+ border: 1px solid #2a3441;
+ background: #0a0a0a;
+ min-height: 200px;
+ max-height: 400px;
+ overflow-y: auto;
+ padding: 10px;
+ margin-bottom: 20px;
+}
+
+body .search-results{
+ margin-top: 10px;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+body .search-result-item{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px;
+ border: 1px solid #2a3441;
+ margin-bottom: 5px;
+ background: #0a0a0a;
+ border-radius: 3px;
+}
+
+body .search-result-item:hover{
+ background: #1a1a1a;
+ border-color: #00ff00;
+}
+
+body .search-result-item.track-info{
+ flex: 1;
+}
+
+body .search-result-item.track-actions{
+ display: flex;
+ gap: 5px;
+}
+
+body .empty-state{
+ text-align: center;
+ color: #666;
+ padding: 30px;
+ font-style: italic;
+}
+
+body .empty-queue{
+ text-align: center;
+ color: #666;
+ padding: 20px;
+ font-style: italic;
+}
+
+body .no-tracks{
+ text-align: center;
+ color: #666;
+ padding: 20px;
+ font-style: italic;
+}
+
+body .no-playlists{
+ text-align: center;
+ color: #666;
+ padding: 20px;
+ font-style: italic;
+}
+
+body .loading{
+ text-align: center;
+ color: #4488ff;
+ padding: 20px;
+}
+
+body .error{
+ text-align: center;
+ color: #ff0000;
+ padding: 20px;
+ font-weight: bold;
+}
+
+body .upload-section{
+ margin: 20px 0;
+ padding: 20px;
+ background: #0a0a0a;
+ border: 1px solid #2a3441;
+ border-radius: 5px;
+}
+
+body .upload-controls{
+ display: flex;
+ gap: 15px;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+body .upload-info{
+ color: #888;
+ font-size: 0.9em;
+}
+
+body .upload-progress{
+ margin-top: 10px;
+ padding: 10px;
+ background: #1a2332;
+ border: 1px solid #2a3441;
+ border-radius: 3px;
+}
+
+body .progress-bar{
+ height: 20px;
+ background: #4488ff;
+ border-radius: 3px;
+ -moz-transition: width 0.3s ease;
+ -o-transition: width 0.3s ease;
+ -webkit-transition: width 0.3s ease;
+ -ms-transition: width 0.3s ease;
+ transition: width 0.3s ease;
+ width: 0%;
+}
+
+body .progress-text{
+ display: block;
+ margin-top: 5px;
+ color: #00ffff;
+ font-size: 0.9em;
+}
+
+body input{
+ padding: 8px 12px;
+ background: #1a2332;
+ color: #00ffff;
+ border: 1px solid #2a3441;
+ border-radius: 3px;
+ font-family: Courier New, monospace;
+}
+
+body .upload-interface{
+ margin-top: 2rem;
+ padding: 1.5rem;
+ background-color: #1a2332;
+ border-radius: 8px;
+ border: 1px solid #2a3441;
+}
+
+body .upload-interface h3{
+ color: #00ffff;
+ margin-bottom: 1rem;
+}
+
+body .upload-interface .upload-area{
+ border: 2px dashed #2a3441;
+ border-radius: 8px;
+ padding: 2rem;
+ text-align: center;
+ background-color: #0f0f0f;
+ -moz-transition: border-color 0.3s ease;
+ -o-transition: border-color 0.3s ease;
+ -webkit-transition: border-color 0.3s ease;
+ -ms-transition: border-color 0.3s ease;
+ transition: border-color 0.3s ease;
+}
+
+body .upload-interface .upload-area .upload-icon{
+ font-size: 3rem;
+ color: #666;
+ margin-bottom: 1rem;
+}
+
+body .upload-interface .upload-area p{
+ color: #999;
+ margin-bottom: 1rem;
+}
+
+body .upload-interface .upload-area .btn{
+ margin-top: 1rem;
+}
+
+body .upload-interface .upload-area:hover{
+ border-color: #00ffff;
+}
+
+body .auth-container{
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 60vh;
+ padding: 2rem;
+}
+
+body .auth-form{
+ background-color: #1a2332;
+ border: 1px solid #2a3441;
+ border-radius: 8px;
+ padding: 2rem;
+ width: 100%;
+ max-width: 600px;
+ -moz-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
+ -o-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
+ -webkit-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
+ -ms-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
+}
+
+body .auth-form h2{
+ color: #00ffff;
+ text-align: center;
+ margin-bottom: 1.5rem;
+ font-size: 1.5rem;
+}
+
+body .auth-form h3{
+ color: #00ffff;
+ margin-bottom: 1rem;
+ font-size: 1.2rem;
+}
+
+body .form-group{
+ margin-bottom: 1.5rem;
+}
+
+body .form-group label{
+ display: block;
+ color: #ccc;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+}
+
+body .form-group input{
+ width: 100%;
+ padding: 0.75rem;
+ background-color: #0f0f0f;
+ border: 1px solid #2a3441;
+ border-radius: 4px;
+ color: #fff;
+ font-size: 1rem;
+ box-sizing: border-box;
+}
+
+body .form-group input:focus{
+ border-color: #00ffff;
+ outline: none;
+ -moz-box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2);
+ -o-box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2);
+ -webkit-box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2);
+ -ms-box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2);
+ box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2);
+}
+
+body .form-actions{
+ display: flex;
+ gap: 1rem;
+ margin-top: 1.5rem;
+}
+
+body .message{
+ padding: 0.75rem;
+ border-radius: 4px;
+ margin-top: 1rem;
+ text-align: center;
+ font-weight: bold;
+}
+
+body .message.success{
+ background-color: rgba(0, 255, 0, 0.1);
+ border: 1px solid #00ffff;
+ color: #00ffff;
+}
+
+body .message.error{
+ background-color: rgba(255, 0, 0, 0.1);
+ border: 1px solid #ff0000;
+ color: #ff0000;
+}
+
+body .auth-link{
+ text-align: center;
+ margin-top: 1.5rem;
+ color: #999;
+}
+
+body .auth-link a{
+ color: #00ffff;
+ text-decoration: none;
+}
+
+body .auth-link a:hover{
+ text-decoration: underline;
+}
+
+body .profile-info{
+ margin-bottom: 2rem;
+}
+
+body .info-group{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem 0;
+ border-bottom: 1px solid #2a3441;
+}
+
+body .info-group label{
+ color: #ccc;
+ font-weight: bold;
+}
+
+body .info-group span{
+ color: #fff;
+}
+
+body .info-group:last-child{
+ border-bottom: none;
+}
+
+body .role-badge{
+ background-color: #00ffff;
+ color: #000;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ font-size: 0.875rem;
+ font-weight: bold;
+}
+
+body .profile-actions{
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+}
+
+body .artist-stats{
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+body .artist-item{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid #2a3441;
+}
+
+body .artist-item:last-child{
+ border-bottom: none;
+}
+
+body .artist-name{
+ color: #e0e6ed;
+ font-weight: 500;
+}
+
+body .artist-plays{
+ color: #8892b0;
+ font-size: 0.875rem;
+}
+
+body .track-item{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem 0;
+ border-bottom: 1px solid #2a3441;
+}
+
+body .track-item:last-child{
+ border-bottom: none;
+}
+
+body .track-info{
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+body .track-title{
+ color: #e0e6ed;
+ font-weight: 500;
+}
+
+body .track-artist{
+ color: #8892b0;
+ font-size: 0.875rem;
+}
+
+body .track-meta{
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.25rem;
+ text-align: right;
+}
+
+body .track-duration{
+ color: #64ffda;
+ font-size: 0.875rem;
+ font-weight: bold;
+}
+
+body .track-played-at{
+ color: #8892b0;
+ font-size: 0.75rem;
+}
+
+body .activity-chart{
+ text-align: center;
+}
+
+body .chart-placeholder{
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ height: 120px;
+ margin: 1rem 0;
+ padding: 0 1rem;
+}
+
+body .chart-bar{
+ width: 8px;
+ background-color: #64ffda;
+ border-radius: 2px 2px 0 0;
+ margin: 0 1px;
+ min-height: 4px;
+ opacity: 0.8;
+}
+
+body .chart-bar:hover{
+ opacity: 1;
+}
+
+body .chart-note{
+ color: #8892b0;
+ font-size: 0.875rem;
+ margin-top: 0.5rem;
+}
+
+body .stat-number{
+ color: #64ffda;
+ font-size: 1.5rem;
+ font-weight: bold;
+ display: block;
+}
+
+body .stat-text{
+ color: #e0e6ed;
+ font-size: 1.2rem;
+ font-weight: 500;
+ display: block;
+}
+
+body .toast{
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ padding: 12px 20px;
+ border-radius: 4px;
+ color: white;
+ font-weight: bold;
+ z-index: 1000;
+ -moz-transition: opacity 0.3s ease;
+ -o-transition: opacity 0.3s ease;
+ -webkit-transition: opacity 0.3s ease;
+ -ms-transition: opacity 0.3s ease;
+ transition: opacity 0.3s ease;
+}
+
+body .user-management{
+ margin-top: 2rem;
+}
+
+body .users-table{
+ width: 100%;
+ border-collapse: collapse;
+ background-color: #1a2332;
+ border: 1px solid #2a3441;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+body .users-table thead{
+ background-color: #0f0f0f;
+}
+
+body .users-table thead th{
+ padding: 1rem;
+ text-align: left;
+ color: #00ffff;
+ font-weight: bold;
+ border-bottom: 1px solid #2a3441;
+}
+
+
+
+body .users-table tbody tr{
+ border-bottom: 1px solid #2a3441;
+}
+
+body .users-table tbody tr td{
+ padding: 1rem;
+ color: #fff;
+ vertical-align: middle;
+}
+
+body .users-table tbody tr:hover{
+ background-color: #222;
+}
+
+body .users-table tbody .user-actions{
+ display: flex;
+ gap: 0.5rem;
+}
+
+body .users-table tbody .user-actions .btn{
+ padding: 0.25rem 0.5rem;
+ font-size: 0.875rem;
+}
+
+body .user-stats{
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+body .stat-card{
+ background-color: #1a2332;
+ border: 1px solid #2a3441;
+ border-radius: 8px;
+ padding: 1rem;
+ text-align: center;
+}
+
+body .stat-card .stat-number{
+ font-size: 2rem;
+ font-weight: bold;
+ color: #00ffff;
+ display: block;
+}
+
+body .stat-card .stat-label{
+ color: #ccc;
+ font-size: 0.875rem;
+ margin-top: 0.5rem;
+}
+
+body.persistent-player-container{
+ margin: 0;
+ padding: 10px;;
+ background: #1a2332;
+}
+
+body.persistent-player-container .persistent-player{
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ max-width: 100%;
+}
+
+body.persistent-player-container .player-label{
+ color: #00ff00;
+ font-weight: bold;
+ white-space: nowrap;
+}
+
+body.persistent-player-container .quality-selector{
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+
+
+body.persistent-player-container .quality-selector label{
+ color: #00ff00;
+ font-size: 0.9em;
+}
+
+
+
+body.persistent-player-container .quality-selector select{
+ background: transparent;
+ color: #00ff00;
+ letter-spacing: 0.08rem;
+ border: 1px solid #00ff00;
+ padding: 3px 8px;
+}
+
+body.persistent-player-container .quality-selector select:hover{
+ background: #2a3441;
+}
+
+body.persistent-player-container audio{
+ flex: 1;
+ min-width: 200px;
+ border: 0;
+}
+
+body.persistent-player-container .now-playing-mini{
+ color: #00ff00;
+ font-size: 0.9em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+ min-width: 300px;
+}
+
+body.persistent-player-container .persistent-reconnect-btn{
+ background: transparent;
+ color: #00ff00;
+ border: 1px solid #00ff00;
+ padding: 5px 10px;
+ cursor: pointer;
+ font-family: VT323, monospace;
+ font-size: 0.85em;
+ white-space: nowrap;
+ margin-right: 5px;
+}
+
+body.persistent-player-container .persistent-reconnect-btn:hover{
+ background: #2a3441;
+}
+
+body.persistent-player-container .persistent-disable-btn{
+ background: transparent;
+ color: #00ff00;
+ border: 1px solid #00ff00;
+ padding: 5px 10px;
+ cursor: pointer;
+ font-family: VT323, monospace;
+ font-size: 0.85em;
+ white-space: nowrap;
+}
+
+body.persistent-player-container .persistent-disable-btn:hover{
+ background: #2a3441;
+}
+
+body.popout-body{
+ margin: 0;
+ padding: 10px;
+ background: #0a0a0a;
+ overflow: hidden;
+}
+
+body.popout-body .popout-player{
+ max-width: 400px;
+ margin: 0 auto;
+}
+
+body.popout-body .popout-header{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #2a3441;
+}
+
+body.popout-body .popout-title{
+ font-size: 1.2em;
+ color: #4488FF;
+}
+
+body.popout-body .popout-title .popout-subtitle{
+ margin-top: 0;
+ margin-left: 2rem;
+ font-style: italic;
+ font-size: .8rem;
+}
+
+body.popout-body .close-btn{
+ background: transparent;
+ font-family: VT323, monospace;
+ color: #00ff00;
+ border: 1px solid #00ff00;
+ padding: 5px 10px;
+ cursor: pointer;
+ border-radius: 3px;
+ font-size: 0.9em;
+}
+
+body.popout-body .close-btn:hover{
+ background: #2a3441;
+}
+
+body.popout-body .now-playing-mini{
+ background: #1a2332;
+ padding: 10px;
+ border-radius: 5px;
+ margin-bottom: 10px;
+ border: 1px solid #2a3441;
+}
+
+body.popout-body .track-info-mini{
+ font-size: 0.9em;
+}
+
+body.popout-body .track-title-mini{
+ color: #00ff00;
+ font-weight: bold;
+ margin-bottom: 3px;
+}
+
+body.popout-body .track-artist-mini{
+ color: #4488ff;
+ font-size: 0.85em;
+}
+
+body.popout-body .quality-selector{
+ margin: 10px 0;
+ padding: 10px;
+ background: #1a2332;
+ border-radius: 5px;
+ border: 1px solid #2a3441;
+}
+
+body.popout-body .quality-selector label{
+ color: #00ff00;
+ margin-right: 10px;
+}
+
+body.popout-body .quality-selector select{
+ background: transparent;
+ color: #00ff00;
+ border: 1px solid #00ff00;
+ padding: 5px;
+ border-radius: 3px;
+}
+
+body.popout-body .quality-selector select:hover{
+ background: #2a3441;
+}
+
+body.popout-body audio{
+ width: 100%;
+ margin: 10px 0;
+}
+
+body.popout-body .status-mini{
+ text-align: center;
+ color: #888;
+ font-size: 0.85em;
+ margin-top: 10px;
+}
+
+@media (max-width: 576px){
+ body .playlist-controls{
+ display: block;
+ }
+ body .playlist-controls >*{
+ width: 100%;
+ }
+ body .playlist-controls button{
+ margin-left: 0;
+ margin-right: 0;
+ }
+}
+
+@supports (-moz-appearance: none){
+ audio{
+ opacity: 1;
+ background-color: #1a2332;
+ }
+}
+
+.live-stream-indicator{
+ animation: asteroid-pulse 2s ease-in-out infinite;
+}
+
+@keyframes asteroid-pulse{
+ 0%{
+ opacity: 1;
+ }
+ 50%{
+ opacity: 0.3;
+ }
+ 100%{
+ opacity: 1;
+ }
+}
\ No newline at end of file
diff --git a/static/asteroid.lass b/static/asteroid.lass
index e840f20..189319e 100644
--- a/static/asteroid.lass
+++ b/static/asteroid.lass
@@ -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"
diff --git a/template/about-content.ctml b/template/about-content.ctml
new file mode 100644
index 0000000..94d89b9
--- /dev/null
+++ b/template/about-content.ctml
@@ -0,0 +1,106 @@
+
+
+
+ About - Asteroid Radio
+
+
+
+
+
+
+
+
+
+
+ 🎵 Asteroid Music for Hackers
+
+ 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.
+
+
+ We met through a shared set of technical biases and a love for building systems from first principles.
+ Asteroid Radio embodies that ethos: music for hackers, built by hackers .
+
+
+
+
+ 🛠️ Built with Common Lisp
+
+ This entire platform is built using Common Lisp , demonstrating the power and elegance
+ of Lisp for modern web applications. We use:
+
+
+ Radiance - Web application framework
+ Clip - HTML5-compliant template engine
+ LASS - Lisp Augmented Style Sheets
+ ParenScript - Lisp-to-JavaScript compiler
+ Icecast - Streaming media server
+ Liquidsoap - Audio stream generation
+
+
+ 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.
+
+
+
+
+ đź“– Open Source & AGPL Licensed
+
+ Asteroid Radio is free and open source software , licensed under the
+ GNU Affero General Public License v3.0 (AGPL) .
+
+
+ The source code is available at:
+ https://github.com/Fade/asteroid
+
+
+ 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.
+
+
+
+
+ 🎧 Features
+
+ Live Streaming - Multiple quality options (AAC, MP3)
+ Persistent Player - Frameset mode for uninterrupted playback while browsing
+ Spectrum Analyzer - Real-time audio visualization with customizable themes
+ Track Library - Browse and search the music collection
+ User Profiles - Track your listening history
+ Admin Tools - Manage tracks, users, and playlists
+
+
+
+
+ 🤝 Community
+
+ We're part of the SystemCrafters
+ community—a group of developers, hackers, and enthusiasts who believe in building systems from first principles,
+ understanding our tools deeply, and sharing knowledge freely.
+
+
+ Join us in celebrating the intersection of great music and great code!
+
+
+
+
+
+
diff --git a/template/about.ctml b/template/about.ctml
new file mode 100644
index 0000000..e8fdd86
--- /dev/null
+++ b/template/about.ctml
@@ -0,0 +1,109 @@
+
+
+
+ About - Asteroid Radio
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🎵 Asteroid Music for Hackers
+
+ 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.
+
+
+ We met through a shared set of technical biases and a love for building systems from first principles.
+ Asteroid Radio embodies that ethos: music for hackers, built by hackers .
+
+
+
+
+ 🛠️ Built with Common Lisp
+
+ This entire platform is built using Common Lisp , demonstrating the power and elegance
+ of Lisp for modern web applications. We use:
+
+
+ Radiance - Web application framework
+ Clip - HTML5-compliant template engine
+ LASS - Lisp Augmented Style Sheets
+ ParenScript - Lisp-to-JavaScript compiler
+ Icecast - Streaming media server
+ Liquidsoap - Audio stream generation
+
+
+ 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.
+
+
+
+
+ đź“– Open Source & AGPL Licensed
+
+ Asteroid Radio is free and open source software , licensed under the
+ GNU Affero General Public License v3.0 (AGPL) .
+
+
+ The source code is available at:
+ https://github.com/Fade/asteroid
+
+
+ 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.
+
+
+
+
+ 🎧 Features
+
+ Live Streaming - Multiple quality options (AAC, MP3)
+ Persistent Player - Frameset mode for uninterrupted playback while browsing
+ Spectrum Analyzer - Real-time audio visualization with customizable themes
+ Track Library - Browse and search the music collection
+ User Profiles - Track your listening history
+ Admin Tools - Manage tracks, users, and playlists
+
+
+
+
+ 🤝 Community
+
+ We're part of the SystemCrafters
+ community—a group of developers, hackers, and enthusiasts who believe in building systems from first principles,
+ understanding our tools deeply, and sharing knowledge freely.
+
+
+ Join us in celebrating the intersection of great music and great code!
+
+
+
+
+
+
diff --git a/template/audio-player-frame.ctml b/template/audio-player-frame.ctml
index 88777b1..f941812 100644
--- a/template/audio-player-frame.ctml
+++ b/template/audio-player-frame.ctml
@@ -28,10 +28,17 @@
Loading...
+
+ 🔄
+
+
âś• Disable
+
+
+