Fix geo stats peak tracking and migrate inline JS to ParenScript

- Fix listener_count accumulation bug: use GREATEST() instead of + to track
  peak concurrent listeners per day rather than cumulative count
- Migrate all inline JavaScript from templates to ParenScript:
  - admin.ctml: listener stats, geo stats, password reset -> admin.lisp
  - audio-player-frame.ctml: stream player logic -> stream-player.lisp (new)
  - popout-player.ctml: popout player logic -> stream-player.lisp (shared)
  - frameset-wrapper.ctml: frame-busting -> frameset-utils.lisp (new)
  - profile.ctml: removed redundant inline init (already in profile.lisp)
- Fix API route detection: handle URIs with or without leading slash
- Add routes to serve new ParenScript-generated JS files
- Update ASDF system definition with new ParenScript components

Tested locally for 8+ hours with no issues.
This commit is contained in:
Glenn Thompson 2025-12-14 17:59:35 +03:00 committed by Brian O'Reilly
parent 87b20ef6cc
commit 1467df7d14
13 changed files with 753 additions and 658 deletions

View File

@ -53,6 +53,8 @@
(:file "users")
(:file "admin")
(:file "player")
(:file "stream-player")
(:file "frameset-utils")
(:file "spectrum-analyzer")))
(:file "stream-media")
(:file "user-management")

View File

@ -868,6 +868,26 @@
(format t "ERROR generating recently-played.js: ~a~%" e)
(format nil "// Error generating JavaScript: ~a~%" e))))
;; Serve ParenScript-compiled stream-player.js
((string= path "js/stream-player.js")
(setf (content-type *response*) "application/javascript")
(handler-case
(let ((js (generate-stream-player-js)))
(if js js "// Error: No JavaScript generated"))
(error (e)
(format t "ERROR generating stream-player.js: ~a~%" e)
(format nil "// Error generating JavaScript: ~a~%" e))))
;; Serve ParenScript-compiled frameset-utils.js
((string= path "js/frameset-utils.js")
(setf (content-type *response*) "application/javascript")
(handler-case
(let ((js (generate-frameset-utils-js)))
(if js js "// Error: No JavaScript generated"))
(error (e)
(format t "ERROR generating frameset-utils.js: ~a~%" e)
(format nil "// Error generating JavaScript: ~a~%" e))))
;; Serve regular static file
(t
(serve-file (merge-pathnames (format nil "static/~a" path)

View File

@ -6,7 +6,7 @@
# Allow running as root in Docker
set("init.allow_root", true)
# Set log level for debugging
# Set log level (4 = warning, suppresses info messages including telnet noise)
log.level.set(4)
# Audio buffering settings to prevent choppiness

View File

@ -266,7 +266,9 @@
(log:error "Session cleanup failed: ~a" e))))
(defun update-geo-stats (country-code listener-count &optional city)
"Update geo stats for today, optionally including city"
"Update geo stats for today, optionally including city.
listener_count tracks peak concurrent listeners (max seen today).
listen_minutes increments by 1 per poll (approximates total listen time)."
(when country-code
(handler-case
(with-db
@ -275,7 +277,7 @@
(format nil "INSERT INTO listener_geo_stats (date, country_code, city, listener_count, listen_minutes)
VALUES (CURRENT_DATE, '~a', ~a, ~a, 1)
ON CONFLICT (date, country_code, city)
DO UPDATE SET listener_count = listener_geo_stats.listener_count + ~a,
DO UPDATE SET listener_count = GREATEST(listener_geo_stats.listener_count, ~a),
listen_minutes = listener_geo_stats.listen_minutes + 1"
country-code city-sql listener-count listener-count))))
(error (e)

View File

@ -27,6 +27,7 @@
(load-playlist-list)
(load-current-queue)
(refresh-liquidsoap-status)
(setup-stats-refresh)
;; Update Liquidsoap status every 10 seconds
(set-interval refresh-liquidsoap-status 10000))))
@ -854,6 +855,244 @@
(ps:chain console (error "Error restarting Icecast:" error))
(alert "Error restarting Icecast")))))
;; ========================================
;; Listener Statistics
;; ========================================
;; Refresh listener stats from API
(defun refresh-listener-stats ()
(let ((status-el (ps:chain document (get-element-by-id "stats-status"))))
(when status-el
(setf (ps:@ status-el text-content) "Loading...")))
(ps:chain
(fetch "/api/asteroid/stats/current")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (and (= (ps:@ data status) "success") (ps:@ data listeners))
(progn
;; Process listener data - get most recent for each mount
(let ((mounts (ps:create)))
(ps:chain (ps:@ data listeners)
(for-each (lambda (item)
;; item is [mount, "/asteroid.mp3", listeners, 1, timestamp, 123456]
(let ((mount (ps:getprop item 1))
(listeners (ps:getprop item 3))
(timestamp (ps:getprop item 5)))
(when (or (not (ps:getprop mounts mount))
(> timestamp (ps:@ (ps:getprop mounts mount) timestamp)))
(setf (ps:getprop mounts mount)
(ps:create :listeners listeners :timestamp timestamp)))))))
;; Update UI
(let ((mp3 (or (and (ps:getprop mounts "/asteroid.mp3")
(ps:@ (ps:getprop mounts "/asteroid.mp3") listeners)) 0))
(aac (or (and (ps:getprop mounts "/asteroid.aac")
(ps:@ (ps:getprop mounts "/asteroid.aac") listeners)) 0))
(low (or (and (ps:getprop mounts "/asteroid-low.mp3")
(ps:@ (ps:getprop mounts "/asteroid-low.mp3") listeners)) 0)))
(let ((mp3-el (ps:chain document (get-element-by-id "listeners-mp3")))
(aac-el (ps:chain document (get-element-by-id "listeners-aac")))
(low-el (ps:chain document (get-element-by-id "listeners-low")))
(total-el (ps:chain document (get-element-by-id "listeners-total")))
(updated-el (ps:chain document (get-element-by-id "stats-updated")))
(status-el (ps:chain document (get-element-by-id "stats-status"))))
(when mp3-el (setf (ps:@ mp3-el text-content) mp3))
(when aac-el (setf (ps:@ aac-el text-content) aac))
(when low-el (setf (ps:@ low-el text-content) low))
(when total-el (setf (ps:@ total-el text-content) (+ mp3 aac low)))
(when updated-el
(setf (ps:@ updated-el text-content)
(ps:chain (ps:new (-date)) (to-locale-time-string))))
(when status-el (setf (ps:@ status-el text-content) ""))))))
(let ((status-el (ps:chain document (get-element-by-id "stats-status"))))
(when status-el
(setf (ps:@ status-el text-content) "No data available")))))))
(catch (lambda (error)
(ps:chain console (error "Error fetching stats:" error))
(let ((status-el (ps:chain document (get-element-by-id "stats-status"))))
(when status-el
(setf (ps:@ status-el text-content) "Error loading stats")))))))
;; ========================================
;; Geo Statistics
;; ========================================
;; Track expanded countries
(defvar *expanded-countries* (ps:new (-set)))
;; Convert country code to flag emoji
(defun country-to-flag (country-code)
(if (or (not country-code) (not (= (ps:@ country-code length) 2)))
"🌍"
(let ((code-points (ps:chain (ps:chain country-code (to-upper-case))
(split "")
(map (lambda (char)
(+ 127397 (ps:chain char (char-code-at 0))))))))
(ps:chain -string (from-code-point (ps:@ code-points 0) (ps:@ code-points 1))))))
;; Refresh geo stats from API
(defun refresh-geo-stats ()
(ps:chain
(fetch "/api/asteroid/stats/geo?days=7")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(tbody (ps:chain document (get-element-by-id "geo-stats-body"))))
(when tbody
(if (and (= (ps:@ data status) "success")
(ps:@ data geo)
(> (ps:@ (ps:@ data geo) length) 0))
(progn
(setf (ps:@ tbody inner-h-t-m-l)
(ps:chain (ps:@ data geo)
(map (lambda (item)
(let* ((country (or (ps:@ item country_code) (ps:getprop item 0)))
(listeners (or (ps:@ item total_listeners) (ps:getprop item 1) 0))
(minutes (or (ps:@ item total_minutes) (ps:getprop item 2) 0))
(is-expanded (ps:chain *expanded-countries* (has country)))
(arrow (if is-expanded "▼" "▶")))
(+ "<tr class=\"country-row\" data-country=\"" country "\" style=\"cursor: pointer;\" onclick=\"toggleCountryCities('" country "')\">"
"<td><span class=\"expand-arrow\">" arrow "</span> " (country-to-flag country) " " country "</td>"
"<td>" listeners "</td>"
"<td>" minutes "</td>"
"</tr>"
"<tr class=\"city-rows\" id=\"cities-" country "\" style=\"display: " (if is-expanded "table-row" "none") ";\">"
"<td colspan=\"3\" style=\"padding: 0;\"><div class=\"city-container\" id=\"city-container-" country "\"></div></td>"
"</tr>"))))
(join "")))
;; Re-fetch cities for expanded countries
(ps:chain *expanded-countries*
(for-each (lambda (country)
(fetch-cities country)))))
(setf (ps:@ tbody inner-h-t-m-l)
"<tr><td colspan=\"3\" style=\"color: #888;\">No geo data yet</td></tr>"))))))
(catch (lambda (error)
(ps:chain console (error "Error fetching geo stats:" error))
(let ((tbody (ps:chain document (get-element-by-id "geo-stats-body"))))
(when tbody
(setf (ps:@ tbody inner-h-t-m-l)
"<tr><td colspan=\"3\" style=\"color: #ff6666;\">Error loading geo data</td></tr>")))))))
;; Toggle city display for a country
(defun toggle-country-cities (country)
(let ((city-row (ps:chain document (get-element-by-id (+ "cities-" country))))
(country-row (ps:chain document (query-selector (+ "tr[data-country=\"" country "\"]"))))
(arrow (when country-row (ps:chain country-row (query-selector ".expand-arrow")))))
(if (ps:chain *expanded-countries* (has country))
(progn
(ps:chain *expanded-countries* (delete country))
(when city-row (setf (ps:@ city-row style display) "none"))
(when arrow (setf (ps:@ arrow text-content) "▶")))
(progn
(ps:chain *expanded-countries* (add country))
(when city-row (setf (ps:@ city-row style display) "table-row"))
(when arrow (setf (ps:@ arrow text-content) "▼"))
(fetch-cities country)))))
;; Fetch cities for a country
(defun fetch-cities (country)
(let ((container (ps:chain document (get-element-by-id (+ "city-container-" country)))))
(when container
(setf (ps:@ container inner-h-t-m-l)
"<div style=\"padding: 5px 20px; color: #888;\">Loading cities...</div>")
(ps:chain
(fetch (+ "/api/asteroid/stats/geo/cities?country=" country "&days=7"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (and (= (ps:@ data status) "success")
(ps:@ data cities)
(> (ps:@ (ps:@ data cities) length) 0))
(setf (ps:@ container inner-h-t-m-l)
(+ "<table style=\"width: 100%; margin-left: 20px;\">"
(ps:chain (ps:@ data cities)
(map (lambda (city)
(+ "<tr style=\"background: rgba(0,255,0,0.05);\">"
"<td style=\"padding: 3px 10px;\">└ " (ps:@ city city) "</td>"
"<td style=\"padding: 3px 10px;\">" (ps:@ city listeners) "</td>"
"<td style=\"padding: 3px 10px;\">" (ps:@ city minutes) "</td>"
"</tr>")))
(join ""))
"</table>"))
(setf (ps:@ container inner-h-t-m-l)
"<div style=\"padding: 5px 20px; color: #888;\">No city data</div>")))))
(catch (lambda (error)
(ps:chain console (error "Error fetching cities:" error))
(setf (ps:@ container inner-h-t-m-l)
"<div style=\"padding: 5px 20px; color: #ff6666;\">Error loading cities</div>")))))))
;; ========================================
;; Admin Password Reset
;; ========================================
(defun reset-user-password (event)
(ps:chain event (prevent-default))
(let ((username (ps:@ (ps:chain document (get-element-by-id "reset-username")) value))
(new-password (ps:@ (ps:chain document (get-element-by-id "reset-new-password")) value))
(confirm-password (ps:@ (ps:chain document (get-element-by-id "reset-confirm-password")) value))
(message-div (ps:chain document (get-element-by-id "reset-password-message"))))
;; Client-side validation
(when (< (ps:@ new-password length) 8)
(setf (ps:@ message-div text-content) "New password must be at least 8 characters")
(setf (ps:@ message-div class-name) "message error")
(return nil))
(when (not (= new-password confirm-password))
(setf (ps:@ message-div text-content) "Passwords do not match")
(setf (ps:@ message-div class-name) "message error")
(return nil))
;; Send request to API
(let ((form-data (ps:new (-form-data))))
(ps:chain form-data (append "username" username))
(ps:chain form-data (append "new-password" new-password))
(ps:chain
(fetch "/api/asteroid/admin/reset-password"
(ps:create :method "POST" :body form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (data)
(if (or (= (ps:@ data status) "success")
(and (ps:@ data data) (= (ps:@ (ps:@ data data) status) "success")))
(progn
(setf (ps:@ message-div text-content)
(+ "Password reset successfully for user: " username))
(setf (ps:@ message-div class-name) "message success")
(ps:chain (ps:chain document (get-element-by-id "admin-reset-password-form")) (reset)))
(progn
(setf (ps:@ message-div text-content)
(or (ps:@ data message)
(and (ps:@ data data) (ps:@ (ps:@ data data) message))
"Failed to reset password"))
(setf (ps:@ message-div class-name) "message error")))))
(catch (lambda (error)
(ps:chain console (error "Error resetting password:" error))
(setf (ps:@ message-div text-content) "Error resetting password")
(setf (ps:@ message-div class-name) "message error"))))))
nil)
;; ========================================
;; Auto-refresh and Initialization for Stats
;; ========================================
;; Setup stats auto-refresh (called from DOMContentLoaded)
(defun setup-stats-refresh ()
;; Initial load
(refresh-listener-stats)
(refresh-geo-stats)
;; Auto-refresh intervals
(set-interval refresh-listener-stats 30000)
(set-interval refresh-geo-stats 60000))
;; Make functions globally accessible for onclick handlers
(setf (ps:@ window go-to-page) go-to-page)
(setf (ps:@ window previous-page) previous-page)
@ -866,6 +1105,11 @@
(setf (ps:@ window move-track-down) move-track-down)
(setf (ps:@ window remove-from-queue) remove-from-queue)
(setf (ps:@ window add-to-queue) add-to-queue)
(setf (ps:@ window toggle-country-cities) toggle-country-cities)
(setf (ps:@ window reset-user-password) reset-user-password)
(setf (ps:@ window refresh-listener-stats) refresh-listener-stats)
(setf (ps:@ window refresh-geo-stats) refresh-geo-stats)
(setf (ps:@ window setup-stats-refresh) setup-stats-refresh)
))
"Compiled JavaScript for admin dashboard - generated at load time")

View File

@ -0,0 +1,18 @@
;;;; frameset-utils.lisp - ParenScript for frameset utilities
;;;; Frame-busting and other frameset-related functionality
(in-package #:asteroid)
(defparameter *frameset-utils-js*
(ps:ps*
'(progn
;; Prevent nested framesets - break out if we're already in a frame
;; This runs immediately (not on DOMContentLoaded) to prevent flicker
(when (not (= (ps:@ window self) (ps:@ window top)))
(setf (ps:@ (ps:@ window top) location href)
(ps:@ (ps:@ window self) location href)))))
"Compiled JavaScript for frameset utilities - generated at load time")
(defun generate-frameset-utils-js ()
"Generate JavaScript code for frameset utilities"
*frameset-utils-js*)

View File

@ -0,0 +1,453 @@
;;;; stream-player.lisp - ParenScript for persistent stream player
;;;; Handles audio-player-frame and popout-player stream reconnect logic
(in-package #:asteroid)
(defparameter *stream-player-js*
(ps:ps*
'(progn
;; ========================================
;; Stream Configuration
;; ========================================
;; Get stream configuration for a given quality
(defun get-stream-config (stream-base-url encoding)
(let ((config (ps:create
:aac (ps:create :url (+ stream-base-url "/asteroid.aac")
:type "audio/aac"
:format "AAC 96kbps Stereo"
:mount "asteroid.aac")
:mp3 (ps:create :url (+ stream-base-url "/asteroid.mp3")
:type "audio/mpeg"
:format "MP3 128kbps Stereo"
:mount "asteroid.mp3")
:low (ps:create :url (+ stream-base-url "/asteroid-low.mp3")
:type "audio/mpeg"
:format "MP3 64kbps Stereo"
:mount "asteroid-low.mp3"))))
(ps:getprop config encoding)))
;; ========================================
;; Stream Quality Selection
;; ========================================
;; Change stream quality
(defun change-stream-quality ()
(let* ((selector (or (ps:chain document (get-element-by-id "stream-quality"))
(ps:chain document (get-element-by-id "popout-stream-quality"))))
(stream-base-url (ps:@ (ps:chain document (get-element-by-id "stream-base-url")) value))
(config (get-stream-config stream-base-url (ps:@ selector value)))
(audio-element (or (ps:chain document (get-element-by-id "persistent-audio"))
(ps:chain document (get-element-by-id "live-audio"))))
(source-element (ps:chain document (get-element-by-id "audio-source"))))
;; Save preference
(ps:chain local-storage (set-item "stream-quality" (ps:@ selector value)))
(let ((was-playing (not (ps:@ audio-element paused))))
(setf (ps:@ source-element src) (ps:@ config url))
(setf (ps:@ source-element type) (ps:@ config type))
(ps:chain audio-element (load))
(when was-playing
(ps:chain audio-element (play)
(catch (lambda (e)
(ps:chain console (log "Autoplay prevented:" e)))))))))
;; ========================================
;; Now Playing Updates
;; ========================================
;; Update mini now playing display (for persistent player frame)
(defun update-mini-now-playing ()
(ps:chain
(fetch "/api/asteroid/partial/now-playing-inline")
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (text))
"")))
(then (lambda (text)
(let ((el (ps:chain document (get-element-by-id "mini-now-playing"))))
(when el
(setf (ps:@ el text-content) text)))))
(catch (lambda (error)
(ps:chain console (log "Could not fetch now playing:" error))))))
;; Update popout now playing display (parses artist - title)
(defun update-popout-now-playing ()
(ps:chain
(fetch "/api/asteroid/partial/now-playing-inline")
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (text))
"")))
(then (lambda (html)
(let* ((parser (ps:new (-d-o-m-parser)))
(doc (ps:chain parser (parse-from-string html "text/html")))
(track-text (or (ps:@ doc body text-content)
(ps:@ doc body inner-text)
""))
(parts (ps:chain track-text (split " - "))))
(if (>= (ps:@ parts length) 2)
(progn
(let ((artist-el (ps:chain document (get-element-by-id "popout-track-artist")))
(title-el (ps:chain document (get-element-by-id "popout-track-title"))))
(when artist-el
(setf (ps:@ artist-el text-content) (ps:chain (aref parts 0) (trim))))
(when title-el
(setf (ps:@ title-el text-content)
(ps:chain (ps:chain parts (slice 1) (join " - ")) (trim))))))
(progn
(let ((title-el (ps:chain document (get-element-by-id "popout-track-title")))
(artist-el (ps:chain document (get-element-by-id "popout-track-artist"))))
(when title-el
(setf (ps:@ title-el text-content) (ps:chain track-text (trim))))
(when artist-el
(setf (ps:@ artist-el text-content) "Asteroid Radio"))))))))
(catch (lambda (error)
(ps:chain console (error "Error updating now playing:" error))))))
;; ========================================
;; Status Display
;; ========================================
;; Show status message
(defun show-status (message is-error)
(let ((status (ps:chain document (get-element-by-id "stream-status"))))
(when status
(setf (ps:@ status text-content) message)
(setf (ps:@ status style display) "block")
(setf (ps:@ status style background) (if is-error "#550000" "#005500"))
(setf (ps:@ status style color) (if is-error "#ff6666" "#66ff66"))
(unless is-error
(set-timeout (lambda ()
(setf (ps:@ status style display) "none"))
3000)))))
;; Hide status message
(defun hide-status ()
(let ((status (ps:chain document (get-element-by-id "stream-status"))))
(when status
(setf (ps:@ status style display) "none"))))
;; ========================================
;; Stream Reconnect Logic
;; ========================================
;; Error retry counter and reconnect state
(defvar *stream-error-count* 0)
(defvar *reconnect-timeout* nil)
(defvar *is-reconnecting* false)
;; Reconnect stream - recreates audio element to fix wedged state
(defun reconnect-stream ()
(ps:chain console (log "Reconnecting stream..."))
(show-status "🔄 Reconnecting..." false)
(let* ((container (ps:chain document (query-selector ".persistent-player")))
(old-audio (ps:chain document (get-element-by-id "persistent-audio")))
(stream-base-url (ps:@ (ps:chain document (get-element-by-id "stream-base-url")) value))
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac"))
(config (get-stream-config stream-base-url stream-quality)))
(unless (and container old-audio)
(show-status "❌ Could not reconnect - reload page" true)
(return))
;; Save current volume and muted state
(let ((saved-volume (ps:@ old-audio volume))
(saved-muted (ps:@ old-audio muted)))
(ps:chain console (log "Saving volume:" saved-volume "muted:" saved-muted))
;; Reset spectrum analyzer if it exists
(when (ps:@ window reset-spectrum-analyzer)
(ps:chain window (reset-spectrum-analyzer)))
;; Stop and remove old audio
(ps:chain old-audio (pause))
(setf (ps:@ old-audio src) "")
(ps:chain old-audio (load))
;; Create new audio element
(let ((new-audio (ps:chain document (create-element "audio"))))
(setf (ps:@ new-audio id) "persistent-audio")
(setf (ps:@ new-audio controls) true)
(setf (ps:@ new-audio preload) "metadata")
(setf (ps:@ new-audio cross-origin) "anonymous")
;; Restore volume and muted state
(setf (ps:@ new-audio volume) saved-volume)
(setf (ps:@ new-audio muted) saved-muted)
;; Create source
(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)))
;; Replace old audio with new
(ps:chain old-audio (replace-with new-audio))
;; Re-attach event listeners
(attach-audio-listeners new-audio)
;; Try to play
(set-timeout
(lambda ()
(ps:chain new-audio (play)
(then (lambda ()
(ps:chain console (log "Reconnected successfully"))
(show-status "✓ Reconnected!" false)
;; Reinitialize spectrum analyzer
(when (ps:@ window init-spectrum-analyzer)
(set-timeout (lambda ()
(ps:chain window (init-spectrum-analyzer)))
500))
;; Also try in content frame
(set-timeout
(lambda ()
(ps:try
(let ((content-frame (ps:@ (ps:@ window parent) frames "content-frame")))
(when (and content-frame (ps:@ content-frame init-spectrum-analyzer))
(when (ps:@ content-frame reset-spectrum-analyzer)
(ps:chain content-frame (reset-spectrum-analyzer)))
(ps:chain content-frame (init-spectrum-analyzer))
(ps:chain console (log "Spectrum analyzer reinitialized in content frame"))))
(:catch (e)
(ps:chain console (log "Could not reinit spectrum in content frame:" e)))))
600)))
(catch (lambda (err)
(ps:chain console (log "Reconnect play failed:" err))
(show-status "Click play to start stream" false)))))
300)))))
;; Simple reconnect for popout player (just reload and play)
(defun simple-reconnect (audio-element)
(ps:chain audio-element (load))
(ps:chain audio-element (play)
(catch (lambda (err)
(ps:chain console (log "Reconnect failed:" err))))))
;; Attach event listeners to audio element
(defun attach-audio-listeners (audio-element)
(ps:chain audio-element
(add-event-listener "waiting"
(lambda ()
(ps:chain console (log "Audio buffering...")))))
(ps:chain audio-element
(add-event-listener "playing"
(lambda ()
(ps:chain console (log "Audio playing"))
(hide-status)
(setf *stream-error-count* 0)
(setf *is-reconnecting* false)
(when *reconnect-timeout*
(clear-timeout *reconnect-timeout*)
(setf *reconnect-timeout* nil)))))
(ps:chain audio-element
(add-event-listener "error"
(lambda (e)
(ps:chain console (error "Audio error:" e))
(unless *is-reconnecting*
(setf *stream-error-count* (+ *stream-error-count* 1))
;; Calculate delay with exponential backoff (3s, 6s, 12s, max 30s)
(let ((delay (ps:chain -math (min (* 3000 (ps:chain -math (pow 2 (- *stream-error-count* 1)))) 30000))))
(show-status (+ "⚠️ Stream error. Reconnecting in " (/ delay 1000) "s... (attempt " *stream-error-count* ")") true)
(setf *is-reconnecting* true)
(setf *reconnect-timeout*
(set-timeout (lambda () (reconnect-stream)) delay)))))))
(ps:chain audio-element
(add-event-listener "stalled"
(lambda ()
(unless *is-reconnecting*
(ps:chain console (log "Audio stalled, will auto-reconnect in 5 seconds..."))
(show-status "⚠️ Stream stalled - reconnecting..." true)
(setf *is-reconnecting* true)
(set-timeout
(lambda ()
(if (< (ps:@ audio-element ready-state) 3)
(reconnect-stream)
(setf *is-reconnecting* false)))
5000)))))
;; Handle ended event - stream shouldn't end, so reconnect
(ps:chain audio-element
(add-event-listener "ended"
(lambda ()
(unless *is-reconnecting*
(ps:chain console (log "Stream ended unexpectedly, reconnecting..."))
(show-status "⚠️ Stream ended - reconnecting..." true)
(setf *is-reconnecting* true)
(set-timeout (lambda () (reconnect-stream)) 2000)))))
;; Handle pause event - detect browser throttling muted streams
(ps:chain audio-element
(add-event-listener "pause"
(lambda ()
;; If paused while muted and we didn't initiate it, browser may have throttled
(when (and (ps:@ audio-element muted) (not *is-reconnecting*))
(ps:chain console (log "Stream paused while muted (possible browser throttling), will reconnect in 3 seconds..."))
(show-status "⚠️ Stream paused - reconnecting..." true)
(setf *is-reconnecting* true)
(set-timeout (lambda () (reconnect-stream)) 3000))))))
;; Attach simple listeners for popout player
(defun attach-popout-listeners (audio-element)
(defvar *popout-error-count* 0)
(defvar *popout-reconnect-timeout* nil)
(defvar *popout-is-reconnecting* false)
(ps:chain audio-element
(add-event-listener "playing"
(lambda ()
(ps:chain console (log "Audio playing"))
(setf *popout-error-count* 0)
(setf *popout-is-reconnecting* false)
(when *popout-reconnect-timeout*
(clear-timeout *popout-reconnect-timeout*)
(setf *popout-reconnect-timeout* nil)))))
(ps:chain audio-element
(add-event-listener "error"
(lambda (e)
(ps:chain console (error "Audio error:" e))
(unless *popout-is-reconnecting*
(setf *popout-error-count* (+ *popout-error-count* 1))
(let ((delay (ps:chain -math (min (* 3000 (ps:chain -math (pow 2 (- *popout-error-count* 1)))) 30000))))
(ps:chain console (log (+ "Stream error. Reconnecting in " (/ delay 1000) "s... (attempt " *popout-error-count* ")")))
(setf *popout-is-reconnecting* true)
(setf *popout-reconnect-timeout*
(set-timeout (lambda () (simple-reconnect audio-element)) delay)))))))
(ps:chain audio-element
(add-event-listener "stalled"
(lambda ()
(unless *popout-is-reconnecting*
(ps:chain console (log "Stream stalled, will auto-reconnect in 5 seconds..."))
(setf *popout-is-reconnecting* true)
(set-timeout
(lambda ()
(if (< (ps:@ audio-element ready-state) 3)
(simple-reconnect audio-element)
(setf *popout-is-reconnecting* false)))
5000)))))
(ps:chain audio-element
(add-event-listener "ended"
(lambda ()
(unless *popout-is-reconnecting*
(ps:chain console (log "Stream ended unexpectedly, reconnecting..."))
(setf *popout-is-reconnecting* true)
(set-timeout (lambda () (simple-reconnect audio-element)) 2000)))))
(ps:chain audio-element
(add-event-listener "pause"
(lambda ()
(when (and (ps:@ audio-element muted) (not *popout-is-reconnecting*))
(ps:chain console (log "Stream paused while muted (possible browser throttling), reconnecting..."))
(setf *popout-is-reconnecting* true)
(set-timeout (lambda () (simple-reconnect audio-element)) 3000))))))
;; ========================================
;; Frameset Mode
;; ========================================
;; Disable frameset mode function
(defun disable-frameset-mode ()
;; Clear preference
(ps:chain local-storage (remove-item "useFrameset"))
;; Redirect parent window to regular view
(setf (ps:@ (ps:@ window parent) location href) "/asteroid/"))
;; ========================================
;; Popout Window Communication
;; ========================================
;; Notify parent window that popout is open
(defun notify-popout-opened ()
(when (and (ps:@ window opener) (not (ps:@ (ps:@ window opener) closed)))
(ps:chain (ps:@ window opener) (post-message (ps:create :type "popout-opened") "*"))))
;; Notify parent when closing
(defun notify-popout-closing ()
(when (and (ps:@ window opener) (not (ps:@ (ps:@ window opener) closed)))
(ps:chain (ps:@ window opener) (post-message (ps:create :type "popout-closed") "*"))))
;; ========================================
;; Initialization
;; ========================================
;; Initialize persistent player (audio-player-frame)
(defun init-persistent-player ()
(let ((audio-element (ps:chain document (get-element-by-id "persistent-audio"))))
(when audio-element
;; Try to enable low-latency mode if supported
(when (ps:@ navigator media-session)
(setf (ps:@ navigator media-session metadata)
(ps:new (-media-metadata
(ps:create :title "Asteroid Radio Live Stream"
:artist "Asteroid Radio"
:album "Live Broadcast")))))
;; Attach event listeners
(attach-audio-listeners audio-element)
;; Restore user quality preference
(let ((selector (ps:chain document (get-element-by-id "stream-quality")))
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac")))
(when (and selector (not (= (ps:@ selector value) stream-quality)))
(setf (ps:@ selector value) stream-quality)
(ps:chain selector (dispatch-event (ps:new (-event "change"))))))
;; Start now playing updates
(set-timeout update-mini-now-playing 1000)
(set-interval update-mini-now-playing 10000))))
;; Initialize popout player
(defun init-popout-player ()
(let ((audio-element (ps:chain document (get-element-by-id "live-audio"))))
(when audio-element
;; Attach event listeners
(attach-popout-listeners audio-element)
;; Start now playing updates
(update-popout-now-playing)
(set-interval update-popout-now-playing 10000)
;; Notify parent window
(notify-popout-opened)
;; Setup close notification
(ps:chain window (add-event-listener "beforeunload" notify-popout-closing)))))
;; Make functions globally accessible
(setf (ps:@ window get-stream-config) get-stream-config)
(setf (ps:@ window change-stream-quality) change-stream-quality)
(setf (ps:@ window reconnect-stream) reconnect-stream)
(setf (ps:@ window disable-frameset-mode) disable-frameset-mode)
(setf (ps:@ window init-persistent-player) init-persistent-player)
(setf (ps:@ window init-popout-player) init-popout-player)
(setf (ps:@ window update-mini-now-playing) update-mini-now-playing)
(setf (ps:@ window update-popout-now-playing) update-popout-now-playing)
;; Auto-initialize on DOMContentLoaded based on which elements exist
(ps:chain document
(add-event-listener
"DOMContentLoaded"
(lambda ()
;; Check for persistent player (audio-player-frame)
(when (ps:chain document (get-element-by-id "persistent-audio"))
(init-persistent-player))
;; Check for popout player
(when (ps:chain document (get-element-by-id "live-audio"))
(init-popout-player)))))))
"Compiled JavaScript for stream player - generated at load time")
(defun generate-stream-player-js ()
"Generate JavaScript code for the stream player"
*stream-player-js*)

