Compare commits

...

4 Commits

Author SHA1 Message Date
Glenn Thompson cb76c02e63 Remove debug logging from channel name update code 2025-12-14 19:08:41 -05:00
Glenn Thompson 987d01beaa Dynamic channel name updates and playlist crossfade transition
- Fix channel name not updating in frame player when playlist changes
- Use localStorage polling (2s interval) for cross-frame communication
- Fix API response access: use bracket notation for 'channel-name' property
- Add skip command after playlist load to trigger crossfade to new playlist
- Add #PHASE metadata to Asteroid-Low-Orbit.m3u playlist
2025-12-14 19:08:41 -05:00
Glenn Thompson 18c251c8c4 Fix toggleCountryCities bug - use let* for sequential binding
The arrow variable was referencing country-row before it was defined
because let binds all variables simultaneously. Changed to let* for
sequential binding so country-row is available when binding arrow.
2025-12-14 19:08:41 -05:00
Glenn Thompson 93140f8f24 Add channel/quality selector separation with dynamic playlist phase names
- Separate Channel selector (Curated/Shuffle) from Quality selector (bitrate)
- Add channel selector to frame player, front page, and popout player
- Dynamic curated channel name from playlist #PHASE: metadata
- Channel selection syncs across all player contexts via localStorage
- Quality selector disabled when Shuffle channel selected (fixed bitrate)
- Fix reconnectStream to use channel-aware config
- Consistent CSS styling for selector heights
2025-12-14 19:08:41 -05:00
12 changed files with 605 additions and 158 deletions

View File

