diff --git a/asteroid.asd b/asteroid.asd index 117038b..c01fb35 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -49,7 +49,8 @@ (:file "template-utils") (:file "parenscript-utils") (:module :parenscript - :components ((:file "recently-played") + :components ((:file "parenscript-utils") + (:file "recently-played") (:file "auth-ui") (:file "front-page") (:file "profile") diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index fe7c873..1a72015 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -222,9 +222,9 @@ (catch (lambda (error) ;; Silently fail nil))))) - + ;; Update now playing info from API - (defun update-now-playing () + (defun update-now-playing() (let ((mount (get-current-mount))) (ps:chain (fetch (+ "/api/asteroid/partial/now-playing?mount=" mount)) @@ -250,19 +250,25 @@ ;; Check if this track is in user's favorites (check-favorite-status) ;; Update favorite count display - (let ((count-el (ps:chain document (get-element-by-id "favorite-count-display"))) - (count-val-el (ps:chain document (get-element-by-id "favorite-count-value")))) - (when (and count-el count-val-el) - (let ((fav-count (parse-int (or (ps:@ count-val-el value) "0") 10))) - (if (> fav-count 0) - (setf (ps:@ count-el text-content) - (if (= fav-count 1) - "1 person loves this track ❤️" - (+ fav-count " people love this track ❤️"))) - (setf (ps:@ count-el text-content) ""))))))))))))) + (update-favorite-information) + (update-media-session new-title))))))))) (catch (lambda (error) (ps:chain console (log "Could not fetch stream status:" error))))))) - + + ;; Update favorite count display + (defun update-favorite-information () + (let ((count-el (ps:chain document (get-element-by-id "favorite-count-display"))) + (count-val-el (ps:chain document (get-element-by-id "favorite-count-value")))) + (when (and count-el count-val-el) + (let ((fav-count (parse-int (or (ps:@ count-val-el value) "0") 10))) + (if (> fav-count 0) + (setf (ps:@ count-el text-content) + (if (= fav-count 1) + "1 person loves this track ❤️" + (+ fav-count " people love this track ❤️"))) + (setf (ps:@ count-el text-content) "")))))) + + ;; Update stream information (defun update-stream-information () (let* ((channel-selector (or (ps:chain document (get-element-by-id "stream-channel")) @@ -635,6 +641,7 @@ ;; Load user's favorites for highlight feature (load-favorites-cache) + (update-favorite-information) ;; Update now playing (update-now-playing) @@ -864,4 +871,6 @@ (defun generate-front-page-js () "Return the pre-compiled JavaScript for front page" - *front-page-js*) + (ps-join + *common-player-js* + *front-page-js*)) diff --git a/parenscript/player.lisp b/parenscript/player.lisp index 11cd87e..325423b 100644 --- a/parenscript/player.lisp +++ b/parenscript/player.lisp @@ -3,811 +3,835 @@ (in-package #:asteroid) -(defparameter *player-js* +(defparameter *common-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") + (defun update-media-session (title) + (let ((media-session (ps:@ navigator media-session))) + (when media-session + (let ((track-title "Unknown") + (now-playing-title-el (ps:chain document (query-selector "#current-track-title")))) + (when title + (setf track-title title)) + (when (and now-playing-title-el (not title)) + (let ((now-playing-title (ps:@ now-playing-title-el text-content))) + (when now-playing-title + (setf track-title now-playing-title)))) + (let* ((media-info (ps:create :title track-title + :artwork (list (ps:create :src "/asteroid/static/asteroid-squared.png" + :type "image/png" + :sizes "256x256")))) + (metadata (ps:new (-media-metadata media-info)))) + (setf (ps:@ media-session metadata) metadata))))))))) + +(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 - (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) - "
Error loading tracks
")))))) - (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) - "
Error loading tracks
"))))) - - ;; 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) "
No tracks found
") - (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))))))) - (+ "
" - "
" - "
" (or (ps:@ track title) "Unknown Title") "
" - "
" (or (ps:@ track artist) "Unknown Artist") " • " (or (ps:@ track album) "Unknown Album") "
" - "
" - "
" - "" - "" - "" - "
" - "
")))) - (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) "
Queue is empty
") - (let ((queue-html (ps:chain *play-queue* - (map (lambda (track index) - (+ "
" - "
" - "
" (or (ps:@ track title) "Unknown Title") "
" - "
" (or (ps:@ track artist) "Unknown Artist") "
" - "
" - "" - "
"))) + (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) + "
Error loading tracks
")))))) + (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) + "
Error loading tracks
"))))) + + ;; 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) "
No tracks found
") + (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))))))) + (+ "
" + "
" + "
" (or (ps:@ track title) "Unknown Title") "
" + "
" (or (ps:@ track artist) "Unknown Artist") " • " (or (ps:@ track album) "Unknown Album") "
" + "
" + "
" + "" + "" + "" + "
" + "
")))) (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) - "
No playlists yet
") - (setf (ps:@ menu inner-h-t-m-l) - (ps:chain playlists - (map (lambda (playlist) - (+ "
" - (ps:@ playlist name) " (" (ps:@ playlist "track-count") ")" - "
"))) - (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)) + + (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) "
Queue is empty
") + (let ((queue-html (ps:chain *play-queue* + (map (lambda (track index) + (+ "
" + "
" + "
" (or (ps:@ track title) "Unknown Title") "
" + "
" (or (ps:@ track artist) "Unknown Artist") "
" + "
" + "" + "
"))) + (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))) - (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))))))) + (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) + "
No playlists yet
") + (setf (ps:@ menu inner-h-t-m-l) + (ps:chain playlists + (map (lambda (playlist) + (+ "
" + (ps:@ playlist name) " (" (ps:@ playlist "track-count") ")" + "
"))) + (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 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) "
No playlists created yet.
") - (let ((playlists-html (ps:chain playlists - (map (lambda (playlist) - (+ "
" - "
" - "
" (ps:@ playlist name) "
" - "
" (ps:@ playlist "track-count") " tracks
" - "
" - "
" - "" - "" - "" - "
" - "
"))) - (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 "\"?")) + (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 (fetch "/api/asteroid/playlists/delete" + (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 - (alert (+ "Playlist \"" playlist-name "\" deleted")) + ;; 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 deleting playlist: " (ps:@ data message))))))) + (alert (+ "Error: " (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 + (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) "
No playlists created yet.
") + (let ((playlists-html (ps:chain playlists + (map (lambda (playlist) + (+ "
" + "
" + "
" (ps:@ playlist name) "
" + "
" (ps:@ playlist "track-count") " tracks
" + "
" + "
" + "" + "" + "" + "
" + "
"))) + (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 + :mp3 (ps:create :url (+ stream-base-url "/asteroid.mp3") :type "audio/mpeg" :mount "asteroid.mp3") - :low (ps:create + :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))) + (aref config quality))) - (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))) + ;; 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) + (update-media-session))) + + (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*) + (ps-join + *common-player-js* + *player-js*)) diff --git a/parenscript/stream-player.lisp b/parenscript/stream-player.lisp index d903ecf..d6a76f6 100644 --- a/parenscript/stream-player.lisp +++ b/parenscript/stream-player.lisp @@ -524,6 +524,7 @@ (setf (ps:@ el text-content) title) ;; Check if this track is in user's favorites (check-favorite-status-mini)) + (update-media-session title) (when track-id-el (let ((track-id (or (ps:@ data data track_id) (ps:@ data track_id)))) (setf (ps:@ track-id-el value) (or track-id "")))) @@ -634,7 +635,8 @@ (when title-el (setf (ps:@ title-el text-content) (ps:chain track-text (trim)))) (when artist-el - (setf (ps:@ artist-el text-content) "Asteroid Radio")))))))) + (setf (ps:@ artist-el text-content) "Asteroid Radio"))))) + (update-media-session track-text)))) (catch (lambda (error) (ps:chain console (error "Error updating now playing:" error))))))) @@ -1082,4 +1084,6 @@ (defun generate-stream-player-js () "Generate JavaScript code for the stream player" - *stream-player-js*) + (ps-join + *common-player-js* + *stream-player-js*)) diff --git a/static/asteroid-squared.png b/static/asteroid-squared.png new file mode 100644 index 0000000..88ed113 Binary files /dev/null and b/static/asteroid-squared.png differ