View File

@ -279,209 +279,6 @@
</div>
</div>
<script>
// Listener Statistics
function refreshListenerStats() {
const statusEl = document.getElementById('stats-status');
statusEl.textContent = 'Loading...';
fetch('/api/asteroid/stats/current')
.then(response => response.json())
.then(result => {
const data = result.data || result;
if (data.status === 'success' && data.listeners) {
// Process listener data - get most recent for each mount
const mounts = {};
data.listeners.forEach(item => {
// item is [mount, "/asteroid.mp3", listeners, 1, timestamp, 123456]
const mount = item[1];
const listeners = item[3];
if (!mounts[mount] || item[5] > mounts[mount].timestamp) {
mounts[mount] = { listeners: listeners, timestamp: item[5] };
}
});
// Update UI
const mp3 = mounts['/asteroid.mp3']?.listeners || 0;
const aac = mounts['/asteroid.aac']?.listeners || 0;
const low = mounts['/asteroid-low.mp3']?.listeners || 0;
document.getElementById('listeners-mp3').textContent = mp3;
document.getElementById('listeners-aac').textContent = aac;
document.getElementById('listeners-low').textContent = low;
document.getElementById('listeners-total').textContent = mp3 + aac + low;
const now = new Date();
document.getElementById('stats-updated').textContent =
now.toLocaleTimeString();
statusEl.textContent = '';
} else {
statusEl.textContent = 'No data available';
}
})
.catch(error => {
console.error('Error fetching stats:', error);
statusEl.textContent = 'Error loading stats';
});
}
// Country code to flag emoji
function countryToFlag(countryCode) {
if (!countryCode || countryCode.length !== 2) return '🌍';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
// Track expanded countries
const expandedCountries = new Set();
// Fetch and display geo stats
function refreshGeoStats() {
fetch('/api/asteroid/stats/geo?days=7')
.then(response => response.json())
.then(result => {
const data = result.data || result;
const tbody = document.getElementById('geo-stats-body');
if (data.status === 'success' && data.geo && data.geo.length > 0) {
tbody.innerHTML = data.geo.map(item => {
const country = item.country_code || item[0];
const listeners = item.total_listeners || item[1] || 0;
const minutes = item.total_minutes || item[2] || 0;
const isExpanded = expandedCountries.has(country);
const arrow = isExpanded ? '▼' : '▶';
return `<tr class="country-row" data-country="${country}" style="cursor: pointer;" onclick="toggleCountryCities('${country}')">
<td><span class="expand-arrow">${arrow}</span> ${countryToFlag(country)} ${country}</td>
<td>${listeners}</td>
<td>${minutes}</td>
</tr>
<tr class="city-rows" id="cities-${country}" style="display: ${isExpanded ? 'table-row' : 'none'};">
<td colspan="3" style="padding: 0;"><div class="city-container" id="city-container-${country}"></div></td>
</tr>`;
}).join('');
// Re-fetch cities for expanded countries
expandedCountries.forEach(country => fetchCities(country));
} else {
tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>';
}
})
.catch(error => {
console.error('Error fetching geo stats:', error);
document.getElementById('geo-stats-body').innerHTML =
'<tr><td colspan="3" style="color: #ff6666;">Error loading geo data</td></tr>';
});
}
// Toggle city display for a country
function toggleCountryCities(country) {
const cityRow = document.getElementById(`cities-${country}`);
const countryRow = document.querySelector(`tr[data-country="${country}"]`);
const arrow = countryRow.querySelector('.expand-arrow');
if (expandedCountries.has(country)) {
expandedCountries.delete(country);
cityRow.style.display = 'none';
arrow.textContent = '▶';
} else {
expandedCountries.add(country);
cityRow.style.display = 'table-row';
arrow.textContent = '▼';
fetchCities(country);
}
}
// Fetch cities for a country
function fetchCities(country) {
const container = document.getElementById(`city-container-${country}`);
container.innerHTML = '<div style="padding: 5px 20px; color: #888;">Loading cities...</div>';
fetch(`/api/asteroid/stats/geo/cities?country=${country}&days=7`)
.then(response => response.json())
.then(result => {
const data = result.data || result;
if (data.status === 'success' && data.cities && data.cities.length > 0) {
container.innerHTML = '<table style="width: 100%; margin-left: 20px;">' +
data.cities.map(city => `
<tr style="background: rgba(0,255,0,0.05);">
<td style="padding: 3px 10px;">└ ${city.city}</td>
<td style="padding: 3px 10px;">${city.listeners}</td>
<td style="padding: 3px 10px;">${city.minutes}</td>
</tr>
`).join('') + '</table>';
} else {
container.innerHTML = '<div style="padding: 5px 20px; color: #888;">No city data</div>';
}
})
.catch(error => {
console.error('Error fetching cities:', error);
container.innerHTML = '<div style="padding: 5px 20px; color: #ff6666;">Error loading cities</div>';
});
}
// Auto-refresh stats every 30 seconds
setInterval(refreshListenerStats, 30000);
setInterval(refreshGeoStats, 60000);
// Initial load
document.addEventListener('DOMContentLoaded', function() {
refreshListenerStats();
refreshGeoStats();
});
// Admin password reset handler
function resetUserPassword(event) {
event.preventDefault();
const username = document.getElementById('reset-username').value;
const newPassword = document.getElementById('reset-new-password').value;
const confirmPassword = document.getElementById('reset-confirm-password').value;
const messageDiv = document.getElementById('reset-password-message');
// Client-side validation
if (newPassword.length < 8) {
messageDiv.textContent = 'New password must be at least 8 characters';
messageDiv.className = 'message error';
return false;
}
if (newPassword !== confirmPassword) {
messageDiv.textContent = 'Passwords do not match';
messageDiv.className = 'message error';
return false;
}
// Send request to API
const formData = new FormData();
formData.append('username', username);
formData.append('new-password', newPassword);
fetch('/api/asteroid/admin/reset-password', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.status === 'success' || (data.data && data.data.status === 'success')) {
messageDiv.textContent = 'Password reset successfully for user: ' + username;
messageDiv.className = 'message success';
document.getElementById('admin-reset-password-form').reset();
} else {
messageDiv.textContent = data.message || data.data?.message || 'Failed to reset password';
messageDiv.className = 'message error';
}
})
.catch(error => {
console.error('Error resetting password:', error);
messageDiv.textContent = 'Error resetting password';
messageDiv.className = 'message error';
});
return false;
}
</script>
<!-- Listener stats, geo stats, and password reset now handled by ParenScript in admin.lisp -->
</body>
</html>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/stream-player.js"></script>
</head>
<body class="persistent-player-container">
<div class="persistent-player">
@ -40,288 +41,6 @@
<!-- Status indicator for connection issues -->
<div id="stream-status" style="display: none; background: #550000; color: #ff6666; padding: 4px 10px; text-align: center; font-size: 0.85em;"></div>
<script>
// Configure audio element for better streaming
document.addEventListener('DOMContentLoaded', function() {
const audioElement = document.getElementById('persistent-audio');
// Try to enable low-latency mode if supported
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: 'Asteroid Radio Live Stream',
artist: 'Asteroid Radio',
album: 'Live Broadcast'
});
}
// Note: Main event listeners are attached via attachAudioListeners()
// which is called at the bottom of this script
const selector = document.getElementById('stream-quality');
const streamQuality = localStorage.getItem('stream-quality') || 'aac';
if (selector && selector.value !== streamQuality) {
selector.value = streamQuality;
selector.dispatchEvent(new Event('change'));
}
});
// Stream quality configuration
function getStreamConfig(streamBaseUrl, encoding) {
const config = {
aac: {
url: streamBaseUrl + '/asteroid.aac',
type: 'audio/aac'
},
mp3: {
url: streamBaseUrl + '/asteroid.mp3',
type: 'audio/mpeg'
},
low: {
url: streamBaseUrl + '/asteroid-low.mp3',
type: 'audio/mpeg'
}
};
return config[encoding];
}
// Change stream quality
function changeStreamQuality() {
const selector = document.getElementById('stream-quality');
const streamBaseUrl = document.getElementById('stream-base-url').value;
const config = getStreamConfig(streamBaseUrl, selector.value);
// Save preference
localStorage.setItem('stream-quality', selector.value);
const audioElement = document.getElementById('persistent-audio');
const sourceElement = document.getElementById('audio-source');
const wasPlaying = !audioElement.paused;
sourceElement.src = config.url;
sourceElement.type = config.type;
audioElement.load();
if (wasPlaying) {
audioElement.play().catch(e => console.log('Autoplay prevented:', e));
}
}
// Update mini now playing display
async function updateMiniNowPlaying() {
try {
const response = await fetch('/api/asteroid/partial/now-playing-inline');
if (response.ok) {
const text = await response.text();
document.getElementById('mini-now-playing').textContent = text;
}
} catch(error) {
console.log('Could not fetch now playing:', error);
}
}
// Update every 10 seconds
setTimeout(updateMiniNowPlaying, 1000);
setInterval(updateMiniNowPlaying, 10000);
// Disable frameset mode function
function disableFramesetMode() {
// Clear preference
localStorage.removeItem('useFrameset');
// Redirect parent window to regular view
window.parent.location.href = '/asteroid/';
}
// Show status message
function showStatus(message, isError) {
const status = document.getElementById('stream-status');
if (status) {
status.textContent = message;
status.style.display = 'block';
status.style.background = isError ? '#550000' : '#005500';
status.style.color = isError ? '#ff6666' : '#66ff66';
if (!isError) {
setTimeout(() => { status.style.display = 'none'; }, 3000);
}
}
}
function hideStatus() {
const status = document.getElementById('stream-status');
if (status) {
status.style.display = 'none';
}
}
// Reconnect stream - recreates audio element to fix wedged state
function reconnectStream() {
console.log('Reconnecting stream...');
showStatus('🔄 Reconnecting...', false);
const container = document.querySelector('.persistent-player');
const oldAudio = document.getElementById('persistent-audio');
const streamBaseUrl = document.getElementById('stream-base-url').value;
const streamQuality = localStorage.getItem('stream-quality') || 'aac';
const config = getStreamConfig(streamBaseUrl, streamQuality);
if (!container || !oldAudio) {
showStatus('❌ Could not reconnect - reload page', true);
return;
}
// Save current volume and muted state
const savedVolume = oldAudio.volume;
const savedMuted = oldAudio.muted;
console.log('Saving volume:', savedVolume, 'muted:', savedMuted);
// Reset spectrum analyzer if it exists
if (window.resetSpectrumAnalyzer) {
window.resetSpectrumAnalyzer();
}
// Stop and remove old audio
oldAudio.pause();
oldAudio.src = '';
oldAudio.load();
// Create new audio element
const newAudio = document.createElement('audio');
newAudio.id = 'persistent-audio';
newAudio.controls = true;
newAudio.preload = 'metadata';
newAudio.crossOrigin = 'anonymous';
// Restore volume and muted state
newAudio.volume = savedVolume;
newAudio.muted = savedMuted;
// Create source
const source = document.createElement('source');
source.id = 'audio-source';
source.src = config.url;
source.type = config.type;
newAudio.appendChild(source);
// Replace old audio with new
oldAudio.replaceWith(newAudio);
// Re-attach event listeners
attachAudioListeners(newAudio);
// Try to play
setTimeout(() => {
newAudio.play()
.then(() => {
console.log('Reconnected successfully');
showStatus('✓ Reconnected!', false);
// Reinitialize spectrum analyzer - try in this frame first
if (window.initSpectrumAnalyzer) {
setTimeout(() => window.initSpectrumAnalyzer(), 500);
}
// Also try in content frame (where spectrum canvas usually is)
try {
const contentFrame = window.parent.frames['content-frame'];
if (contentFrame && contentFrame.initSpectrumAnalyzer) {
setTimeout(() => {
if (contentFrame.resetSpectrumAnalyzer) {
contentFrame.resetSpectrumAnalyzer();
}
contentFrame.initSpectrumAnalyzer();
console.log('Spectrum analyzer reinitialized in content frame');
}, 600);
}
} catch(e) {
console.log('Could not reinit spectrum in content frame:', e);
}
})
.catch(err => {
console.log('Reconnect play failed:', err);
showStatus('Click play to start stream', false);
});
}, 300);
}
// Error retry counter and reconnect state
var streamErrorCount = 0;
var reconnectTimeout = null;
var isReconnecting = false;
// Attach event listeners to audio element
function attachAudioListeners(audioElement) {
audioElement.addEventListener('waiting', function() {
console.log('Audio buffering...');
});
audioElement.addEventListener('playing', function() {
console.log('Audio playing');
hideStatus();
streamErrorCount = 0; // Reset error count on successful play
isReconnecting = false; // Reset reconnecting flag
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
});
audioElement.addEventListener('error', function(e) {
console.error('Audio error:', e);
if (isReconnecting) return; // Already reconnecting, skip
streamErrorCount++;
// Calculate delay with exponential backoff (3s, 6s, 12s, max 30s)
var delay = Math.min(3000 * Math.pow(2, streamErrorCount - 1), 30000);
showStatus('⚠️ Stream error. Reconnecting in ' + (delay/1000) + 's... (attempt ' + streamErrorCount + ')', true);
isReconnecting = true;
reconnectTimeout = setTimeout(function() {
reconnectStream();
}, delay);
});
audioElement.addEventListener('stalled', function() {
if (isReconnecting) return; // Already reconnecting, skip
console.log('Audio stalled, will auto-reconnect in 5 seconds...');
showStatus('⚠️ Stream stalled - reconnecting...', true);
isReconnecting = true;
setTimeout(function() {
if (audioElement.readyState < 3) {
reconnectStream();
} else {
isReconnecting = false;
}
}, 5000);
});
// Handle ended event - stream shouldn't end, so reconnect
audioElement.addEventListener('ended', function() {
if (isReconnecting) return; // Already reconnecting, skip
console.log('Stream ended unexpectedly, reconnecting...');
showStatus('⚠️ Stream ended - reconnecting...', true);
isReconnecting = true;
setTimeout(function() {
reconnectStream();
}, 2000);
});
// Handle pause event - detect browser throttling muted streams
audioElement.addEventListener('pause', function() {
// If paused while muted and we didn't initiate it, browser may have throttled
if (audioElement.muted && !isReconnecting) {
console.log('Stream paused while muted (possible browser throttling), will reconnect in 3 seconds...');
showStatus('⚠️ Stream paused - reconnecting...', true);
isReconnecting = true;
setTimeout(function() {
reconnectStream();
}, 3000);
}
});
}
// Attach listeners to initial audio element
document.addEventListener('DOMContentLoaded', function() {
const audioElement = document.getElementById('persistent-audio');
if (audioElement) {
attachAudioListeners(audioElement);
}
});
</script>
<!-- Initialization handled by stream-player.js -->
</body>
</html>

View File

@ -4,12 +4,7 @@
<title lquery="(text title)">ASTEROID RADIO</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script>
// Prevent nested framesets - break out if we're already in a frame
if (window.self !== window.top) {
window.top.location.href = window.self.location.href;
}
</script>
<script src="/asteroid/static/js/frameset-utils.js"></script>
</head>
<frameset rows="*,80" frameborder="0" border="0" framespacing="0">
<frame src="/asteroid/content" name="content-frame" noresize>

View File

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/front-page.js"></script>
<script src="/asteroid/static/js/stream-player.js"></script>
</head>
<body class="popout-body">
<div class="popout-container">
@ -47,160 +48,6 @@
</div>
</div>
<script>
// Stream quality configuration for popout
function getStreamConfig(streamBaseUrl, encoding) {
const config = {
aac: {
url: `${streamBaseUrl}/asteroid.aac`,
format: 'AAC 96kbps Stereo',
type: 'audio/aac',
mount: 'asteroid.aac'
},
mp3: {
url: `${streamBaseUrl}/asteroid.mp3`,
format: 'MP3 128kbps Stereo',
type: 'audio/mpeg',
mount: 'asteroid.mp3'
},
low: {
url: `${streamBaseUrl}/asteroid-low.mp3`,
format: 'MP3 64kbps Stereo',
type: 'audio/mpeg',
mount: 'asteroid-low.mp3'
}
};
return config[encoding];
}
// Change stream quality in popout
function changeStreamQuality() {
const selector = document.getElementById('popout-stream-quality');
const streamBaseUrl = document.getElementById('stream-base-url');
const config = getStreamConfig(streamBaseUrl.value, selector.value);
// Update audio player
const audioElement = document.getElementById('live-audio');
const sourceElement = document.getElementById('audio-source');
const wasPlaying = !audioElement.paused;
sourceElement.src = config.url;
sourceElement.type = config.type;
audioElement.load();
// Resume playback if it was playing
if (wasPlaying) {
audioElement.play().catch(e => console.log('Autoplay prevented:', e));
}
}
// Update now playing info for popout
async function updatePopoutNowPlaying() {
try {
const response = await fetch('/api/asteroid/partial/now-playing-inline');
const html = await response.text();
// Parse the HTML to extract track info
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const trackText = doc.body.textContent || doc.body.innerText || '';
// Try to split artist - title format
const parts = trackText.split(' - ');
if (parts.length >= 2) {
document.getElementById('popout-track-artist').textContent = parts[0].trim();
document.getElementById('popout-track-title').textContent = parts.slice(1).join(' - ').trim();
} else {
document.getElementById('popout-track-title').textContent = trackText.trim();
document.getElementById('popout-track-artist').textContent = 'Asteroid Radio';
}
} catch (error) {
console.error('Error updating now playing:', error);
}
}
// Update every 10 seconds
setInterval(updatePopoutNowPlaying, 10000);
// Initial update
updatePopoutNowPlaying();
// Auto-reconnect on stream errors
const audioElement = document.getElementById('live-audio');
var streamErrorCount = 0;
var reconnectTimeout = null;
var isReconnecting = false;
audioElement.addEventListener('playing', function() {
console.log('Audio playing');
streamErrorCount = 0;
isReconnecting = false;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
});
audioElement.addEventListener('error', function(e) {
console.error('Audio error:', e);
if (isReconnecting) return;
streamErrorCount++;
var delay = Math.min(3000 * Math.pow(2, streamErrorCount - 1), 30000);
console.log('Stream error. Reconnecting in ' + (delay/1000) + 's... (attempt ' + streamErrorCount + ')');
isReconnecting = true;
reconnectTimeout = setTimeout(function() {
audioElement.load();
audioElement.play().catch(err => console.log('Reconnect failed:', err));
}, delay);
});
audioElement.addEventListener('stalled', function() {
if (isReconnecting) return;
console.log('Stream stalled, will auto-reconnect in 5 seconds...');
isReconnecting = true;
setTimeout(function() {
if (audioElement.readyState < 3) {
audioElement.load();
audioElement.play().catch(err => console.log('Reload failed:', err));
} else {
isReconnecting = false;
}
}, 5000);
});
audioElement.addEventListener('ended', function() {
if (isReconnecting) return;
console.log('Stream ended unexpectedly, reconnecting...');
isReconnecting = true;
setTimeout(function() {
audioElement.load();
audioElement.play().catch(err => console.log('Reconnect failed:', err));
}, 2000);
});
// Handle pause event - detect browser throttling muted streams
audioElement.addEventListener('pause', function() {
if (audioElement.muted && !isReconnecting) {
console.log('Stream paused while muted (possible browser throttling), reconnecting...');
isReconnecting = true;
setTimeout(function() {
audioElement.load();
audioElement.play().catch(err => console.log('Reconnect failed:', err));
}, 3000);
}
});
// Notify parent window that popout is open
if (window.opener && !window.opener.closed) {
window.opener.postMessage({ type: 'popout-opened' }, '*');
}
// Notify parent when closing
window.addEventListener('beforeunload', function() {
if (window.opener && !window.opener.closed) {
window.opener.postMessage({ type: 'popout-closed' }, '*');
}
});
</script>
<!-- Initialization handled by stream-player.js -->
</body>
</html>

View File

@ -183,11 +183,6 @@
</div>
</div>
<script>
// Initialize profile page
document.addEventListener('DOMContentLoaded', function() {
loadProfileData();
});
</script>
<!-- Initialization handled by profile.js -->
</body>
</html>

View File

@ -186,7 +186,10 @@
(let* ((current-user (get-current-user))
(uri (radiance:path (radiance:uri *request*)))
;; Use explicit flag if provided, otherwise auto-detect from URI
(is-api-request (if api t (search "/api/" uri))))
;; Check both "/api/" and "api/" since path may or may not have leading slash
(is-api-request (if api t (or (search "/api/" uri)
(and (>= (length uri) 4)
(string= "api/" (subseq uri 0 4)))))))
(format t "Current user for role check: ~a~%" (if current-user "FOUND" "NOT FOUND"))
(format t "Request URI: ~a, Is API: ~a~%" uri (if is-api-request "YES" "NO"))
(when current-user