@ -78,6 +78,54 @@
:song (string-trim " " (subseq title (+ pos 3))))
(list :artist "Unknown" :song title))))
(defun get-playlist-metadata ()
"Parse metadata from the stream-queue.m3u playlist file.
Returns a plist with :playlist-name, :phase, :description, :curator, :duration"
(let ((playlist-path (merge-pathnames "playlists/stream-queue.m3u"
(asdf:system-source-directory :asteroid))))
(if (probe-file playlist-path)
(handler-case
(with-open-file (stream playlist-path :direction :input)
(let ((metadata (list :playlist-name nil
:phase nil
:description nil
:curator nil
:duration nil)))
(loop for line = (read-line stream nil nil)
while line
do (cond
((cl-ppcre:scan "^#PLAYLIST:" line)
(setf (getf metadata :playlist-name)
(string-trim " " (subseq line 10))))
((cl-ppcre:scan "^#PHASE:" line)
(setf (getf metadata :phase)
(string-trim " " (subseq line 7))))
((cl-ppcre:scan "^#DESCRIPTION:" line)
(setf (getf metadata :description)
(string-trim " " (subseq line 13))))
((cl-ppcre:scan "^#CURATOR:" line)
(setf (getf metadata :curator)
(string-trim " " (subseq line 9))))
((cl-ppcre:scan "^#DURATION:" line)
(setf (getf metadata :duration)
(string-trim " " (subseq line 10))))
;; Stop parsing after we hit actual track entries
((and (> (length line) 0)
(not (char= (char line 0) #\#)))
(return))))
metadata))
(error (e)
(format t "Error reading playlist metadata: ~a~%" e)
(list :playlist-name nil :phase nil :description nil :curator nil :duration nil)))
(list :playlist-name nil :phase nil :description nil :curator nil :duration nil))))
(defun get-curated-channel-name ()
"Get the display name for the curated channel from playlist metadata.
Falls back to 'Curated' if no phase is defined."
(let* ((metadata (get-playlist-metadata))
(phase (getf metadata :phase)))
(or phase "Curated")))
(defun generate-music-search-url (artist song)
"Generate MusicBrainz search URL for artist and song"
;; Simple search without field prefixes works better with URL encoding
@ -381,10 +429,17 @@
;; Copy playlist to stream-queue.m3u
(copy-playlist-to-stream-queue playlist-path)
;; Load into in-memory queue
(let ((count (load-queue-from-m3u-file)))
(let ((count (load-queue-from-m3u-file))
(channel-name (get-curated-channel-name)))
;; Skip current track to trigger crossfade to new playlist
(handler-case
(liquidsoap-command "stream-queue_m3u.skip")
(error (e)
(format *error-output* "Warning: Could not skip track: ~a~%" e)))
(api-output `(("status" . "success")
("message" . ,(format nil "Loaded playlist: ~a" name))
("count" . ,count)
("channel-name" . ,channel-name)
("paths" . ,(read-m3u-file-paths stream-queue-path))))))
(api-output `(("status" . "error")
("message" . "Playlist file not found"))
@ -767,6 +822,7 @@
:listeners "0"
:stream-quality "128kbps MP3"
:stream-base-url *stream-base-url*
:curated-channel-name (get-curated-channel-name)
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
:default-stream-encoding "audio/aac"
:default-stream-encoding-desc "AAC 96kbps Stereo"
@ -793,6 +849,7 @@
:listeners "0"
:stream-quality "128kbps MP3"
:stream-base-url *stream-base-url*
:curated-channel-name (get-curated-channel-name)
:now-playing-artist "The Void"
:now-playing-track "Silence"
:now-playing-album "Startup Sounds"
@ -806,6 +863,7 @@
(clip:process-to-string
(load-template "audio-player-frame")
:stream-base-url *stream-base-url*
:curated-channel-name (get-curated-channel-name)
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
:default-stream-encoding "audio/aac"))
@ -1209,6 +1267,7 @@
(clip:process-to-string
(load-template "popout-player")
:stream-base-url *stream-base-url*
:curated-channel-name (get-curated-channel-name)
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
:default-stream-encoding "audio/aac"))

View File

@ -653,7 +653,12 @@
(if (= (ps:@ data status) "success")
(progn
(show-toast (+ "✓ Loaded " (ps:@ data count) " tracks from " name))
(load-current-queue))
(load-current-queue)
;; Update channel name in all channel selectors
;; Use bracket notation because API returns "channel-name" with hyphen
(let ((channel-name (aref data "channel-name")))
(when channel-name
(update-channel-selector-name channel-name))))
(alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlist:" error))
@ -705,6 +710,27 @@
(setf html (+ html "</div>"))
(setf (ps:@ container inner-h-t-m-l) html))))))
;; Update channel selector name in UI after loading a new playlist
(defun update-channel-selector-name (channel-name)
"Update the curated channel option text in all channel selectors"
;; Store in localStorage so popout player can pick it up
(ps:chain local-storage (set-item "curated-channel-name" channel-name))
;; Update in current document
(let ((channel-selector (ps:chain document (get-element-by-id "stream-channel"))))
(when channel-selector
(let ((curated-option (ps:chain channel-selector (query-selector "option[value='curated']"))))
(when curated-option
(setf (ps:@ curated-option text-content) (+ "🎧 " channel-name))))))
;; Use postMessage to notify all frames about the channel name change
(when (and (ps:@ window top)
(not (= (ps:@ window top) window)))
;; Post to top window which will relay to all frames
(ps:chain window top (post-message
(ps:create :type "channel-name-update" :channel-name channel-name)
"*"))))
;; Save current queue to stream-queue.m3u
(defun save-stream-queue ()
(ps:chain
@ -983,7 +1009,7 @@
;; Toggle city display for a country
(defun toggle-country-cities (country)
(let ((city-row (ps:chain document (get-element-by-id (+ "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")))))

View File

@ -13,9 +13,10 @@
(defvar *is-reconnecting* false)
(defvar *reconnect-timeout* nil)
;; Stream quality configuration
(defun get-stream-config (stream-base-url encoding)
(let ((config (ps:create
;; Stream configuration by channel and quality
;; Curated channel has multiple quality options, shuffle has only one
(defun get-stream-config (stream-base-url channel quality)
(let ((curated-config (ps:create
:aac (ps:create
:url (+ stream-base-url "/asteroid.aac")
:format "AAC 96kbps Stereo"
@ -30,56 +31,129 @@
:url (+ stream-base-url "/asteroid-low.mp3")
:format "MP3 64kbps Stereo"
:type "audio/mpeg"
:mount "asteroid-low.mp3")
:shuffle (ps:create
:mount "asteroid-low.mp3")))
(shuffle-config (ps:create
:url (+ stream-base-url "/asteroid-shuffle.mp3")
:format "Shuffle MP3 96kbps"
:type "audio/mpeg"
:mount "asteroid-shuffle.mp3"))))
(ps:getprop config encoding)))
:mount "asteroid-shuffle.mp3")))
(if (= channel "shuffle")
shuffle-config
(ps:getprop curated-config quality))))
;; Change stream quality
(defun change-stream-quality ()
(let* ((selector (ps:chain document (get-element-by-id "stream-quality")))
;; Get current channel from selector or localStorage
(defun get-current-channel ()
(let ((selector (or (ps:chain document (get-element-by-id "stream-channel"))
(ps:chain document (get-element-by-id "popout-stream-channel")))))
(if selector
(ps:@ selector value)
(or (ps:chain local-storage (get-item "stream-channel")) "curated"))))
;; Get current quality from selector or localStorage
(defun get-current-quality ()
(let ((selector (or (ps:chain document (get-element-by-id "stream-quality"))
(ps:chain document (get-element-by-id "popout-stream-quality")))))
(if selector
(ps:@ selector value)
(or (ps:chain local-storage (get-item "stream-quality")) "aac"))))
;; Update quality selector state based on channel
(defun update-quality-selector-state ()
(let* ((channel (get-current-channel))
(quality-selector (or (ps:chain document (get-element-by-id "stream-quality"))
(ps:chain document (get-element-by-id "popout-stream-quality")))))
(when quality-selector
(if (= channel "shuffle")
(progn
(setf (ps:@ quality-selector disabled) t)
(setf (ps:@ quality-selector title) "Shuffle channel has fixed quality"))
(progn
(setf (ps:@ quality-selector disabled) nil)
(setf (ps:@ quality-selector title) ""))))))
;; Change channel (curated vs shuffle)
(defun change-channel ()
(let* ((channel-selector (or (ps:chain document (get-element-by-id "stream-channel"))
(ps:chain document (get-element-by-id "popout-stream-channel"))))
(channel (ps:@ channel-selector value))
(stream-base-url (ps:chain document (get-element-by-id "stream-base-url")))
(config (get-stream-config (ps:@ stream-base-url value) (ps:@ selector value)))
(audio-element (ps:chain document (get-element-by-id "live-audio")))
(quality (get-current-quality))
(config (get-stream-config (ps:@ stream-base-url value) channel quality))
(audio-element (or (ps:chain document (get-element-by-id "live-audio"))
(ps:chain document (get-element-by-id "persistent-audio"))))
(source-element (ps:chain document (get-element-by-id "audio-source")))
(was-playing (not (ps:@ audio-element paused)))
(current-time (ps:@ audio-element current-time)))
(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-channel" channel))
;; Update quality selector state
(update-quality-selector-state)
;; Update stream information display
(update-stream-information)
;; Update audio player
(when source-element
(setf (ps:@ source-element src) (ps:@ config url))
(setf (ps:@ source-element type) (ps:@ config type)))
(when audio-element
(ps:chain audio-element (load))
;; Resume playback if it was playing
(when was-playing
(ps:chain (ps:chain audio-element (play))
(catch (lambda (e)
(ps:chain console (log "Autoplay prevented:" e)))))))
;; If in frameset mode, notify the player frame to update
(when (not (= (ps:@ window parent) window))
(ps:try
(let ((player-frame (ps:@ (ps:@ window parent) frames "player-frame")))
(when (and player-frame (ps:@ player-frame sync-channel-from-storage))
(ps:chain player-frame (sync-channel-from-storage))))
(:catch (e) nil)))
;; Immediately refresh now playing and recently played
(update-now-playing)
(when (ps:@ window update-recently-played)
(ps:chain window (update-recently-played)))))
;; Change stream quality (bitrate)
(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:chain document (get-element-by-id "stream-base-url")))
(channel (get-current-channel))
(quality (ps:@ selector value))
(config (get-stream-config (ps:@ stream-base-url value) channel quality))
(audio-element (or (ps:chain document (get-element-by-id "live-audio"))
(ps:chain document (get-element-by-id "persistent-audio"))))
(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" quality))
;; Update stream information
(update-stream-information)
;; Update audio player
(when source-element
(setf (ps:@ source-element src) (ps:@ config url))
(setf (ps:@ source-element type) (ps:@ config type))
(setf (ps:@ source-element type) (ps:@ config type)))
(when audio-element
(ps:chain audio-element (load))
;; Resume playback if it was playing
(when was-playing
(ps:chain (ps:chain audio-element (play))
(catch (lambda (e)
(ps:chain console (log "Autoplay prevented:" 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)
;; Get current mount from channel and quality selection
;; Checks local selectors 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"))
(let* ((channel (get-current-channel))
(quality (get-current-quality))
(stream-base-url (or (ps:chain document (get-element-by-id "stream-base-url"))
(when (not (= (ps:@ window parent) window))
(ps:try
@ -88,7 +162,7 @@
(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))))
(get-stream-config (ps:@ stream-base-url value) channel quality))))
(if config (ps:@ config mount) "asteroid.mp3")))
;; Update now playing info from API
@ -109,22 +183,34 @@
;; Update stream information
(defun update-stream-information ()
(let* ((selector (ps:chain document (get-element-by-id "stream-quality")))
(let* ((channel-selector (or (ps:chain document (get-element-by-id "stream-channel"))
(ps:chain document (get-element-by-id "popout-stream-channel"))))
(quality-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:chain document (get-element-by-id "stream-base-url")))
(stream-channel (or (ps:chain local-storage (get-item "stream-channel")) "curated"))
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac")))
;; Update selector if needed
(when (and selector (not (= (ps:@ selector value) stream-quality)))
(setf (ps:@ selector value) stream-quality)
(ps:chain selector (dispatch-event (ps:new (-event "change")))))
;; Update channel selector if needed
(when (and channel-selector (not (= (ps:@ channel-selector value) stream-channel)))
(setf (ps:@ channel-selector value) stream-channel))
;; Update quality selector if needed
(when (and quality-selector (not (= (ps:@ quality-selector value) stream-quality)))
(setf (ps:@ quality-selector value) stream-quality))
;; Update quality selector state (disabled for shuffle)
(update-quality-selector-state)
;; Update stream info display
(when stream-base-url
(let ((config (get-stream-config (ps:@ stream-base-url value) stream-quality)))
(setf (ps:@ (ps:chain document (get-element-by-id "stream-url")) text-content)
(ps:@ config url))
(setf (ps:@ (ps:chain document (get-element-by-id "stream-format")) text-content)
(ps:@ config format))
(let ((config (get-stream-config (ps:@ stream-base-url value) stream-channel stream-quality)))
(let ((url-el (ps:chain document (get-element-by-id "stream-url"))))
(when url-el
(setf (ps:@ url-el text-content) (ps:@ config url))))
(let ((format-el (ps:chain document (get-element-by-id "stream-format"))))
(when format-el
(setf (ps:@ format-el text-content) (ps:@ config format))))
(let ((status-quality (ps:chain document (query-selector "[data-text=\"stream-quality\"]"))))
(when status-quality
(setf (ps:@ status-quality text-content) (ps:@ config format))))))))
@ -219,8 +305,9 @@
(let* ((container (ps:chain document (get-element-by-id "audio-container")))
(old-audio (ps:chain document (get-element-by-id "live-audio")))
(stream-base-url (ps:chain document (get-element-by-id "stream-base-url")))
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac"))
(config (get-stream-config (ps:@ stream-base-url value) stream-quality)))
(stream-channel (get-current-channel))
(stream-quality (get-current-quality))
(config (get-stream-config (ps:@ stream-base-url value) stream-channel stream-quality)))
(when (and container old-audio)
;; Reset spectrum analyzer before removing audio
@ -469,21 +556,25 @@
;; 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
;; Refresh now playing immediately when user switches channel or quality
(let ((channel-selector (or (ps:chain document (get-element-by-id "stream-channel"))
(ps:chain document (get-element-by-id "popout-stream-channel"))))
(quality-selector (or (ps:chain document (get-element-by-id "stream-quality"))
(ps:chain document (get-element-by-id "popout-stream-quality")))))
(when channel-selector
(ps:chain channel-selector
(add-event-listener
"change"
(lambda (_ev)
;; Small delay so localStorage / UI state settles
(ps:chain window (set-timeout update-now-playing 50))
(when (ps:@ window update-recently-played)
(ps:chain window (set-timeout (ps:@ window update-recently-played) 50)))))))
(when quality-selector
(ps:chain quality-selector
(add-event-listener
"change"
(lambda (_ev)
(ps:chain window (set-timeout update-now-playing 50)))))))
;; Attach event listeners to audio element

View File

@ -7,28 +7,32 @@
(ps:ps
(progn
;; Get current mount from stream quality selection
;; Checks local selector first, then sibling player-frame (for frameset mode)
;; Get current channel from selector or localStorage
(defun get-current-channel ()
(let ((selector (or (ps:chain document (get-element-by-id "stream-channel"))
(ps:chain document (get-element-by-id "popout-stream-channel")))))
(if selector
(ps:@ selector value)
(or (ps:chain local-storage (get-item "stream-channel")) "curated"))))
;; Get current quality from selector or localStorage
(defun get-current-quality ()
(let ((selector (or (ps:chain document (get-element-by-id "stream-quality"))
(ps:chain document (get-element-by-id "popout-stream-quality")))))
(if selector
(ps:@ selector value)
(or (ps:chain local-storage (get-item "stream-quality")) "aac"))))
;; Get current mount from channel and quality selection
(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")))
(let ((channel (get-current-channel))
(quality (get-current-quality)))
(if (= channel "shuffle")
"asteroid-shuffle.mp3"
(cond
((= quality "shuffle") "asteroid-shuffle.mp3")
((= quality "low") "asteroid-low.mp3")
((= quality "mp3") "asteroid.mp3")
(t "asteroid.aac"))))
(t "asteroid.aac")))))
;; Update recently played tracks display
(defun update-recently-played ()
@ -105,17 +109,11 @@
(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
;; Refresh immediately when user switches channel
(let ((channel-selector (or (ps:chain document (get-element-by-id "stream-channel"))
(ps:chain document (get-element-by-id "popout-stream-channel")))))
(when channel-selector
(ps:chain channel-selector
(add-event-listener
"change"
(lambda (_ev)

View File

@ -11,9 +11,10 @@
;; Stream Configuration
;; ========================================
;; Get stream configuration for a given quality
(defun get-stream-config (stream-base-url encoding)
(let ((config (ps:create
;; Get stream configuration for a given channel and quality
;; Curated channel has multiple quality options, shuffle has only one
(defun get-stream-config (stream-base-url channel quality)
(let ((curated-config (ps:create
:aac (ps:create :url (+ stream-base-url "/asteroid.aac")
:type "audio/aac"
:format "AAC 96kbps Stereo"
@ -25,24 +26,140 @@
:low (ps:create :url (+ stream-base-url "/asteroid-low.mp3")
:type "audio/mpeg"
:format "MP3 64kbps Stereo"
:mount "asteroid-low.mp3")
:shuffle (ps:create :url (+ stream-base-url "/asteroid-shuffle.mp3")
:mount "asteroid-low.mp3")))
(shuffle-config (ps:create :url (+ stream-base-url "/asteroid-shuffle.mp3")
:type "audio/mpeg"
:format "Shuffle MP3 96kbps"
:mount "asteroid-shuffle.mp3"))))
(ps:getprop config encoding)))
:mount "asteroid-shuffle.mp3")))
(if (= channel "shuffle")
shuffle-config
(ps:getprop curated-config quality))))
;; Get current channel from selector or localStorage
(defun get-current-channel ()
(let ((selector (or (ps:chain document (get-element-by-id "stream-channel"))
(ps:chain document (get-element-by-id "popout-stream-channel")))))
(if selector
(ps:@ selector value)
(or (ps:chain local-storage (get-item "stream-channel")) "curated"))))
;; Get current quality from selector or localStorage
(defun get-current-quality ()
(let ((selector (or (ps:chain document (get-element-by-id "stream-quality"))
(ps:chain document (get-element-by-id "popout-stream-quality")))))
(if selector
(ps:@ selector value)
(or (ps:chain local-storage (get-item "stream-quality")) "aac"))))
;; Update quality selector state based on channel
(defun update-quality-selector-state ()
(let* ((channel (get-current-channel))
(quality-selector (or (ps:chain document (get-element-by-id "stream-quality"))
(ps:chain document (get-element-by-id "popout-stream-quality")))))
(when quality-selector
(if (= channel "shuffle")
(progn
(setf (ps:@ quality-selector disabled) t)
(setf (ps:@ quality-selector title) "Shuffle channel has fixed quality"))
(progn
(setf (ps:@ quality-selector disabled) nil)
(setf (ps:@ quality-selector title) ""))))))
;; Change channel (curated vs shuffle)
;; Called from content frame or popout - updates localStorage and notifies frame player
(defun change-channel ()
(let* ((channel-selector (or (ps:chain document (get-element-by-id "stream-channel"))
(ps:chain document (get-element-by-id "popout-stream-channel"))))
(channel (ps:@ channel-selector value))
(stream-base-url-el (ps:chain document (get-element-by-id "stream-base-url")))
(stream-base-url (when stream-base-url-el (ps:@ stream-base-url-el value)))
(quality (get-current-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")))
(was-playing (and audio-element (not (ps:@ audio-element paused)))))
;; Save preference
(ps:chain local-storage (set-item "stream-channel" channel))
;; Update quality selector state
(update-quality-selector-state)
;; If we have audio element (popout player), update it
(when (and stream-base-url audio-element source-element)
(let ((config (get-stream-config stream-base-url channel quality)))
;; 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))))))))
;; If in frameset mode, notify the player frame to update
(when (not (= (ps:@ window parent) window))
(ps:try
(let ((player-frame (ps:@ (ps:@ window parent) frames "player-frame")))
(when (and player-frame (ps:@ player-frame sync-channel-from-storage))
(ps:chain player-frame (sync-channel-from-storage))))
(:catch (e) nil)))
;; Refresh now-playing immediately
(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)))))
;; Sync channel from localStorage (called by content frame when channel changes)
(defun sync-channel-from-storage ()
(let* ((channel (or (ps:chain local-storage (get-item "stream-channel")) "curated"))
(quality (get-current-quality))
(stream-base-url-el (ps:chain document (get-element-by-id "stream-base-url")))
(stream-base-url (when stream-base-url-el (ps:@ stream-base-url-el value)))
(channel-selector (ps:chain document (get-element-by-id "stream-channel")))
(audio-element (ps:chain document (get-element-by-id "persistent-audio")))
(source-element (ps:chain document (get-element-by-id "audio-source")))
(was-playing (and audio-element (not (ps:@ audio-element paused)))))
;; Update channel selector dropdown to match localStorage
(when (and channel-selector (not (= (ps:@ channel-selector value) channel)))
(setf (ps:@ channel-selector value) channel))
(when (and stream-base-url audio-element source-element)
(let ((config (get-stream-config stream-base-url channel quality)))
;; Update quality selector state
(update-quality-selector-state)
;; 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
(ps:chain window (set-timeout update-mini-now-playing 50))))))
;; ========================================
;; Stream Quality Selection
;; ========================================
;; Change stream quality
;; Change stream quality (bitrate)
(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))
(channel (get-current-channel))
(selected-quality (ps:@ selector value))
(config (get-stream-config stream-base-url selected-quality))
(config (get-stream-config stream-base-url channel 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")))
@ -73,13 +190,12 @@
;; Now Playing Updates
;; ========================================
;; Get current mount from stream quality selection
;; Get current mount from channel and 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"))
(let* ((channel (get-current-channel))
(quality (get-current-quality))
(stream-base-url (ps:@ (ps:chain document (get-element-by-id "stream-base-url")) value))
(config (get-stream-config stream-base-url quality)))
(config (get-stream-config stream-base-url channel quality)))
(if config (ps:@ config mount) "asteroid.mp3")))
;; Update mini now playing display (for persistent player frame)
@ -173,8 +289,9 @@
(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)))
(stream-channel (get-current-channel))
(stream-quality (get-current-quality))
(config (get-stream-config stream-base-url stream-channel stream-quality)))
(unless (and container old-audio)
(show-status "❌ Could not reconnect - reload page" true)
@ -422,12 +539,37 @@
;; Attach event listeners
(attach-audio-listeners audio-element)
;; Restore user channel preference
(let ((channel-selector (ps:chain document (get-element-by-id "stream-channel")))
(stream-channel (or (ps:chain local-storage (get-item "stream-channel")) "curated")))
(when (and channel-selector (not (= (ps:@ channel-selector value) stream-channel)))
(setf (ps:@ channel-selector value) stream-channel)
;; Sync the stream to the saved channel
(change-channel)))
;; Restore user quality preference
(let ((selector (ps:chain document (get-element-by-id "stream-quality")))
(let ((quality-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"))))))
(when (and quality-selector (not (= (ps:@ quality-selector value) stream-quality)))
(setf (ps:@ quality-selector value) stream-quality)))
;; Update quality selector state based on channel
(update-quality-selector-state)
;; Check for channel name changes from localStorage periodically
(let ((last-channel-name (ps:chain local-storage (get-item "curated-channel-name"))))
(set-interval
(lambda ()
(let ((current-channel-name (ps:chain local-storage (get-item "curated-channel-name"))))
(when (and current-channel-name
(not (= current-channel-name last-channel-name)))
(setf last-channel-name current-channel-name)
(let ((channel-selector (ps:chain document (get-element-by-id "stream-channel"))))
(when channel-selector
(let ((curated-option (ps:chain channel-selector (query-selector "option[value='curated']"))))
(when curated-option
(setf (ps:@ curated-option text-content) (+ "🎧 " current-channel-name)))))))))
2000))
;; Start now playing updates
(set-timeout update-mini-now-playing 1000)
@ -440,6 +582,31 @@
;; Attach event listeners
(attach-popout-listeners audio-element)
;; Restore user channel preference
(let ((channel-selector (ps:chain document (get-element-by-id "popout-stream-channel")))
(stream-channel (or (ps:chain local-storage (get-item "stream-channel")) "curated")))
(when (and channel-selector (not (= (ps:@ channel-selector value) stream-channel)))
(setf (ps:@ channel-selector value) stream-channel)))
;; Restore user quality preference
(let ((quality-selector (ps:chain document (get-element-by-id "popout-stream-quality")))
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac")))
(when (and quality-selector (not (= (ps:@ quality-selector value) stream-quality)))
(setf (ps:@ quality-selector value) stream-quality)))
;; Update quality selector state based on channel
(update-quality-selector-state)
;; Listen for channel name changes from localStorage
(ps:chain window (add-event-listener "storage"
(lambda (e)
(when (= (ps:@ e key) "curated-channel-name")
(let ((channel-selector (ps:chain document (get-element-by-id "popout-stream-channel"))))
(when channel-selector
(let ((curated-option (ps:chain channel-selector (query-selector "option[value='curated']"))))
(when curated-option
(setf (ps:@ curated-option text-content) (+ "🎧 " (ps:@ e new-value)))))))))))
;; Start now playing updates
(update-popout-now-playing)
(set-interval update-popout-now-playing 5000)
@ -452,6 +619,8 @@
;; Make functions globally accessible
(setf (ps:@ window get-stream-config) get-stream-config)
(setf (ps:@ window change-channel) change-channel)
(setf (ps:@ window sync-channel-from-storage) sync-channel-from-storage)
(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)

View File

@ -1,6 +1,9 @@
#EXTM3U
#PLAYLIST:Asteroid Low Orbit - Ambient Electronic Journey
#PLAYLIST:Low Orbit
#PHASE:Low Orbit
#DURATION:12 hours (approx)
#CURATOR:Asteroid Radio
#DESCRIPTION:A 12-hour voyage through ambient, IDM, and space music
#EXTINF:-1,Brian Eno - Emerald And Lime
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A1 Emerald And Lime.flac

View File

@ -1295,6 +1295,37 @@ body.persistent-player-container .quality-selector{
gap: 5px;
}
body.persistent-player-container .channel-selector{
display: flex;
align-items: center;
gap: 5px;
}
body.persistent-player-container .channel-selector label{
color: #00ff00;
font-size: 0.9em;
}
body.persistent-player-container .channel-selector select{
background: transparent;
color: #00ff00;
letter-spacing: 0.08rem;
border: 1px solid #00ff00;
padding: 3px 8px;
min-width: 140px;
font-size: 0.9em;
height: 26px;
line-height: 18px;
}
body.persistent-player-container .channel-selector select:hover{
background: #2a3441;
}
body.persistent-player-container .quality-selector label{
@ -1311,12 +1342,20 @@ body.persistent-player-container .quality-selector select{
border: 1px solid #00ff00;
padding: 3px 8px;
min-width: 140px;
font-size: 0.9em;
height: 26px;
line-height: 18px;
}
body.persistent-player-container .quality-selector select:hover{
background: #2a3441;
}
body.persistent-player-container .quality-selector select:disabled{
opacity: 0.5;
cursor: not-allowed;
}
body.persistent-player-container audio{
flex: 1;
min-width: 200px;

View File

@ -1044,6 +1044,30 @@
:align-items "center"
:gap "5px")
(.channel-selector
:display "flex"
:align-items "center"
:gap "5px")
(.channel-selector
(label
:color "#00ff00"
:font-size "0.9em"))
(.channel-selector
(select
:background "transparent"
:color "#00ff00"
:letter-spacing "0.08rem"
:border "1px solid #00ff00"
:padding "3px 8px"
:min-width "140px"
:font-size "0.9em"
:height "26px"
:line-height "18px")
((:and select :hover)
:background "#2a3441"))
(.quality-selector
(label
:color "#00ff00"
@ -1056,9 +1080,15 @@
:letter-spacing "0.08rem"
:border "1px solid #00ff00"
:padding "3px 8px"
:min-width "140px")
:min-width "140px"
:font-size "0.9em"
:height "26px"
:line-height "18px")
((:and select :hover)
:background "#2a3441"))
:background "#2a3441")
((:and select :disabled)
:opacity "0.5"
:cursor "not-allowed"))
(audio
:flex 1

View File

@ -13,14 +13,21 @@
LIVE:
</span>
<div class="quality-selector">
<div class="channel-selector">
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
<label for="stream-channel">Channel:</label>
<select id="stream-channel" onchange="changeChannel()">
<option value="curated">🎧 <c:splice lquery="(text curated-channel-name)">Curated</c:splice></option>
<option value="shuffle">🎲 Shuffle</option>
</select>
</div>
<div class="quality-selector">
<label for="stream-quality">Quality:</label>
<select id="stream-quality" onchange="changeStreamQuality()">
<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

@ -66,6 +66,16 @@
<main>
<div class="live-stream">
<h2 style="color: #00ff00; margin: 0;"><span class="live-stream-indicator" style="font-size: 1rem;">🟢</span> LIVE STREAM</h2>
<!-- Channel Selector -->
<div class="live-stream-quality" style="margin-bottom: 15px;">
<label for="stream-channel" class="live-stream-label"><strong>Channel:</strong></label>
<select id="stream-channel" onchange="changeChannel()">
<option value="curated">🎧 <c:splice lquery="(text curated-channel-name)">Curated</c:splice></option>
<option value="shuffle">🎲 Shuffle</option>
</select>
</div>
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
<p><strong class="live-stream-label">Stream URL:</strong> <code id="stream-url" lquery="(text default-stream-url)"></code></p>
<p><strong class="live-stream-label">Stream Quality:</strong> <span id="stream-format" lquery="(text default-stream-encoding-desc)"></span></p>

View File

@ -69,14 +69,22 @@
<h2 style="color: #00ff00; margin: 0;"><span class="live-stream-indicator" style="font-size: 1rem;">🟢</span> LIVE STREAM</h2>
</div>
<!-- Channel Selector -->
<div class="live-stream-quality" style="margin-bottom: 15px;">
<label for="stream-channel" class="live-stream-label"><strong>Channel:</strong></label>
<select id="stream-channel" onchange="changeChannel()">
<option value="curated">🎧 <c:splice lquery="(text curated-channel-name)">Curated</c:splice></option>
<option value="shuffle">🎲 Shuffle</option>
</select>
</div>
<!-- Stream Quality Selector -->
<div class="live-stream-quality">
<div class="live-stream-quality" style="margin-bottom: 15px;">
<label for="stream-quality" class="live-stream-label" ><strong>Quality:</strong></label>
<select id="stream-quality" onchange="changeStreamQuality()">
<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

@ -26,14 +26,21 @@
</div>
</div>
<div class="quality-selector">
<div class="channel-selector">
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
<label for="popout-stream-channel"><strong>Channel:</strong></label>
<select id="popout-stream-channel" onchange="changeChannel()">
<option value="curated">🎧 <c:splice lquery="(text curated-channel-name)">Curated</c:splice></option>
<option value="shuffle">🎲 Shuffle</option>
</select>
</div>
<div class="quality-selector">
<label for="popout-stream-quality"><strong>Quality:</strong></label>
<select id="popout-stream-quality" onchange="changeStreamQuality()">
<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>