asteroid/parenscript/player.lisp

814 lines
48 KiB
Common Lisp
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;;;; player.lisp - ParenScript version of player.js
;;;; Web Player functionality including audio playback, playlists, queue management, and live streaming
(in-package #:asteroid)
(defparameter *player-js*
(ps:ps*
'(progn
;; Global variables
(defvar *tracks* (array))
(defvar *current-track* nil)
(defvar *current-track-index* -1)
(defvar *play-queue* (array))
(defvar *is-shuffled* nil)
(defvar *is-repeating* nil)
(defvar *audio-player* nil)
;; Pagination variables for track library
(defvar *library-current-page* 1)
(defvar *library-tracks-per-page* 20)
(defvar *filtered-library-tracks* (array))
;; Initialize player on page load
(ps:chain document
(add-event-listener
"DOMContentLoaded"
(lambda ()
(setf *audio-player* (ps:chain document (get-element-by-id "audio-player")))
(redirect-when-frame)
(load-tracks)
(load-playlists)
(setup-event-listeners)
(update-player-display)
(update-volume)
;; Setup live stream with reduced buffering and reconnect logic
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
(when live-audio
;; Reduce buffer to minimize delay
(setf (ps:@ live-audio preload) "none")
;; Add reconnect logic for long pauses
(let ((pause-timestamp nil)
(is-reconnecting false)
(needs-reconnect false)
(pause-reconnect-threshold 10000))
(ps:chain live-audio
(add-event-listener "pause"
(lambda ()
(setf pause-timestamp (ps:chain |Date| (now)))
(ps:chain console (log "Live stream paused at:" pause-timestamp)))))
(ps:chain live-audio
(add-event-listener "play"
(lambda ()
(when (and (not is-reconnecting)
pause-timestamp
(> (- (ps:chain |Date| (now)) pause-timestamp) pause-reconnect-threshold))
(setf needs-reconnect true)
(ps:chain console (log "Long pause detected, will reconnect when playing starts...")))
(setf pause-timestamp nil))))
(ps:chain live-audio
(add-event-listener "playing"
(lambda ()
(when (and needs-reconnect (not is-reconnecting))
(setf is-reconnecting true)
(setf needs-reconnect false)
(ps:chain console (log "Reconnecting live stream after long pause to clear stale buffers..."))
(ps:chain live-audio (pause))
(when (ps:@ window |resetSpectrumAnalyzer|)
(ps:chain window (reset-spectrum-analyzer)))
(ps:chain live-audio (load))
(set-timeout
(lambda ()
(ps:chain live-audio (play)
(catch (lambda (err)
(ps:chain console (log "Reconnect play failed:" err)))))
(when (ps:@ window |initSpectrumAnalyzer|)
(ps:chain window (init-spectrum-analyzer))
(ps:chain console (log "Spectrum analyzer reinitialized after reconnect")))
(setf is-reconnecting false))
200)))))
)))
;; Restore user quality preference
(let ((selector (ps:chain document (get-element-by-id "live-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")))))))))
;; Frame redirection logic
(defun redirect-when-frame ()
(let* ((path (ps:@ window location pathname))
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self))))
(is-content-frame (ps:chain path (includes "player-content"))))
(when (and is-frameset-page (not is-content-frame))
(setf (ps:@ window location href) "/asteroid/player-content"))
(when (and (not is-frameset-page) is-content-frame)
(setf (ps:@ window location href) "/asteroid/player"))))
;; Setup all event listeners
(defun setup-event-listeners ()
;; Search
(ps:chain (ps:chain document (get-element-by-id "search-tracks"))
(add-event-listener "input" filter-tracks))
;; Player controls
(ps:chain (ps:chain document (get-element-by-id "play-pause-btn"))
(add-event-listener "click" toggle-play-pause))
(ps:chain (ps:chain document (get-element-by-id "prev-btn"))
(add-event-listener "click" play-previous))
(ps:chain (ps:chain document (get-element-by-id "next-btn"))
(add-event-listener "click" play-next))
(ps:chain (ps:chain document (get-element-by-id "shuffle-btn"))
(add-event-listener "click" toggle-shuffle))
(ps:chain (ps:chain document (get-element-by-id "repeat-btn"))
(add-event-listener "click" toggle-repeat))
;; Volume control
(ps:chain (ps:chain document (get-element-by-id "volume-slider"))
(add-event-listener "input" update-volume))
;; Audio player events
(when (and *audio-player* (ps:chain *audio-player* add-event-listener))
(ps:chain *audio-player* (add-event-listener "loadedmetadata" update-time-display))
(ps:chain *audio-player* (add-event-listener "timeupdate" update-time-display))
(ps:chain *audio-player* (add-event-listener "ended" handle-track-end))
(ps:chain *audio-player* (add-event-listener "play" (lambda () (update-play-button "⏸️ Pause"))))
(ps:chain *audio-player* (add-event-listener "pause" (lambda () (update-play-button "▶️ Play")))))
;; Playlist controls
(ps:chain (ps:chain document (get-element-by-id "create-playlist"))
(add-event-listener "click" create-playlist))
(ps:chain (ps:chain document (get-element-by-id "clear-queue"))
(add-event-listener "click" clear-queue))
(ps:chain (ps:chain document (get-element-by-id "save-queue"))
(add-event-listener "click" save-queue-as-playlist)))
;; Load tracks from API
(defun load-tracks ()
(ps:chain
(ps:chain (fetch "/api/asteroid/tracks"))
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (json))
(progn
(ps:chain console (error (+ "HTTP " (ps:@ response status))))
(ps:create :status "error" :tracks (array))))))
(then (lambda (result)
;; Handle RADIANCE API wrapper format
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(setf *tracks* (or (ps:@ data tracks) (array)))
(display-tracks *tracks*))
(progn
(ps:chain console (error "Error loading tracks:" (ps:@ data error)))
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-h-t-m-l)
"<div class=\"error\">Error loading tracks</div>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading tracks:" error))
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-h-t-m-l)
"<div class=\"error\">Error loading tracks</div>")))))
;; Display tracks in library
(defun display-tracks (track-list)
(setf *filtered-library-tracks* track-list)
(setf *library-current-page* 1)
(render-library-page))
;; Render current library page
(defun render-library-page ()
(let ((container (ps:chain document (get-element-by-id "track-list")))
(pagination-controls (ps:chain document (get-element-by-id "library-pagination-controls"))))
(if (= (ps:@ *filtered-library-tracks* length) 0)
(progn
(setf (ps:@ container inner-h-t-m-l) "<div class=\"no-tracks\">No tracks found</div>")
(setf (ps:@ pagination-controls style display) "none")
(return)))
;; Calculate pagination
(let* ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*)))
(start-index (* (- *library-current-page* 1) *library-tracks-per-page*))
(end-index (+ start-index *library-tracks-per-page*))
(tracks-to-show (ps:chain *filtered-library-tracks* (slice start-index end-index))))
;; Render tracks for current page
(let ((tracks-html (ps:chain tracks-to-show
(map (lambda (track page-index)
;; Find the actual index in the full tracks array
(let ((actual-index (ps:chain *tracks*
(find-index (lambda (trk) (= (ps:@ trk id) (ps:@ track id)))))))
(+ "<div class=\"track-item\" data-track-id=\"" (ps:@ track id) "\" data-index=\"" actual-index "\">"
"<div class=\"track-info\">"
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>"
"<div class=\"track-meta\">" (or (ps:@ track artist) "Unknown Artist") " • " (or (ps:@ track album) "Unknown Album") "</div>"
"</div>"
"<div class=\"track-actions\">"
"<button onclick=\"playTrack(" actual-index ")\" class=\"btn btn-sm btn-success\" title=\"Play\">▶️</button>"
"<button onclick=\"addToQueue(" actual-index ")\" class=\"btn btn-sm btn-info\" title=\"Add to queue\"></button>"
"<button onclick=\"showAddToPlaylistMenu(" (ps:@ track id) ", event)\" class=\"btn btn-sm btn-secondary\" title=\"Add to playlist\">📋</button>"
"</div>"
"</div>"))))
(join ""))))
(setf (ps:@ container inner-h-t-m-l) tracks-html)
;; Update pagination controls
(setf (ps:chain (ps:chain document (get-element-by-id "library-page-info")) text-content)
(+ "Page " *library-current-page* " of " total-pages " (" (ps:@ *filtered-library-tracks* length) " tracks)"))
(setf (ps:@ pagination-controls style display)
(if (> total-pages 1) "block" "none"))))))
;; Library pagination functions
(defun library-go-to-page (page)
(let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*))))
(when (and (>= page 1) (<= page total-pages))
(setf *library-current-page* page)
(render-library-page))))
(defun library-previous-page ()
(when (> *library-current-page* 1)
(setf *library-current-page* (- *library-current-page* 1))
(render-library-page)))
(defun library-next-page ()
(let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*))))
(when (< *library-current-page* total-pages)
(setf *library-current-page* (+ *library-current-page* 1))
(render-library-page))))
(defun library-go-to-last-page ()
(let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*))))
(setf *library-current-page* total-pages)
(render-library-page)))
(defun change-library-tracks-per-page ()
(setf *library-tracks-per-page*
(parse-int (ps:chain (ps:chain document (get-element-by-id "library-tracks-per-page")) value)))
(setf *library-current-page* 1)
(render-library-page))
;; Filter tracks based on search query
(defun filter-tracks ()
(let ((query (ps:chain (ps:chain document (get-element-by-id "search-tracks")) value (to-lower-case))))
(let ((filtered (ps:chain *tracks*
(filter (lambda (track)
(or (ps:chain (or (ps:@ track title) "") (to-lower-case) (includes query))
(ps:chain (or (ps:@ track artist) "") (to-lower-case) (includes query))
(ps:chain (or (ps:@ track album) "") (to-lower-case) (includes query))))))))
(display-tracks filtered))))
;; Play a specific track by index
(defun play-track (index)
(when (and (>= index 0) (< index (ps:@ *tracks* length)))
(setf *current-track* (aref *tracks* index))
(setf *current-track-index* index)
;; Load track into audio player
(setf (ps:@ *audio-player* src) (+ "/asteroid/tracks/" (ps:@ *current-track* id) "/stream"))
(ps:chain *audio-player* (load))
(ps:chain *audio-player*
(play)
(catch (lambda (error)
(ps:chain console (error "Playback error:" error))
(alert "Error playing track. The track may not be available."))))
(update-player-display)
;; Update server-side player state
(ps:chain (fetch (+ "/api/asteroid/player/play?track-id=" (ps:@ *current-track* id))
(ps:create :method "POST"))
(catch (lambda (error)
(ps:chain console (error "API update error:" error)))))))
;; Toggle play/pause
(defun toggle-play-pause ()
(if *current-track*
(if (ps:@ *audio-player* paused)
(ps:chain *audio-player* (play))
(ps:chain *audio-player* (pause)))
(alert "Please select a track to play")))
;; Play previous track
(defun play-previous ()
(if (> (ps:@ *play-queue* length) 0)
;; Play from queue
(let ((prev-index (max 0 (- *current-track-index* 1))))
(play-track prev-index))
;; Play previous track in library
(let ((prev-index (if (> *current-track-index* 0)
(- *current-track-index* 1)
(- (ps:@ *tracks* length) 1))))
(play-track prev-index))))
;; Play next track
(defun play-next ()
(if (> (ps:@ *play-queue* length) 0)
;; Play from queue
(let ((next-track (ps:chain *play-queue* (shift))))
(play-track (ps:chain *tracks*
(find-index (lambda (trk) (= (ps:@ trk id) (ps:@ next-track id))))))
(update-queue-display))
;; Play next track in library
(let ((next-index (if *is-shuffled*
(floor (* (random) (ps:@ *tracks* length)))
(mod (+ *current-track-index* 1) (ps:@ *tracks* length)))))
(play-track next-index))))
;; Handle track end
(defun handle-track-end ()
(if *is-repeating*
(progn
(setf (ps:@ *audio-player* current-time) 0)
(ps:chain *audio-player* (play)))
(play-next)))
;; Toggle shuffle mode
(defun toggle-shuffle ()
(setf *is-shuffled* (not *is-shuffled*))
(let ((btn (ps:chain document (get-element-by-id "shuffle-btn"))))
(setf (ps:@ btn text-content) (if *is-shuffled* "🔀 Shuffle ON" "🔀 Shuffle"))
(ps:chain btn (class-list toggle "active" *is-shuffled*))))
;; Toggle repeat mode
(defun toggle-repeat ()
(setf *is-repeating* (not *is-repeating*))
(let ((btn (ps:chain document (get-element-by-id "repeat-btn"))))
(setf (ps:@ btn text-content) (if *is-repeating* "🔁 Repeat ON" "🔁 Repeat"))
(ps:chain btn (class-list toggle "active" *is-repeating*))))
;; Update volume
(defun update-volume ()
(let ((volume (/ (parse-int (ps:chain (ps:chain document (get-element-by-id "volume-slider")) value)) 100)))
(when *audio-player*
(setf (ps:@ *audio-player* volume) volume))))
;; Update time display
(defun update-time-display ()
(let ((current (format-time (ps:@ *audio-player* current-time)))
(total (format-time (ps:@ *audio-player* duration))))
(setf (ps:chain (ps:chain document (get-element-by-id "current-time")) text-content) current)
(setf (ps:chain (ps:chain document (get-element-by-id "total-time")) text-content) total)))
;; Format time helper
(defun format-time (seconds)
(if (isNaN seconds)
"0:00"
(let ((mins (floor (/ seconds 60)))
(secs (floor (mod seconds 60))))
(+ mins ":" (ps:chain secs (to-string) (pad-start 2 "0"))))))
;; Update play button text
(defun update-play-button (text)
(setf (ps:chain (ps:chain document (get-element-by-id "play-pause-btn")) text-content) text))
;; Update player display with current track info
(defun update-player-display ()
(when *current-track*
(setf (ps:chain (ps:chain document (get-element-by-id "current-title")) text-content)
(or (ps:@ *current-track* title) "Unknown Title"))
(setf (ps:chain (ps:chain document (get-element-by-id "current-artist")) text-content)
(or (ps:@ *current-track* artist) "Unknown Artist"))
(setf (ps:chain (ps:chain document (get-element-by-id "current-album")) text-content)
(or (ps:@ *current-track* album) "Unknown Album"))))
;; Add track to queue
(defun add-to-queue (index)
(when (and (>= index 0) (< index (ps:@ *tracks* length)))
(setf (aref *play-queue* (ps:@ *play-queue* length)) (aref *tracks* index))
(update-queue-display)))
;; Update queue display
(defun update-queue-display ()
(let ((container (ps:chain document (get-element-by-id "play-queue"))))
(if (= (ps:@ *play-queue* length) 0)
(setf (ps:@ container inner-h-t-m-l) "<div class=\"empty-queue\">Queue is empty</div>")
(let ((queue-html (ps:chain *play-queue*
(map (lambda (track index)
(+ "<div class=\"queue-item\">"
"<div class=\"track-info\">"
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>"
"<div class=\"track-meta\">" (or (ps:@ track artist) "Unknown Artist") "</div>"
"</div>"
"<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>"
"</div>")))
(join ""))))
(setf (ps:@ container inner-h-t-m-l) queue-html)))))
;; Remove track from queue
(defun remove-from-queue (index)
(ps:chain *play-queue* (splice index 1))
(update-queue-display))
;; Clear queue
(defun clear-queue ()
(setf *play-queue* (array))
(update-queue-display))
;; Store playlists for the add-to-playlist menu
(defvar *user-playlists* (array))
;; Show add to playlist dropdown menu
(defun show-add-to-playlist-menu (track-id event)
(ps:chain event (stop-propagation))
;; Remove any existing menu
(let ((existing-menu (ps:chain document (get-element-by-id "playlist-dropdown-menu"))))
(when existing-menu
(ps:chain existing-menu (remove))))
;; Fetch playlists and show menu
(ps:chain (fetch "/api/asteroid/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let* ((data (or (ps:@ result data) result))
(playlists (or (ps:@ data playlists) (array)))
(menu (ps:chain document (create-element "div"))))
(setf *user-playlists* playlists)
(setf (ps:@ menu id) "playlist-dropdown-menu")
(setf (ps:@ menu class-name) "playlist-dropdown-menu")
(setf (ps:@ menu style position) "fixed")
(setf (ps:@ menu style left) (+ (ps:@ event client-x) "px"))
(setf (ps:@ menu style top) (+ (ps:@ event client-y) "px"))
(setf (ps:@ menu style z-index) "1000")
(setf (ps:@ menu style background) "#1a1a2e")
(setf (ps:@ menu style border) "1px solid #00ff00")
(setf (ps:@ menu style border-radius) "4px")
(setf (ps:@ menu style padding) "5px 0")
(setf (ps:@ menu style min-width) "150px")
(if (= (ps:@ playlists length) 0)
(setf (ps:@ menu inner-h-t-m-l)
"<div style=\"padding: 8px 12px; color: #888;\">No playlists yet</div>")
(setf (ps:@ menu inner-h-t-m-l)
(ps:chain playlists
(map (lambda (playlist)
(+ "<div class=\"playlist-menu-item\" onclick=\"addTrackToPlaylist("
(ps:@ playlist id) ", " track-id
")\" style=\"padding: 8px 12px; cursor: pointer; color: #00ff00;\" "
"onmouseover=\"this.style.background='#2a2a4e'\" "
"onmouseout=\"this.style.background='transparent'\">"
(ps:@ playlist name) " (" (ps:@ playlist "track-count") ")"
"</div>")))
(join ""))))
(ps:chain document body (append-child menu))
;; Close menu when clicking elsewhere
(let ((close-handler (lambda (e)
(when (not (ps:chain menu (contains (ps:@ e target))))
(ps:chain menu (remove))
(ps:chain document (remove-event-listener "click" close-handler))))))
(set-timeout (lambda ()
(ps:chain document (add-event-listener "click" close-handler)))
100)))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlists for menu:" error))))))
;; Add track to a specific playlist
(defun add-track-to-playlist (playlist-id track-id)
;; Close the menu
(let ((menu (ps:chain document (get-element-by-id "playlist-dropdown-menu"))))
(when menu (ps:chain menu (remove))))
(let ((form-data (ps:new (-Form-data))))
(ps:chain form-data (append "playlist-id" playlist-id))
(ps:chain form-data (append "track-id" track-id))
(ps:chain (fetch "/api/asteroid/playlists/add-track"
(ps:create :method "POST" :body form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
;; Find playlist name for feedback
(let ((playlist (ps:chain *user-playlists*
(find (lambda (p) (= (ps:@ p id) playlist-id))))))
(alert (+ "Track added to \"" (if playlist (ps:@ playlist name) "playlist") "\"")))
(load-playlists))
(alert (+ "Error: " (ps:@ data message)))))))
(catch (lambda (error)
(ps:chain console (error "Error adding track to playlist:" error))
(alert "Error adding track to playlist"))))))
;; Create playlist
(defun create-playlist ()
(let ((name (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value (trim))))
(when (not (= name ""))
(let ((form-data (ps:new (-Form-data))))
(ps:chain form-data (append "name" name))
(ps:chain form-data (append "description" ""))
(ps:chain (fetch "/api/asteroid/playlists/create"
(ps:create :method "POST" :body form-data))
(then (lambda (response)
(ps:chain response (json))))
(then (lambda (result)
;; Handle RADIANCE API wrapper format
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(alert (+ "Playlist \"" name "\" created successfully!"))
(setf (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value) "")
;; Wait a moment then reload playlists
(set-timeout load-playlists 500))
(alert (+ "Error creating playlist: " (ps:@ data message)))))))
(catch (lambda (error)
(ps:chain console (error "Error creating playlist:" error))
(alert (+ "Error creating playlist: " (ps:@ error message))))))))))
;; Save queue as playlist
(defun save-queue-as-playlist ()
(if (> (ps:@ *play-queue* length) 0)
(let ((name (prompt "Enter playlist name:")))
(when name
;; Create the playlist
(let ((form-data (ps:new (-Form-data))))
(ps:chain form-data (append "name" name))
(ps:chain form-data (append "description" (+ "Created from queue with " (ps:@ *play-queue* length) " tracks")))
(ps:chain (fetch "/api/asteroid/playlists/create"
(ps:create :method "POST" :body form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (create-result)
;; Handle RADIANCE API wrapper format
(let ((create-data (or (ps:@ create-result data) create-result)))
(if (= (ps:@ create-data status) "success")
(progn
;; Wait a moment for database to update, then fetch playlists
(set-timeout
(lambda ()
;; Get the new playlist ID by fetching playlists
(ps:chain (fetch "/api/asteroid/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (playlists-result)
;; Handle RADIANCE API wrapper format
(let ((playlist-result-data (or (ps:@ playlists-result data) playlists-result)))
(if (and (= (ps:@ playlist-result-data status) "success")
(> (ps:@ playlist-result-data playlists length) 0))
(progn
;; Find the playlist with matching name (most recent)
(let ((new-playlist (or (ps:chain (ps:@ playlist-result-data playlists)
(find (lambda (p) (= (ps:@ p name) name))))
(aref (ps:@ playlist-result-data playlists)
(- (ps:@ playlist-result-data playlists length) 1)))))
;; Add all tracks from queue to playlist
(let ((added-count 0))
(ps:chain *play-queue*
(for-each (lambda (track)
(let ((track-id (ps:@ track id)))
(when track-id
(let ((add-form-data (ps:new (-Form-data))))
(ps:chain add-form-data (append "playlist-id" (ps:@ new-playlist id)))
(ps:chain add-form-data (append "track-id" track-id))
(ps:chain (fetch "/api/asteroid/playlists/add-track"
(ps:create :method "POST" :body add-form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (add-result)
(when (= (ps:@ add-result data status) "success")
(setf added-count (+ added-count 1)))))
(catch (lambda (err)
(ps:chain console (log "Error adding track:" err)))))))))))
(alert (+ "Playlist \"" name "\" created with " added-count " tracks!"))
(load-playlists))))
(progn
(alert (+ "Playlist created but could not add tracks. Error: "
(or (ps:@ playlist-result-data message) "Unknown")))
(load-playlists))))))
(catch (lambda (error)
(ps:chain console (error "Error fetching playlists:" error))
(alert "Playlist created but could not add tracks")))))
500))
(alert (+ "Error creating playlist: " (ps:@ create-data message)))))))
(catch (lambda (error)
(ps:chain console (error "Error saving queue as playlist:" error))
(alert (+ "Error saving queue as playlist: " (ps:@ error message)))))))))
(alert "Queue is empty")))
;; Load playlists from API
(defun load-playlists ()
(ps:chain
(fetch "/api/asteroid/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(ps:chain console (log "Playlists API result:" result))
(let ((playlists (cond
((and (ps:@ result data) (= (ps:@ result data status) "success"))
(ps:chain console (log "Found playlists in result.data.playlists"))
(or (ps:@ result data playlists) (array)))
((= (ps:@ result status) "success")
(ps:chain console (log "Found playlists in result.playlists"))
(or (ps:@ result playlists) (array)))
(t
(ps:chain console (log "No playlists found in response"))
(array)))))
(ps:chain console (log "Playlists to display:" playlists))
(display-playlists playlists))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlists:" error))
(display-playlists (array))))))
;; Display playlists
(defun display-playlists (playlists)
(let ((container (ps:chain document (get-element-by-id "playlists-container"))))
(if (or (not playlists) (= (ps:@ playlists length) 0))
(setf (ps:@ container inner-h-t-m-l) "<div class=\"no-playlists\">No playlists created yet.</div>")
(let ((playlists-html (ps:chain playlists
(map (lambda (playlist)
(+ "<div class=\"playlist-item\" data-playlist-id=\"" (ps:@ playlist id) "\">"
"<div class=\"playlist-info\">"
"<div class=\"playlist-name\">" (ps:@ playlist name) "</div>"
"<div class=\"playlist-meta\">" (ps:@ playlist "track-count") " tracks</div>"
"</div>"
"<div class=\"playlist-actions\">"
"<button onclick=\"viewPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-secondary\" title=\"View tracks\">👁️</button>"
"<button onclick=\"loadPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-info\" title=\"Load to queue\">📂</button>"
"<button onclick=\"deletePlaylist(" (ps:@ playlist id) ", '" (ps:chain (ps:@ playlist name) (replace (ps:regex "/'/g") "\\\\'")) "')\" class=\"btn btn-sm btn-danger\" title=\"Delete playlist\">🗑️</button>"
"</div>"
"</div>")))
(join ""))))
(setf (ps:@ container inner-h-t-m-l) playlists-html)))))
;; Delete playlist
(defun delete-playlist (playlist-id playlist-name)
(when (confirm (+ "Are you sure you want to delete playlist \"" playlist-name "\"?"))
(let ((form-data (ps:new (-Form-data))))
(ps:chain form-data (append "playlist-id" playlist-id))
(ps:chain (fetch "/api/asteroid/playlists/delete"
(ps:create :method "POST" :body form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(alert (+ "Playlist \"" playlist-name "\" deleted"))
(load-playlists))
(alert (+ "Error deleting playlist: " (ps:@ data message)))))))
(catch (lambda (error)
(ps:chain console (error "Error deleting playlist:" error))
(alert "Error deleting playlist")))))))
;; View playlist contents
(defun view-playlist (playlist-id)
(ps:chain
(fetch (+ "/api/asteroid/playlists/get?playlist-id=" playlist-id))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (and (= (ps:@ data status) "success") (ps:@ data playlist))
(let* ((playlist (ps:@ data playlist))
(tracks (or (ps:@ playlist tracks) (array)))
(track-list (if (> (ps:@ tracks length) 0)
(ps:chain tracks
(map (lambda (track index)
(+ (+ index 1) ". "
(or (ps:@ track artist) "Unknown") " - "
(or (ps:@ track title) "Unknown"))))
(join "\\n"))
"No tracks in playlist")))
(alert (+ "Playlist: " (ps:@ playlist name) "\\n"
"Tracks: " (ps:@ playlist "track-count") "\\n\\n"
track-list)))
(alert "Could not load playlist")))))
(catch (lambda (error)
(ps:chain console (error "Error viewing playlist:" error))
(alert "Error viewing playlist")))))
;; Load playlist into queue
(defun load-playlist (playlist-id)
(ps:chain
(ps:chain (fetch (+ "/api/asteroid/playlists/get?playlist-id=" playlist-id)))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
;; Handle RADIANCE API wrapper format
(let ((data (or (ps:@ result data) result)))
(if (and (= (ps:@ data status) "success") (ps:@ data playlist))
(let ((playlist (ps:@ data playlist)))
;; Clear current queue
(setf *play-queue* (array))
;; Add all playlist tracks to queue
(if (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
(progn
(ps:chain (ps:@ playlist tracks)
(for-each (lambda (track)
;; Find the full track object from our tracks array
(let ((full-track (ps:chain *tracks*
(find (lambda (trk) (= (ps:@ trk id) (ps:@ track id)))))))
(when full-track
(setf (aref *play-queue* (ps:@ *play-queue* length)) full-track))))))
(update-queue-display)
(let ((loaded-count (ps:@ *play-queue* length)))
(alert (+ "Loaded " loaded-count " tracks from \"" (ps:@ playlist name) "\" into queue!"))
;; Optionally start playing the first track
(when (> loaded-count 0)
(let* ((first-track (aref *play-queue* 0))
(track-index (ps:chain *tracks*
(find-index (lambda (trk) (= (ps:@ trk id) (ps:@ first-track id)))))))
;; Remove first track from queue since we're playing it
(ps:chain *play-queue* (shift))
(update-queue-display)
(when (>= track-index 0)
(play-track track-index))))))
(alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
(alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlist:" error))
(alert (+ "Error loading playlist: " (ps:@ error message)))))))
;; Stream quality configuration
(defun get-live-stream-config (stream-base-url quality)
(let ((config (ps:create
:aac (ps:create
:url (+ stream-base-url "/asteroid.aac")
:type "audio/aac"
:mount "asteroid.aac")
:mp3 (ps:create
:url (+ stream-base-url "/asteroid.mp3")
:type "audio/mpeg"
:mount "asteroid.mp3")
:low (ps:create
:url (+ stream-base-url "/asteroid-low.mp3")
:type "audio/mpeg"
:mount "asteroid-low.mp3"))))
(aref config quality)))
;; Change live stream quality
(defun change-live-stream-quality ()
(let ((stream-base-url (ps:chain (ps:chain document (get-element-by-id "stream-base-url")) value))
(selector (ps:chain document (get-element-by-id "live-stream-quality")))
(config (get-live-stream-config
(ps:chain (ps:chain document (get-element-by-id "stream-base-url")) value)
(ps:chain (ps:chain document (get-element-by-id "live-stream-quality")) value))))
;; Update audio player
(let ((audio-element (ps:chain document (get-element-by-id "live-stream-audio")))
(source-element (ps:chain document (get-element-by-id "live-stream-source")))
(was-playing (not (ps:chain (ps:chain document (get-element-by-id "live-stream-audio")) paused))))
(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)))))))))
;; Update now playing information
(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))
(progn
(ps:chain console (log "Error connecting to stream"))
"")))))
(then (lambda (data)
(setf (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))))))
;; Initial update after 1 second
(set-timeout update-now-playing 1000)
;; Update live stream info every 10 seconds
(set-interval update-now-playing 10000)
;; Make functions globally accessible for onclick handlers
(defvar window (ps:@ window))
(setf (ps:@ window play-track) play-track)
(setf (ps:@ window add-to-queue) add-to-queue)
(setf (ps:@ window remove-from-queue) remove-from-queue)
(setf (ps:@ window library-go-to-page) library-go-to-page)
(setf (ps:@ window library-previous-page) library-previous-page)
(setf (ps:@ window library-next-page) library-next-page)
(setf (ps:@ window library-go-to-last-page) library-go-to-last-page)
(setf (ps:@ window change-library-tracks-per-page) change-library-tracks-per-page)
(setf (ps:@ window load-playlist) load-playlist)
(setf (ps:@ window delete-playlist) delete-playlist)
(setf (ps:@ window view-playlist) view-playlist)
(setf (ps:@ window show-add-to-playlist-menu) show-add-to-playlist-menu)
(setf (ps:@ window add-track-to-playlist) add-track-to-playlist)))
"Compiled JavaScript for web player - generated at load time")
(defun generate-player-js ()
"Generate JavaScript code for the web player"
*player-js*)