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:
parent
4166f1c898
commit
0acb01f6ef
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ()
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue