From 8c19e0fbdedb36245083ee783d22127acc6d1405 Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Sun, 7 Dec 2025 20:42:16 +0300 Subject: [PATCH] Fix wedged player with reconnect button and volume preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- asteroid.lisp | 14 + parenscript/front-page.lisp | 327 +++++-- parenscript/spectrum-analyzer.lisp | 7 + static/asteroid.css | 1462 ++++++++++++++++++++++++++++ static/asteroid.lass | 14 + template/about-content.ctml | 106 ++ template/about.ctml | 109 +++ template/audio-player-frame.ctml | 146 +++ template/front-page-content.ctml | 15 +- template/front-page.ctml | 17 +- template/player-content.ctml | 1 + template/player.ctml | 1 + 12 files changed, 2134 insertions(+), 85 deletions(-) create mode 100644 static/asteroid.css create mode 100644 template/about-content.ctml create mode 100644 template/about.ctml 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 + ABOUT ASTEROID RADIO + Asteroid +

+ +
+
+
+

🎵 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 + ABOUT ASTEROID RADIO + Asteroid +

+ +
+
+
+

🎵 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... + + + + + diff --git a/template/front-page-content.ctml b/template/front-page-content.ctml index 34b63f1..2ca6441 100644 --- a/template/front-page-content.ctml +++ b/template/front-page-content.ctml @@ -51,13 +51,14 @@ diff --git a/template/front-page.ctml b/template/front-page.ctml index e5bf958..3953475 100644 --- a/template/front-page.ctml +++ b/template/front-page.ctml @@ -53,6 +53,7 @@