From 4f1a60328b559de776b4c372702ce23158bba43b Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Sun, 14 Dec 2025 21:04:06 +0300 Subject: [PATCH] Add shuffle stream mount with separate recently-played tracking - Add shuffle source to Liquidsoap config (96kbps MP3) - Add shuffle option to all UI quality selectors (frame, popout, front page) - Make now-playing APIs mount-aware for correct metadata display - Implement separate recently-played lists for curated vs shuffle streams - Speed up now-playing and recently-played refresh on stream change - Fix clean shutdown of stats polling thread (positional timeout arg) - Widen quality selector dropdown for shuffle label --- asteroid.lisp | 47 +++++++---- docker/asteroid-radio-docker.liq | 51 ++++++++++++ frontend-partials.lisp | 54 +++++++++---- listener-stats.lisp | 2 +- parenscript/front-page.lisp | 75 ++++++++++++++---- parenscript/recently-played.lisp | 129 ++++++++++++++++++++----------- parenscript/stream-player.lisp | 102 +++++++++++++++--------- static/asteroid.css | 1 + static/asteroid.lass | 3 +- template/audio-player-frame.ctml | 1 + template/front-page.ctml | 1 + template/popout-player.ctml | 1 + 12 files changed, 337 insertions(+), 130 deletions(-) diff --git a/asteroid.lisp b/asteroid.lisp index 0deaa97..f123874 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -24,12 +24,16 @@ (defparameter *supported-formats* '("mp3" "flac" "ogg" "wav")) (defparameter *stream-base-url* "http://localhost:8000") -;; Recently played tracks storage (in-memory) -(defparameter *recently-played* nil - "List of recently played tracks (max 3), newest first") +;; Recently played tracks storage (in-memory) - separate lists per stream type +(defparameter *recently-played-curated* nil + "List of recently played tracks on curated stream (max 3), newest first") +(defparameter *recently-played-shuffle* nil + "List of recently played tracks on shuffle stream (max 3), newest first") (defparameter *recently-played-lock* (bt:make-lock "recently-played-lock")) -(defparameter *last-known-track* nil - "Last known track title to detect changes") +(defparameter *last-known-track-curated* nil + "Last known track title on curated stream to detect changes") +(defparameter *last-known-track-shuffle* nil + "Last known track title on shuffle stream to detect changes") ;; Configure JSON as the default API format (define-api-format json (data) @@ -41,12 +45,16 @@ (setf *default-api-format* "json") ;; Recently played tracks management -(defun add-recently-played (track-info) - "Add a track to the recently played list (max 3 tracks)" +(defun add-recently-played (track-info &optional (stream-type :curated)) + "Add a track to the recently played list (max 3 tracks). + STREAM-TYPE is :curated or :shuffle" (bt:with-lock-held (*recently-played-lock*) - (push track-info *recently-played*) - (when (> (length *recently-played*) 3) - (setf *recently-played* (subseq *recently-played* 0 3))))) + (let ((target-list (if (eq stream-type :shuffle) + '*recently-played-shuffle* + '*recently-played-curated*))) + (push track-info (symbol-value target-list)) + (when (> (length (symbol-value target-list)) 3) + (setf (symbol-value target-list) (subseq (symbol-value target-list) 0 3)))))) (defun universal-time-to-unix (universal-time) "Convert Common Lisp universal time to Unix timestamp" @@ -54,10 +62,13 @@ ;; Difference is 2208988800 seconds (70 years) (- universal-time 2208988800)) -(defun get-recently-played () - "Get the list of recently played tracks" +(defun get-recently-played (&optional (stream-type :curated)) + "Get the list of recently played tracks. + STREAM-TYPE is :curated or :shuffle" (bt:with-lock-held (*recently-played-lock*) - (copy-list *recently-played*))) + (copy-list (if (eq stream-type :shuffle) + *recently-played-shuffle* + *recently-played-curated*)))) (defun parse-track-title (title) "Parse track title into artist and song name. Expected format: 'Artist - Song'" @@ -196,10 +207,14 @@ ("message" . "Could not remove track"))))))) ;; Recently played tracks API endpoint -(define-api asteroid/recently-played () () - "Get the last 3 played tracks with AllMusic links" +(define-api asteroid/recently-played (&optional mount) () + "Get the last 3 played tracks with AllMusic links. + Optional MOUNT parameter specifies which stream's history to return." (with-error-handling - (let ((tracks (get-recently-played))) + (let* ((stream-type (if (and mount (search "shuffle" mount)) + :shuffle + :curated)) + (tracks (get-recently-played stream-type))) (api-output `(("status" . "success") ("tracks" . ,(mapcar (lambda (track) (let* ((title (getf track :title)) diff --git a/docker/asteroid-radio-docker.liq b/docker/asteroid-radio-docker.liq index 556f570..71cc8c6 100644 --- a/docker/asteroid-radio-docker.liq +++ b/docker/asteroid-radio-docker.liq @@ -25,6 +25,10 @@ settings.server.telnet.set(true) settings.server.telnet.port.set(1234) settings.server.telnet.bind_addr.set("0.0.0.0") +# ============================================================================= +# CURATED STREAM (Low Orbit) - Sequential playlist +# ============================================================================= + # Create playlist source from generated M3U file # This file is managed by Asteroid's stream control system # Falls back to directory scan if playlist file doesn't exist @@ -114,9 +118,56 @@ output.icecast( radio ) +# ============================================================================= +# SHUFFLE STREAM (Deep Space) - Random from full library +# ============================================================================= + +# Create shuffle source from full music library +shuffle_radio = playlist( + mode="randomize", # Random mode: shuffle tracks + reload=3600, # Reload playlist hourly + "/app/music/" +) + +# Apply crossfade for smooth transitions +shuffle_radio = crossfade( + duration=3.0, + fade_in=2.0, + fade_out=2.0, + shuffle_radio +) + +# Add buffer to handle high sample rate files +shuffle_radio = buffer(buffer=5.0, max=10.0, shuffle_radio) + +# Make source safe with emergency fallback +shuffle_radio = fallback(track_sensitive=false, [shuffle_radio, emergency]) + +# Add metadata +shuffle_radio = map_metadata(fun(m) -> + [("title", m["title"] ?? "Unknown Track"), + ("artist", m["artist"] ?? "Unknown Artist"), + ("album", m["album"] ?? "Unknown Album")], shuffle_radio) + +# Shuffle Stream - Medium Quality MP3 (96kbps) +output.icecast( + %mp3(bitrate=96), + host="icecast", + port=8000, + password="H1tn31EhsyLrfRmo", + mount="asteroid-shuffle.mp3", + name="Asteroid Radio (Shuffle)", + description="Music for Hackers - Random shuffle from the library", + genre="Electronic/Alternative", + url="http://localhost:8080/asteroid/", + public=true, + shuffle_radio +) + print("🎵 Asteroid Radio Docker streaming started!") print("High Quality MP3: http://localhost:8000/asteroid.mp3") print("High Quality AAC: http://localhost:8000/asteroid.aac") print("Low Quality MP3: http://localhost:8000/asteroid-low.mp3") +print("Shuffle Stream: http://localhost:8000/asteroid-shuffle.mp3") print("Icecast Admin: http://localhost:8000/admin/") print("Telnet control: telnet localhost 1234") diff --git a/frontend-partials.lisp b/frontend-partials.lisp index ddf0821..157ef61 100644 --- a/frontend-partials.lisp +++ b/frontend-partials.lisp @@ -1,9 +1,10 @@ (in-package :asteroid) -(defun icecast-now-playing (icecast-base-url) +(defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3")) "Fetch now-playing information from Icecast server. ICECAST-BASE-URL - Base URL of the Icecast server (e.g. http://localhost:8000) + MOUNT - Mount point to fetch metadata from (default: asteroid.mp3) Returns a plist with :listenurl, :title, and :listeners, or NIL on error." (let* ((icecast-url (format nil "~a/admin/stats.xml" icecast-base-url)) @@ -15,14 +16,16 @@ response (babel:octets-to-string response :encoding :utf-8)))) ;; Extract total listener count from root tag (sums all mount points) - ;; Extract title from asteroid.mp3 mount point + ;; Extract title from specified mount point (let* ((total-listeners (multiple-value-bind (match groups) (cl-ppcre:scan-to-strings "(\\d+)" xml-string) (if (and match groups) (parse-integer (aref groups 0) :junk-allowed t) 0))) - ;; Get title from asteroid.mp3 mount point - (mount-start (cl-ppcre:scan "" xml-string)) + ;; Escape dots in mount name for regex + (mount-pattern (format nil "" + (cl-ppcre:regex-replace-all "\\." mount "\\\\."))) + (mount-start (cl-ppcre:scan mount-pattern xml-string)) (title (if mount-start (let* ((source-section (subseq xml-string mount-start (or (cl-ppcre:scan "" xml-string :start mount-start) @@ -35,21 +38,36 @@ "Unknown"))) ;; Track recently played if title changed - (when (and title - (not (string= title "Unknown")) - (not (equal title *last-known-track*))) - (setf *last-known-track* title) - (add-recently-played (list :title title - :timestamp (get-universal-time)))) + ;; Use appropriate last-known-track and list based on stream type + (let* ((is-shuffle (string= mount "asteroid-shuffle.mp3")) + (last-known (if is-shuffle *last-known-track-shuffle* *last-known-track-curated*)) + (stream-type (if is-shuffle :shuffle :curated))) + (when (and title + (not (string= title "Unknown")) + (not (equal title last-known))) + (if is-shuffle + (setf *last-known-track-shuffle* title) + (setf *last-known-track-curated* title)) + (add-recently-played (list :title title + :timestamp (get-universal-time)) + stream-type))) - `((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*)) + `((:listenurl . ,(format nil "~a/~a" *stream-base-url* mount)) (:title . ,title) (:listeners . ,total-listeners))))))) -(define-api asteroid/partial/now-playing () () - "Get Partial HTML with live status from Icecast server" +(define-api asteroid/partial/now-playing (&optional mount) () + "Get Partial HTML with live status from Icecast server. + Optional MOUNT parameter specifies which stream to get metadata from. + Always polls both streams to keep recently played lists updated." (with-error-handling - (let ((now-playing-stats (icecast-now-playing *stream-base-url*))) + (let* ((mount-name (or mount "asteroid.mp3")) + ;; Always poll both streams to keep recently played lists updated + (dummy-curated (when (not (string= mount-name "asteroid.mp3")) + (icecast-now-playing *stream-base-url* "asteroid.mp3"))) + (dummy-shuffle (when (not (string= mount-name "asteroid-shuffle.mp3")) + (icecast-now-playing *stream-base-url* "asteroid-shuffle.mp3"))) + (now-playing-stats (icecast-now-playing *stream-base-url* mount-name))) (if now-playing-stats (progn ;; TODO: it should be able to define a custom api-output for this @@ -65,10 +83,12 @@ :connection-error t :stats nil)))))) -(define-api asteroid/partial/now-playing-inline () () - "Get inline text with now playing info (for admin dashboard and widgets)" +(define-api asteroid/partial/now-playing-inline (&optional mount) () + "Get inline text with now playing info (for admin dashboard and widgets). + Optional MOUNT parameter specifies which stream to get metadata from." (with-error-handling - (let ((now-playing-stats (icecast-now-playing *stream-base-url*))) + (let* ((mount-name (or mount "asteroid.mp3")) + (now-playing-stats (icecast-now-playing *stream-base-url* mount-name))) (if now-playing-stats (progn (setf (header "Content-Type") "text/plain") diff --git a/listener-stats.lisp b/listener-stats.lisp index b355185..d6d9ec6 100644 --- a/listener-stats.lisp +++ b/listener-stats.lisp @@ -509,7 +509,7 @@ "Stop the background statistics polling thread" (setf *stats-polling-active* nil) (when (and *stats-polling-thread* (bt:thread-alive-p *stats-polling-thread*)) - (bt:join-thread *stats-polling-thread* :timeout 5)) + (bt:join-thread *stats-polling-thread* 5)) (setf *stats-polling-thread* nil) (log:info "Stats polling thread stopped")) diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index a262789..b76057e 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -30,7 +30,12 @@ :url (+ stream-base-url "/asteroid-low.mp3") :format "MP3 64kbps Stereo" :type "audio/mpeg" - :mount "asteroid-low.mp3")))) + :mount "asteroid-low.mp3") + :shuffle (ps:create + :url (+ stream-base-url "/asteroid-shuffle.mp3") + :format "Shuffle MP3 96kbps" + :type "audio/mpeg" + :mount "asteroid-shuffle.mp3")))) (ps:getprop config encoding))) ;; Change stream quality @@ -60,20 +65,47 @@ (catch (lambda (e) (ps:chain console (log "Autoplay prevented:" e)))))))) + ;; Get current mount from stream quality selection + ;; Checks local selector first, then sibling player-frame (for frameset mode) + (defun get-current-mount () + (let* ((selector (ps:chain document (get-element-by-id "stream-quality"))) + ;; If no local selector, try to get from sibling player-frame (frameset mode) + (player-frame-selector + (when (and (not selector) + (not (= (ps:@ window parent) window))) + (ps:try + (let ((player-frame (ps:@ (ps:@ window parent) frames "player-frame"))) + (when player-frame + (ps:chain player-frame document (get-element-by-id "stream-quality")))) + (:catch (e) nil)))) + (effective-selector (or selector player-frame-selector)) + (quality (if effective-selector (ps:@ effective-selector value) "aac")) + (stream-base-url (or (ps:chain document (get-element-by-id "stream-base-url")) + (when (not (= (ps:@ window parent) window)) + (ps:try + (let ((player-frame (ps:@ (ps:@ window parent) frames "player-frame"))) + (when player-frame + (ps:chain player-frame document (get-element-by-id "stream-base-url")))) + (:catch (e) nil))))) + (config (when stream-base-url + (get-stream-config (ps:@ stream-base-url value) quality)))) + (if config (ps:@ config mount) "asteroid.mp3"))) + ;; Update now playing info from API (defun update-now-playing () - (ps:chain - (fetch "/api/asteroid/partial/now-playing") - (then (lambda (response) - (let ((content-type (ps:chain response headers (get "content-type")))) - (if (ps:chain content-type (includes "text/html")) - (ps:chain response (text)) - (throw (ps:new (-error "Error connecting to stream"))))))) - (then (lambda (data) - (setf (ps:@ (ps:chain document (get-element-by-id "now-playing")) inner-h-t-m-l) - data))) - (catch (lambda (error) - (ps:chain console (log "Could not fetch stream status:" error)))))) + (let ((mount (get-current-mount))) + (ps:chain + (fetch (+ "/api/asteroid/partial/now-playing?mount=" mount)) + (then (lambda (response) + (let ((content-type (ps:chain response headers (get "content-type")))) + (if (ps:chain content-type (includes "text/html")) + (ps:chain response (text)) + (throw (ps:new (-error "Error connecting to stream"))))))) + (then (lambda (data) + (setf (ps:@ (ps:chain document (get-element-by-id "now-playing")) inner-h-t-m-l) + data))) + (catch (lambda (error) + (ps:chain console (log "Could not fetch stream status:" error))))))) ;; Update stream information (defun update-stream-information () @@ -437,6 +469,23 @@ ;; Update now playing (update-now-playing) + ;; Refresh now playing immediately when user switches streams + (let ((selector (ps:chain document (get-element-by-id "stream-quality")))) + (when (not selector) + (setf selector + (ps:try + (let ((player-frame (ps:@ (ps:@ window parent) frames "player-frame"))) + (when player-frame + (ps:chain player-frame document (get-element-by-id "stream-quality")))) + (:catch (e) nil)))) + (when selector + (ps:chain selector + (add-event-listener + "change" + (lambda (_ev) + ;; Small delay so localStorage / UI state settles + (ps:chain window (set-timeout update-now-playing 50))))))) + ;; Attach event listeners to audio element (let ((audio-element (ps:chain document (get-element-by-id "live-audio")))) (when audio-element diff --git a/parenscript/recently-played.lisp b/parenscript/recently-played.lisp index 31119d9..16b84df 100644 --- a/parenscript/recently-played.lisp +++ b/parenscript/recently-played.lisp @@ -7,52 +7,76 @@ (ps:ps (progn + ;; Get current mount from stream quality selection + ;; Checks local selector first, then sibling player-frame (for frameset mode) + (defun get-current-mount-for-recently-played () + (let* ((selector (ps:chain document (get-element-by-id "stream-quality"))) + ;; If no local selector, try to get from sibling player-frame (frameset mode) + (player-frame-selector + (when (and (not selector) + (not (= (ps:@ window parent) window))) + (ps:try + (let ((player-frame (ps:@ (ps:@ window parent) frames "player-frame"))) + (when player-frame + (ps:chain player-frame document (get-element-by-id "stream-quality")))) + (:catch (e) nil)))) + (effective-selector (or selector player-frame-selector)) + (quality (or (when effective-selector (ps:@ effective-selector value)) + (ps:chain local-storage (get-item "stream-quality")) + "aac"))) + (cond + ((= quality "shuffle") "asteroid-shuffle.mp3") + ((= quality "low") "asteroid-low.mp3") + ((= quality "mp3") "asteroid.mp3") + (t "asteroid.aac")))) + ;; Update recently played tracks display (defun update-recently-played () - (ps:chain - (fetch "/api/asteroid/recently-played") - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - ;; Radiance wraps API responses in a data envelope - (let ((data (or (ps:@ result data) result))) - (if (and (equal (ps:@ data status) "success") - (ps:@ data tracks) - (> (ps:@ data tracks length) 0)) - (let ((list-el (ps:chain document (get-element-by-id "recently-played-list")))) - (when list-el - ;; Build HTML for tracks - (let ((html "")) - (setf (aref list-el "innerHTML") html)))) - (let ((list-el (ps:chain document (get-element-by-id "recently-played-list")))) - (when list-el - (setf (aref list-el "innerHTML") "

No tracks played yet

"))))))) - (catch (lambda (error) - (ps:chain console (error "Error fetching recently played:" error)) - (let ((list-el (ps:chain document (get-element-by-id "recently-played-list")))) - (when list-el - (setf (aref list-el "innerHTML") "

Error loading recently played tracks

")))))))) + (let ((mount (get-current-mount-for-recently-played))) + (ps:chain + (fetch (+ "/api/asteroid/recently-played?mount=" mount)) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + ;; Radiance wraps API responses in a data envelope + (let ((data (or (ps:@ result data) result))) + (if (and (equal (ps:@ data status) "success") + (ps:@ data tracks) + (> (ps:@ data tracks length) 0)) + (let ((list-el (ps:chain document (get-element-by-id "recently-played-list")))) + (when list-el + ;; Build HTML for tracks + (let ((html "")) + (setf (aref list-el "innerHTML") html)))) + (let ((list-el (ps:chain document (get-element-by-id "recently-played-list")))) + (when list-el + (setf (aref list-el "innerHTML") "

No tracks played yet

"))))))) + (catch (lambda (error) + (ps:chain console (error "Error fetching recently played:" error)) + (let ((list-el (ps:chain document (get-element-by-id "recently-played-list")))) + (when list-el + (setf (aref list-el "innerHTML") "

Error loading recently played tracks

")))))))) ;; Format timestamp as relative time (defun format-time-ago (timestamp) @@ -81,12 +105,29 @@ (if panel (progn (update-recently-played) + ;; Refresh immediately when user switches streams + (let ((selector (ps:chain document (get-element-by-id "stream-quality")))) + (when (not selector) + (setf selector + (ps:try + (let ((player-frame (ps:@ (ps:@ window parent) frames "player-frame"))) + (when player-frame + (ps:chain player-frame document (get-element-by-id "stream-quality")))) + (:catch (e) nil)))) + (when selector + (ps:chain selector + (add-event-listener + "change" + (lambda (_ev) + ;; Small delay so localStorage / UI state settles + (ps:chain window (set-timeout update-recently-played 50))))))) ;; Update every 30 seconds (set-interval update-recently-played 30000)) (let ((list (ps:chain document (get-element-by-id "recently-played-list")))) (when list (update-recently-played) (set-interval update-recently-played 30000)))))))))) + ) "Compiled JavaScript for recently played tracks - generated at load time" ) diff --git a/parenscript/stream-player.lisp b/parenscript/stream-player.lisp index ffe883b..7711288 100644 --- a/parenscript/stream-player.lisp +++ b/parenscript/stream-player.lisp @@ -25,7 +25,11 @@ :low (ps:create :url (+ stream-base-url "/asteroid-low.mp3") :type "audio/mpeg" :format "MP3 64kbps Stereo" - :mount "asteroid-low.mp3")))) + :mount "asteroid-low.mp3") + :shuffle (ps:create :url (+ stream-base-url "/asteroid-shuffle.mp3") + :type "audio/mpeg" + :format "Shuffle MP3 96kbps" + :mount "asteroid-shuffle.mp3")))) (ps:getprop config encoding))) ;; ======================================== @@ -37,52 +41,73 @@ (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))) + (selected-quality (ps:@ selector value)) + (config (get-stream-config stream-base-url selected-quality)) (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")))) + (source-element (ps:chain document (get-element-by-id "audio-source"))) + (was-playing (and audio-element (not (ps:@ audio-element paused))))) ;; Save preference - (ps:chain local-storage (set-item "stream-quality" (ps:@ selector value))) + (ps:chain local-storage (set-item "stream-quality" selected-quality)) - (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))))))))) + ;; Swap source and reload + (setf (ps:@ source-element src) (ps:@ config url)) + (setf (ps:@ source-element type) (ps:@ config type)) + (ps:chain audio-element (load)) + + ;; Resume playback if it was playing + (when was-playing + (ps:chain audio-element (play) + (catch (lambda (e) + (ps:chain console (log "Autoplay prevented:" e)))))) + + ;; Refresh now-playing immediately when user switches streams + (when (ps:chain document (get-element-by-id "mini-now-playing")) + (ps:chain window (set-timeout update-mini-now-playing 50))) + (when (or (ps:chain document (get-element-by-id "popout-track-title")) + (ps:chain document (get-element-by-id "popout-track-artist"))) + (ps:chain window (set-timeout update-popout-now-playing 50))))) ;; ======================================== ;; Now Playing Updates ;; ======================================== + ;; Get current mount from stream quality selection + (defun get-current-mount () + (let* ((selector (or (ps:chain document (get-element-by-id "stream-quality")) + (ps:chain document (get-element-by-id "popout-stream-quality")))) + (quality (if selector (ps:@ selector value) "aac")) + (stream-base-url (ps:@ (ps:chain document (get-element-by-id "stream-base-url")) value)) + (config (get-stream-config stream-base-url quality))) + (if config (ps:@ config mount) "asteroid.mp3"))) + ;; 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)))))) + (let ((mount (get-current-mount))) + (ps:chain + (fetch (+ "/api/asteroid/partial/now-playing-inline?mount=" mount)) + (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 ((mount (get-current-mount))) + (ps:chain + (fetch (+ "/api/asteroid/partial/now-playing-inline?mount=" mount)) + (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) @@ -105,8 +130,8 @@ (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)))))) + (catch (lambda (error) + (ps:chain console (error "Error updating now playing:" error))))))) ;; ======================================== ;; Status Display @@ -153,7 +178,7 @@ (unless (and container old-audio) (show-status "❌ Could not reconnect - reload page" true) - (return)) + (return-from reconnect-stream nil)) ;; Save current volume and muted state (let ((saved-volume (ps:@ old-audio volume)) @@ -406,7 +431,7 @@ ;; Start now playing updates (set-timeout update-mini-now-playing 1000) - (set-interval update-mini-now-playing 10000)))) + (set-interval update-mini-now-playing 5000)))) ;; Initialize popout player (defun init-popout-player () @@ -417,7 +442,7 @@ ;; Start now playing updates (update-popout-now-playing) - (set-interval update-popout-now-playing 10000) + (set-interval update-popout-now-playing 5000) ;; Notify parent window (notify-popout-opened) @@ -445,7 +470,8 @@ (init-persistent-player)) ;; Check for popout player (when (ps:chain document (get-element-by-id "live-audio")) - (init-popout-player))))))) + (init-popout-player)))))) + ) "Compiled JavaScript for stream player - generated at load time") (defun generate-stream-player-js () diff --git a/static/asteroid.css b/static/asteroid.css index ad467f4..9555580 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1310,6 +1310,7 @@ body.persistent-player-container .quality-selector select{ letter-spacing: 0.08rem; border: 1px solid #00ff00; padding: 3px 8px; + min-width: 140px; } body.persistent-player-container .quality-selector select:hover{ diff --git a/static/asteroid.lass b/static/asteroid.lass index 50b0b97..8b5a6ff 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -1055,7 +1055,8 @@ :color "#00ff00" :letter-spacing "0.08rem" :border "1px solid #00ff00" - :padding "3px 8px") + :padding "3px 8px" + :min-width "140px") ((:and select :hover) :background "#2a3441")) diff --git a/template/audio-player-frame.ctml b/template/audio-player-frame.ctml index 4fce603..9759454 100644 --- a/template/audio-player-frame.ctml +++ b/template/audio-player-frame.ctml @@ -20,6 +20,7 @@ + diff --git a/template/front-page.ctml b/template/front-page.ctml index 3953475..d3190ef 100644 --- a/template/front-page.ctml +++ b/template/front-page.ctml @@ -76,6 +76,7 @@ + diff --git a/template/popout-player.ctml b/template/popout-player.ctml index 5cbf75d..cf7675f 100644 --- a/template/popout-player.ctml +++ b/template/popout-player.ctml @@ -33,6 +33,7 @@ +