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:
parent
87b20ef6cc
commit
1467df7d14
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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*)
|
||||
|
|
@ -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*)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -183,11 +183,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Initialize profile page
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProfileData();
|
||||
});
|
||||
</script>
|
||||
<!-- Initialization handled by profile.js -->
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue