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
This commit is contained in:
Glenn Thompson 2025-12-14 21:04:06 +03:00
parent 4166f1c898
commit 0acb01f6ef
12 changed files with 337 additions and 130 deletions

View File

@ -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))

View File

@ -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")

View File

@ -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 <listeners> 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 "<listeners>(\\d+)</listeners>" 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 "<source mount=\"/asteroid\\.mp3\">" xml-string))
;; Escape dots in mount name for regex
(mount-pattern (format nil "<source mount=\"/~a\">"
(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 "</source>" 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")

View File

@ -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"))

View File

@ -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

View File

@ -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 "<ul class=\"track-list\">"))
(ps:chain (ps:@ data tracks)
(for-each (lambda (track index)
(let ((time-ago (format-time-ago (ps:@ track timestamp))))
(setf html
(+ html
"<li class=\"track-item\">"
"<div class=\"track-info\">"
"<div class=\"track-title\">"
"<a href=\"" (ps:@ track search_url) "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"track-link\">"
(escape-html (ps:@ track song))
"<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" class=\"external-icon\">"
"<path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"></path>"
"<polyline points=\"15 3 21 3 21 9\"></polyline>"
"<line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\"></line>"
"</svg>"
"</a>"
"</div>"
"<div class=\"track-artist\">" (escape-html (ps:@ track artist)) "</div>"
"<span class=\"track-time\">" time-ago "</span>"
"</div>"
"</li>"))))))
(setf html (+ html "</ul>"))
(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") "<p class=\"no-tracks\">No tracks played yet</p>")))))))
(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") "<p class=\"error\">Error loading recently played tracks</p>"))))))))
(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 "<ul class=\"track-list\">"))
(ps:chain (ps:@ data tracks)
(for-each (lambda (track index)
(let ((time-ago (format-time-ago (ps:@ track timestamp))))
(setf html
(+ html
"<li class=\"track-item\">"
"<div class=\"track-info\">"
"<div class=\"track-title\">"
"<a href=\"" (ps:@ track search_url) "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"track-link\">"
(escape-html (ps:@ track song))
"<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" class=\"external-icon\">"
"<path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"></path>"
"<polyline points=\"15 3 21 3 21 9\"></polyline>"
"<line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\"></line>"
"</svg>"
"</a>"
"</div>"
"<div class=\"track-artist\">" (escape-html (ps:@ track artist)) "</div>"
"<span class=\"track-time\">" time-ago "</span>"
"</div>"
"</li>"))))))
(setf html (+ html "</ul>"))
(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") "<p class=\"no-tracks\">No tracks played yet</p>")))))))
(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") "<p class=\"error\">Error loading recently played tracks</p>"))))))))
;; 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"
)

View File

@ -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 ()

View File

@ -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{

View File

@ -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"))

View File

@ -20,6 +20,7 @@
<option value="aac">AAC 96k</option>
<option value="mp3">MP3 128k</option>
<option value="low">MP3 64k</option>
<option value="shuffle">🎲 Shuffle 96k</option>
</select>
</div>

View File

@ -76,6 +76,7 @@
<option value="aac">AAC 96kbps (Recommended)</option>
<option value="mp3">MP3 128kbps (Compatible)</option>
<option value="low">MP3 64kbps (Low Bandwidth)</option>
<option value="shuffle">🎲 Shuffle 96kbps</option>
</select>
</div>

View File

@ -33,6 +33,7 @@
<option value="aac">AAC 96kbps</option>
<option value="mp3">MP3 128kbps</option>
<option value="low">MP3 64kbps</option>
<option value="shuffle">🎲 Shuffle 96kbps</option>
</select>
</div>