/ parenscript / player.lisp
player.lisp
1 ;;;; player.lisp - ParenScript version of player.js 2 ;;;; Web Player functionality including audio playback, playlists, queue management, and live streaming 3 4 (in-package #:asteroid) 5 6 (defparameter *player-js* 7 (ps:ps* 8 '(progn 9 10 ;; Global variables 11 (defvar *tracks* (array)) 12 (defvar *current-track* nil) 13 (defvar *current-track-index* -1) 14 (defvar *play-queue* (array)) 15 (defvar *is-shuffled* nil) 16 (defvar *is-repeating* nil) 17 (defvar *audio-player* nil) 18 19 ;; Pagination variables for track library 20 (defvar *library-current-page* 1) 21 (defvar *library-tracks-per-page* 20) 22 (defvar *filtered-library-tracks* (array)) 23 24 ;; Initialize player on page load 25 (ps:chain document 26 (add-event-listener 27 "DOMContentLoaded" 28 (lambda () 29 (setf *audio-player* (ps:chain document (get-element-by-id "audio-player"))) 30 (redirect-when-frame) 31 (load-tracks) 32 (load-playlists) 33 (setup-event-listeners) 34 (update-player-display) 35 (update-volume) 36 37 ;; Setup live stream with reduced buffering and reconnect logic 38 (let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio")))) 39 (when live-audio 40 ;; Reduce buffer to minimize delay 41 (setf (ps:@ live-audio preload) "none") 42 43 ;; Add reconnect logic for long pauses 44 (let ((pause-timestamp nil) 45 (is-reconnecting false) 46 (needs-reconnect false) 47 (pause-reconnect-threshold 10000)) 48 49 (ps:chain live-audio 50 (add-event-listener "pause" 51 (lambda () 52 (setf pause-timestamp (ps:chain |Date| (now))) 53 (ps:chain console (log "Live stream paused at:" pause-timestamp))))) 54 55 (ps:chain live-audio 56 (add-event-listener "play" 57 (lambda () 58 (when (and (not is-reconnecting) 59 pause-timestamp 60 (> (- (ps:chain |Date| (now)) pause-timestamp) pause-reconnect-threshold)) 61 (setf needs-reconnect true) 62 (ps:chain console (log "Long pause detected, will reconnect when playing starts..."))) 63 (setf pause-timestamp nil)))) 64 65 (ps:chain live-audio 66 (add-event-listener "playing" 67 (lambda () 68 (when (and needs-reconnect (not is-reconnecting)) 69 (setf is-reconnecting true) 70 (setf needs-reconnect false) 71 (ps:chain console (log "Reconnecting live stream after long pause to clear stale buffers...")) 72 73 (ps:chain live-audio (pause)) 74 75 (when (ps:@ window |resetSpectrumAnalyzer|) 76 (ps:chain window (reset-spectrum-analyzer))) 77 78 (ps:chain live-audio (load)) 79 80 (set-timeout 81 (lambda () 82 (ps:chain live-audio (play) 83 (catch (lambda (err) 84 (ps:chain console (log "Reconnect play failed:" err))))) 85 86 (when (ps:@ window |initSpectrumAnalyzer|) 87 (ps:chain window (init-spectrum-analyzer)) 88 (ps:chain console (log "Spectrum analyzer reinitialized after reconnect"))) 89 90 (setf is-reconnecting false)) 91 200))))) 92 ))) 93 94 ;; Restore user quality preference 95 (let ((selector (ps:chain document (get-element-by-id "live-stream-quality"))) 96 (stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac"))) 97 (when (and selector (not (= (ps:@ selector value) stream-quality))) 98 (setf (ps:@ selector value) stream-quality) 99 (ps:chain selector (dispatch-event (ps:new (-Event "change"))))))))) 100 101 ;; Frame redirection logic 102 (defun redirect-when-frame () 103 (let* ((path (ps:@ window location pathname)) 104 (is-frameset-page (not (= (ps:@ window parent) (ps:@ window self)))) 105 (is-content-frame (ps:chain path (includes "player-content")))) 106 107 (when (and is-frameset-page (not is-content-frame)) 108 (setf (ps:@ window location href) "/asteroid/player-content")) 109 110 (when (and (not is-frameset-page) is-content-frame) 111 (setf (ps:@ window location href) "/asteroid/player")))) 112 113 ;; Setup all event listeners 114 (defun setup-event-listeners () 115 ;; Search 116 (ps:chain (ps:chain document (get-element-by-id "search-tracks")) 117 (add-event-listener "input" filter-tracks)) 118 119 ;; Player controls 120 (ps:chain (ps:chain document (get-element-by-id "play-pause-btn")) 121 (add-event-listener "click" toggle-play-pause)) 122 (ps:chain (ps:chain document (get-element-by-id "prev-btn")) 123 (add-event-listener "click" play-previous)) 124 (ps:chain (ps:chain document (get-element-by-id "next-btn")) 125 (add-event-listener "click" play-next)) 126 (ps:chain (ps:chain document (get-element-by-id "shuffle-btn")) 127 (add-event-listener "click" toggle-shuffle)) 128 (ps:chain (ps:chain document (get-element-by-id "repeat-btn")) 129 (add-event-listener "click" toggle-repeat)) 130 131 ;; Volume control 132 (ps:chain (ps:chain document (get-element-by-id "volume-slider")) 133 (add-event-listener "input" update-volume)) 134 135 ;; Audio player events 136 (when (and *audio-player* (ps:chain *audio-player* add-event-listener)) 137 (ps:chain *audio-player* (add-event-listener "loadedmetadata" update-time-display)) 138 (ps:chain *audio-player* (add-event-listener "timeupdate" update-time-display)) 139 (ps:chain *audio-player* (add-event-listener "ended" handle-track-end)) 140 (ps:chain *audio-player* (add-event-listener "play" (lambda () (update-play-button "⏸️ Pause")))) 141 (ps:chain *audio-player* (add-event-listener "pause" (lambda () (update-play-button "▶️ Play"))))) 142 143 ;; Playlist controls 144 (ps:chain (ps:chain document (get-element-by-id "create-playlist")) 145 (add-event-listener "click" create-playlist)) 146 (ps:chain (ps:chain document (get-element-by-id "clear-queue")) 147 (add-event-listener "click" clear-queue)) 148 (ps:chain (ps:chain document (get-element-by-id "save-queue")) 149 (add-event-listener "click" save-queue-as-playlist))) 150 151 ;; Load tracks from API 152 (defun load-tracks () 153 (ps:chain 154 (ps:chain (fetch "/api/asteroid/tracks")) 155 (then (lambda (response) 156 (if (ps:@ response ok) 157 (ps:chain response (json)) 158 (progn 159 (ps:chain console (error (+ "HTTP " (ps:@ response status)))) 160 (ps:create :status "error" :tracks (array)))))) 161 (then (lambda (result) 162 ;; Handle RADIANCE API wrapper format 163 (let ((data (or (ps:@ result data) result))) 164 (if (= (ps:@ data status) "success") 165 (progn 166 (setf *tracks* (or (ps:@ data tracks) (array))) 167 (display-tracks *tracks*)) 168 (progn 169 (ps:chain console (error "Error loading tracks:" (ps:@ data error))) 170 (setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-h-t-m-l) 171 "<div class=\"error\">Error loading tracks</div>")))))) 172 (catch (lambda (error) 173 (ps:chain console (error "Error loading tracks:" error)) 174 (setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-h-t-m-l) 175 "<div class=\"error\">Error loading tracks</div>"))))) 176 177 ;; Display tracks in library 178 (defun display-tracks (track-list) 179 (setf *filtered-library-tracks* track-list) 180 (setf *library-current-page* 1) 181 (render-library-page)) 182 183 ;; Render current library page 184 (defun render-library-page () 185 (let ((container (ps:chain document (get-element-by-id "track-list"))) 186 (pagination-controls (ps:chain document (get-element-by-id "library-pagination-controls")))) 187 188 (if (= (ps:@ *filtered-library-tracks* length) 0) 189 (progn 190 (setf (ps:@ container inner-h-t-m-l) "<div class=\"no-tracks\">No tracks found</div>") 191 (setf (ps:@ pagination-controls style display) "none") 192 (return))) 193 194 ;; Calculate pagination 195 (let* ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*))) 196 (start-index (* (- *library-current-page* 1) *library-tracks-per-page*)) 197 (end-index (+ start-index *library-tracks-per-page*)) 198 (tracks-to-show (ps:chain *filtered-library-tracks* (slice start-index end-index)))) 199 200 ;; Render tracks for current page 201 (let ((tracks-html (ps:chain tracks-to-show 202 (map (lambda (track page-index) 203 ;; Find the actual index in the full tracks array 204 (let ((actual-index (ps:chain *tracks* 205 (find-index (lambda (trk) (= (ps:@ trk id) (ps:@ track id))))))) 206 (+ "<div class=\"track-item\" data-track-id=\"" (ps:@ track id) "\" data-index=\"" actual-index "\">" 207 "<div class=\"track-info\">" 208 "<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>" 209 "<div class=\"track-meta\">" (or (ps:@ track artist) "Unknown Artist") " • " (or (ps:@ track album) "Unknown Album") "</div>" 210 "</div>" 211 "<div class=\"track-actions\">" 212 "<button onclick=\"playTrack(" actual-index ")\" class=\"btn btn-sm btn-success\" title=\"Play\">▶️</button>" 213 "<button onclick=\"addToQueue(" actual-index ")\" class=\"btn btn-sm btn-info\" title=\"Add to queue\">➕</button>" 214 "<button onclick=\"showAddToPlaylistMenu(" (ps:@ track id) ", event)\" class=\"btn btn-sm btn-secondary\" title=\"Add to playlist\">📋</button>" 215 "</div>" 216 "</div>")))) 217 (join "")))) 218 219 (setf (ps:@ container inner-h-t-m-l) tracks-html) 220 221 ;; Update pagination controls 222 (setf (ps:chain (ps:chain document (get-element-by-id "library-page-info")) text-content) 223 (+ "Page " *library-current-page* " of " total-pages " (" (ps:@ *filtered-library-tracks* length) " tracks)")) 224 (setf (ps:@ pagination-controls style display) 225 (if (> total-pages 1) "block" "none")))))) 226 227 ;; Library pagination functions 228 (defun library-go-to-page (page) 229 (let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*)))) 230 (when (and (>= page 1) (<= page total-pages)) 231 (setf *library-current-page* page) 232 (render-library-page)))) 233 234 (defun library-previous-page () 235 (when (> *library-current-page* 1) 236 (setf *library-current-page* (- *library-current-page* 1)) 237 (render-library-page))) 238 239 (defun library-next-page () 240 (let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*)))) 241 (when (< *library-current-page* total-pages) 242 (setf *library-current-page* (+ *library-current-page* 1)) 243 (render-library-page)))) 244 245 (defun library-go-to-last-page () 246 (let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*)))) 247 (setf *library-current-page* total-pages) 248 (render-library-page))) 249 250 (defun change-library-tracks-per-page () 251 (setf *library-tracks-per-page* 252 (parse-int (ps:chain (ps:chain document (get-element-by-id "library-tracks-per-page")) value))) 253 (setf *library-current-page* 1) 254 (render-library-page)) 255 256 ;; Filter tracks based on search query 257 (defun filter-tracks () 258 (let ((query (ps:chain (ps:chain document (get-element-by-id "search-tracks")) value (to-lower-case)))) 259 (let ((filtered (ps:chain *tracks* 260 (filter (lambda (track) 261 (or (ps:chain (or (ps:@ track title) "") (to-lower-case) (includes query)) 262 (ps:chain (or (ps:@ track artist) "") (to-lower-case) (includes query)) 263 (ps:chain (or (ps:@ track album) "") (to-lower-case) (includes query)))))))) 264 (display-tracks filtered)))) 265 266 ;; Play a specific track by index 267 (defun play-track (index) 268 (when (and (>= index 0) (< index (ps:@ *tracks* length))) 269 (setf *current-track* (aref *tracks* index)) 270 (setf *current-track-index* index) 271 272 ;; Load track into audio player 273 (setf (ps:@ *audio-player* src) (+ "/asteroid/tracks/" (ps:@ *current-track* id) "/stream")) 274 (ps:chain *audio-player* (load)) 275 (ps:chain *audio-player* 276 (play) 277 (catch (lambda (error) 278 (ps:chain console (error "Playback error:" error)) 279 (alert "Error playing track. The track may not be available.")))) 280 281 (update-player-display) 282 283 ;; Update server-side player state 284 (ps:chain (fetch (+ "/api/asteroid/player/play?track-id=" (ps:@ *current-track* id)) 285 (ps:create :method "POST")) 286 (catch (lambda (error) 287 (ps:chain console (error "API update error:" error))))))) 288 289 ;; Toggle play/pause 290 (defun toggle-play-pause () 291 (if *current-track* 292 (if (ps:@ *audio-player* paused) 293 (ps:chain *audio-player* (play)) 294 (ps:chain *audio-player* (pause))) 295 (alert "Please select a track to play"))) 296 297 ;; Play previous track 298 (defun play-previous () 299 (if (> (ps:@ *play-queue* length) 0) 300 ;; Play from queue 301 (let ((prev-index (max 0 (- *current-track-index* 1)))) 302 (play-track prev-index)) 303 ;; Play previous track in library 304 (let ((prev-index (if (> *current-track-index* 0) 305 (- *current-track-index* 1) 306 (- (ps:@ *tracks* length) 1)))) 307 (play-track prev-index)))) 308 309 ;; Play next track 310 (defun play-next () 311 (if (> (ps:@ *play-queue* length) 0) 312 ;; Play from queue 313 (let ((next-track (ps:chain *play-queue* (shift)))) 314 (play-track (ps:chain *tracks* 315 (find-index (lambda (trk) (= (ps:@ trk id) (ps:@ next-track id)))))) 316 (update-queue-display)) 317 ;; Play next track in library 318 (let ((next-index (if *is-shuffled* 319 (floor (* (random) (ps:@ *tracks* length))) 320 (mod (+ *current-track-index* 1) (ps:@ *tracks* length))))) 321 (play-track next-index)))) 322 323 ;; Handle track end 324 (defun handle-track-end () 325 (if *is-repeating* 326 (progn 327 (setf (ps:@ *audio-player* current-time) 0) 328 (ps:chain *audio-player* (play))) 329 (play-next))) 330 331 ;; Toggle shuffle mode 332 (defun toggle-shuffle () 333 (setf *is-shuffled* (not *is-shuffled*)) 334 (let ((btn (ps:chain document (get-element-by-id "shuffle-btn")))) 335 (setf (ps:@ btn text-content) (if *is-shuffled* "🔀 Shuffle ON" "🔀 Shuffle")) 336 (ps:chain btn (class-list toggle "active" *is-shuffled*)))) 337 338 ;; Toggle repeat mode 339 (defun toggle-repeat () 340 (setf *is-repeating* (not *is-repeating*)) 341 (let ((btn (ps:chain document (get-element-by-id "repeat-btn")))) 342 (setf (ps:@ btn text-content) (if *is-repeating* "🔁 Repeat ON" "🔁 Repeat")) 343 (ps:chain btn (class-list toggle "active" *is-repeating*)))) 344 345 ;; Update volume 346 (defun update-volume () 347 (let ((volume (/ (parse-int (ps:chain (ps:chain document (get-element-by-id "volume-slider")) value)) 100))) 348 (when *audio-player* 349 (setf (ps:@ *audio-player* volume) volume)))) 350 351 ;; Update time display 352 (defun update-time-display () 353 (let ((current (format-time (ps:@ *audio-player* current-time))) 354 (total (format-time (ps:@ *audio-player* duration)))) 355 (setf (ps:chain (ps:chain document (get-element-by-id "current-time")) text-content) current) 356 (setf (ps:chain (ps:chain document (get-element-by-id "total-time")) text-content) total))) 357 358 ;; Format time helper 359 (defun format-time (seconds) 360 (if (isNaN seconds) 361 "0:00" 362 (let ((mins (floor (/ seconds 60))) 363 (secs (floor (mod seconds 60)))) 364 (+ mins ":" (ps:chain secs (to-string) (pad-start 2 "0")))))) 365 366 ;; Update play button text 367 (defun update-play-button (text) 368 (setf (ps:chain (ps:chain document (get-element-by-id "play-pause-btn")) text-content) text)) 369 370 ;; Update player display with current track info 371 (defun update-player-display () 372 (when *current-track* 373 (setf (ps:chain (ps:chain document (get-element-by-id "current-title")) text-content) 374 (or (ps:@ *current-track* title) "Unknown Title")) 375 (setf (ps:chain (ps:chain document (get-element-by-id "current-artist")) text-content) 376 (or (ps:@ *current-track* artist) "Unknown Artist")) 377 (setf (ps:chain (ps:chain document (get-element-by-id "current-album")) text-content) 378 (or (ps:@ *current-track* album) "Unknown Album")))) 379 380 ;; Add track to queue 381 (defun add-to-queue (index) 382 (when (and (>= index 0) (< index (ps:@ *tracks* length))) 383 (setf (aref *play-queue* (ps:@ *play-queue* length)) (aref *tracks* index)) 384 (update-queue-display))) 385 386 ;; Update queue display 387 (defun update-queue-display () 388 (let ((container (ps:chain document (get-element-by-id "play-queue")))) 389 (if (= (ps:@ *play-queue* length) 0) 390 (setf (ps:@ container inner-h-t-m-l) "<div class=\"empty-queue\">Queue is empty</div>") 391 (let ((queue-html (ps:chain *play-queue* 392 (map (lambda (track index) 393 (+ "<div class=\"queue-item\">" 394 "<div class=\"track-info\">" 395 "<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>" 396 "<div class=\"track-meta\">" (or (ps:@ track artist) "Unknown Artist") "</div>" 397 "</div>" 398 "<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>" 399 "</div>"))) 400 (join "")))) 401 (setf (ps:@ container inner-h-t-m-l) queue-html))))) 402 403 ;; Remove track from queue 404 (defun remove-from-queue (index) 405 (ps:chain *play-queue* (splice index 1)) 406 (update-queue-display)) 407 408 ;; Clear queue 409 (defun clear-queue () 410 (setf *play-queue* (array)) 411 (update-queue-display)) 412 413 ;; Store playlists for the add-to-playlist menu 414 (defvar *user-playlists* (array)) 415 416 ;; Show add to playlist dropdown menu 417 (defun show-add-to-playlist-menu (track-id event) 418 (ps:chain event (stop-propagation)) 419 ;; Remove any existing menu 420 (let ((existing-menu (ps:chain document (get-element-by-id "playlist-dropdown-menu")))) 421 (when existing-menu 422 (ps:chain existing-menu (remove)))) 423 424 ;; Fetch playlists and show menu 425 (ps:chain (fetch "/api/asteroid/playlists") 426 (then (lambda (response) (ps:chain response (json)))) 427 (then (lambda (result) 428 (let* ((data (or (ps:@ result data) result)) 429 (playlists (or (ps:@ data playlists) (array))) 430 (menu (ps:chain document (create-element "div")))) 431 (setf *user-playlists* playlists) 432 (setf (ps:@ menu id) "playlist-dropdown-menu") 433 (setf (ps:@ menu class-name) "playlist-dropdown-menu") 434 (setf (ps:@ menu style position) "fixed") 435 (setf (ps:@ menu style left) (+ (ps:@ event client-x) "px")) 436 (setf (ps:@ menu style top) (+ (ps:@ event client-y) "px")) 437 (setf (ps:@ menu style z-index) "1000") 438 (setf (ps:@ menu style background) "#1a1a2e") 439 (setf (ps:@ menu style border) "1px solid #00ff00") 440 (setf (ps:@ menu style border-radius) "4px") 441 (setf (ps:@ menu style padding) "5px 0") 442 (setf (ps:@ menu style min-width) "150px") 443 444 (if (= (ps:@ playlists length) 0) 445 (setf (ps:@ menu inner-h-t-m-l) 446 "<div style=\"padding: 8px 12px; color: #888;\">No playlists yet</div>") 447 (setf (ps:@ menu inner-h-t-m-l) 448 (ps:chain playlists 449 (map (lambda (playlist) 450 (+ "<div class=\"playlist-menu-item\" onclick=\"addTrackToPlaylist(" 451 (ps:@ playlist id) ", " track-id 452 ")\" style=\"padding: 8px 12px; cursor: pointer; color: #00ff00;\" " 453 "onmouseover=\"this.style.background='#2a2a4e'\" " 454 "onmouseout=\"this.style.background='transparent'\">" 455 (ps:@ playlist name) " (" (ps:@ playlist "track-count") ")" 456 "</div>"))) 457 (join "")))) 458 459 (ps:chain document body (append-child menu)) 460 461 ;; Close menu when clicking elsewhere 462 (let ((close-handler (lambda (e) 463 (when (not (ps:chain menu (contains (ps:@ e target)))) 464 (ps:chain menu (remove)) 465 (ps:chain document (remove-event-listener "click" close-handler)))))) 466 (set-timeout (lambda () 467 (ps:chain document (add-event-listener "click" close-handler))) 468 100))))) 469 (catch (lambda (error) 470 (ps:chain console (error "Error loading playlists for menu:" error)))))) 471 472 ;; Add track to a specific playlist 473 (defun add-track-to-playlist (playlist-id track-id) 474 ;; Close the menu 475 (let ((menu (ps:chain document (get-element-by-id "playlist-dropdown-menu")))) 476 (when menu (ps:chain menu (remove)))) 477 478 (let ((form-data (ps:new (-Form-data)))) 479 (ps:chain form-data (append "playlist-id" playlist-id)) 480 (ps:chain form-data (append "track-id" track-id)) 481 (ps:chain (fetch "/api/asteroid/playlists/add-track" 482 (ps:create :method "POST" :body form-data)) 483 (then (lambda (response) (ps:chain response (json)))) 484 (then (lambda (result) 485 (let ((data (or (ps:@ result data) result))) 486 (if (= (ps:@ data status) "success") 487 (progn 488 ;; Find playlist name for feedback 489 (let ((playlist (ps:chain *user-playlists* 490 (find (lambda (p) (= (ps:@ p id) playlist-id)))))) 491 (alert (+ "Track added to \"" (if playlist (ps:@ playlist name) "playlist") "\""))) 492 (load-playlists)) 493 (alert (+ "Error: " (ps:@ data message))))))) 494 (catch (lambda (error) 495 (ps:chain console (error "Error adding track to playlist:" error)) 496 (alert "Error adding track to playlist")))))) 497 498 ;; Create playlist 499 (defun create-playlist () 500 (let ((name (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value (trim)))) 501 (when (not (= name "")) 502 (let ((form-data (ps:new (-Form-data)))) 503 (ps:chain form-data (append "name" name)) 504 (ps:chain form-data (append "description" "")) 505 506 (ps:chain (fetch "/api/asteroid/playlists/create" 507 (ps:create :method "POST" :body form-data)) 508 (then (lambda (response) 509 (ps:chain response (json)))) 510 (then (lambda (result) 511 ;; Handle RADIANCE API wrapper format 512 (let ((data (or (ps:@ result data) result))) 513 (if (= (ps:@ data status) "success") 514 (progn 515 (alert (+ "Playlist \"" name "\" created successfully!")) 516 (setf (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value) "") 517 518 ;; Wait a moment then reload playlists 519 (set-timeout load-playlists 500)) 520 (alert (+ "Error creating playlist: " (ps:@ data message))))))) 521 (catch (lambda (error) 522 (ps:chain console (error "Error creating playlist:" error)) 523 (alert (+ "Error creating playlist: " (ps:@ error message)))))))))) 524 525 ;; Save queue as playlist 526 (defun save-queue-as-playlist () 527 (if (> (ps:@ *play-queue* length) 0) 528 (let ((name (prompt "Enter playlist name:"))) 529 (when name 530 ;; Create the playlist 531 (let ((form-data (ps:new (-Form-data)))) 532 (ps:chain form-data (append "name" name)) 533 (ps:chain form-data (append "description" (+ "Created from queue with " (ps:@ *play-queue* length) " tracks"))) 534 535 (ps:chain (fetch "/api/asteroid/playlists/create" 536 (ps:create :method "POST" :body form-data)) 537 (then (lambda (response) (ps:chain response (json)))) 538 (then (lambda (create-result) 539 ;; Handle RADIANCE API wrapper format 540 (let ((create-data (or (ps:@ create-result data) create-result))) 541 (if (= (ps:@ create-data status) "success") 542 (progn 543 ;; Wait a moment for database to update, then fetch playlists 544 (set-timeout 545 (lambda () 546 ;; Get the new playlist ID by fetching playlists 547 (ps:chain (fetch "/api/asteroid/playlists") 548 (then (lambda (response) (ps:chain response (json)))) 549 (then (lambda (playlists-result) 550 ;; Handle RADIANCE API wrapper format 551 (let ((playlist-result-data (or (ps:@ playlists-result data) playlists-result))) 552 (if (and (= (ps:@ playlist-result-data status) "success") 553 (> (ps:@ playlist-result-data playlists length) 0)) 554 (progn 555 ;; Find the playlist with matching name (most recent) 556 (let ((new-playlist (or (ps:chain (ps:@ playlist-result-data playlists) 557 (find (lambda (p) (= (ps:@ p name) name)))) 558 (aref (ps:@ playlist-result-data playlists) 559 (- (ps:@ playlist-result-data playlists length) 1))))) 560 561 ;; Add all tracks from queue to playlist 562 (let ((added-count 0)) 563 (ps:chain *play-queue* 564 (for-each (lambda (track) 565 (let ((track-id (ps:@ track id))) 566 (when track-id 567 (let ((add-form-data (ps:new (-Form-data)))) 568 (ps:chain add-form-data (append "playlist-id" (ps:@ new-playlist id))) 569 (ps:chain add-form-data (append "track-id" track-id)) 570 571 (ps:chain (fetch "/api/asteroid/playlists/add-track" 572 (ps:create :method "POST" :body add-form-data)) 573 (then (lambda (response) (ps:chain response (json)))) 574 (then (lambda (add-result) 575 (when (= (ps:@ add-result data status) "success") 576 (setf added-count (+ added-count 1))))) 577 (catch (lambda (err) 578 (ps:chain console (log "Error adding track:" err))))))))))) 579 580 (alert (+ "Playlist \"" name "\" created with " added-count " tracks!")) 581 (load-playlists)))) 582 (progn 583 (alert (+ "Playlist created but could not add tracks. Error: " 584 (or (ps:@ playlist-result-data message) "Unknown"))) 585 (load-playlists)))))) 586 (catch (lambda (error) 587 (ps:chain console (error "Error fetching playlists:" error)) 588 (alert "Playlist created but could not add tracks"))))) 589 500)) 590 (alert (+ "Error creating playlist: " (ps:@ create-data message))))))) 591 (catch (lambda (error) 592 (ps:chain console (error "Error saving queue as playlist:" error)) 593 (alert (+ "Error saving queue as playlist: " (ps:@ error message))))))))) 594 (alert "Queue is empty"))) 595 596 ;; Load playlists from API 597 (defun load-playlists () 598 (ps:chain 599 (fetch "/api/asteroid/playlists") 600 (then (lambda (response) (ps:chain response (json)))) 601 (then (lambda (result) 602 (ps:chain console (log "Playlists API result:" result)) 603 (let ((playlists (cond 604 ((and (ps:@ result data) (= (ps:@ result data status) "success")) 605 (ps:chain console (log "Found playlists in result.data.playlists")) 606 (or (ps:@ result data playlists) (array))) 607 ((= (ps:@ result status) "success") 608 (ps:chain console (log "Found playlists in result.playlists")) 609 (or (ps:@ result playlists) (array))) 610 (t 611 (ps:chain console (log "No playlists found in response")) 612 (array))))) 613 (ps:chain console (log "Playlists to display:" playlists)) 614 (display-playlists playlists)))) 615 (catch (lambda (error) 616 (ps:chain console (error "Error loading playlists:" error)) 617 (display-playlists (array)))))) 618 619 ;; Display playlists 620 (defun display-playlists (playlists) 621 (let ((container (ps:chain document (get-element-by-id "playlists-container")))) 622 623 (if (or (not playlists) (= (ps:@ playlists length) 0)) 624 (setf (ps:@ container inner-h-t-m-l) "<div class=\"no-playlists\">No playlists created yet.</div>") 625 (let ((playlists-html (ps:chain playlists 626 (map (lambda (playlist) 627 (+ "<div class=\"playlist-item\" data-playlist-id=\"" (ps:@ playlist id) "\">" 628 "<div class=\"playlist-info\">" 629 "<div class=\"playlist-name\">" (ps:@ playlist name) "</div>" 630 "<div class=\"playlist-meta\">" (ps:@ playlist "track-count") " tracks</div>" 631 "</div>" 632 "<div class=\"playlist-actions\">" 633 "<button onclick=\"viewPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-secondary\" title=\"View tracks\">👁️</button>" 634 "<button onclick=\"loadPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-info\" title=\"Load to queue\">📂</button>" 635 "<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>" 636 "</div>" 637 "</div>"))) 638 (join "")))) 639 640 (setf (ps:@ container inner-h-t-m-l) playlists-html))))) 641 642 ;; Delete playlist 643 (defun delete-playlist (playlist-id playlist-name) 644 (when (confirm (+ "Are you sure you want to delete playlist \"" playlist-name "\"?")) 645 (let ((form-data (ps:new (-Form-data)))) 646 (ps:chain form-data (append "playlist-id" playlist-id)) 647 (ps:chain (fetch "/api/asteroid/playlists/delete" 648 (ps:create :method "POST" :body form-data)) 649 (then (lambda (response) (ps:chain response (json)))) 650 (then (lambda (result) 651 (let ((data (or (ps:@ result data) result))) 652 (if (= (ps:@ data status) "success") 653 (progn 654 (alert (+ "Playlist \"" playlist-name "\" deleted")) 655 (load-playlists)) 656 (alert (+ "Error deleting playlist: " (ps:@ data message))))))) 657 (catch (lambda (error) 658 (ps:chain console (error "Error deleting playlist:" error)) 659 (alert "Error deleting playlist"))))))) 660 661 ;; View playlist contents 662 (defun view-playlist (playlist-id) 663 (ps:chain 664 (fetch (+ "/api/asteroid/playlists/get?playlist-id=" playlist-id)) 665 (then (lambda (response) (ps:chain response (json)))) 666 (then (lambda (result) 667 (let ((data (or (ps:@ result data) result))) 668 (if (and (= (ps:@ data status) "success") (ps:@ data playlist)) 669 (let* ((playlist (ps:@ data playlist)) 670 (tracks (or (ps:@ playlist tracks) (array))) 671 (track-list (if (> (ps:@ tracks length) 0) 672 (ps:chain tracks 673 (map (lambda (track index) 674 (+ (+ index 1) ". " 675 (or (ps:@ track artist) "Unknown") " - " 676 (or (ps:@ track title) "Unknown")))) 677 (join "\\n")) 678 "No tracks in playlist"))) 679 (alert (+ "Playlist: " (ps:@ playlist name) "\\n" 680 "Tracks: " (ps:@ playlist "track-count") "\\n\\n" 681 track-list))) 682 (alert "Could not load playlist"))))) 683 (catch (lambda (error) 684 (ps:chain console (error "Error viewing playlist:" error)) 685 (alert "Error viewing playlist"))))) 686 687 ;; Load playlist into queue 688 (defun load-playlist (playlist-id) 689 (ps:chain 690 (ps:chain (fetch (+ "/api/asteroid/playlists/get?playlist-id=" playlist-id))) 691 (then (lambda (response) (ps:chain response (json)))) 692 (then (lambda (result) 693 ;; Handle RADIANCE API wrapper format 694 (let ((data (or (ps:@ result data) result))) 695 (if (and (= (ps:@ data status) "success") (ps:@ data playlist)) 696 (let ((playlist (ps:@ data playlist))) 697 698 ;; Clear current queue 699 (setf *play-queue* (array)) 700 701 ;; Add all playlist tracks to queue 702 (if (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0)) 703 (progn 704 (ps:chain (ps:@ playlist tracks) 705 (for-each (lambda (track) 706 ;; Find the full track object from our tracks array 707 (let ((full-track (ps:chain *tracks* 708 (find (lambda (trk) (= (ps:@ trk id) (ps:@ track id))))))) 709 (when full-track 710 (setf (aref *play-queue* (ps:@ *play-queue* length)) full-track)))))) 711 712 (update-queue-display) 713 (let ((loaded-count (ps:@ *play-queue* length))) 714 (alert (+ "Loaded " loaded-count " tracks from \"" (ps:@ playlist name) "\" into queue!")) 715 716 ;; Optionally start playing the first track 717 (when (> loaded-count 0) 718 (let* ((first-track (aref *play-queue* 0)) 719 (track-index (ps:chain *tracks* 720 (find-index (lambda (trk) (= (ps:@ trk id) (ps:@ first-track id))))))) 721 ;; Remove first track from queue since we're playing it 722 (ps:chain *play-queue* (shift)) 723 (update-queue-display) 724 (when (>= track-index 0) 725 (play-track track-index)))))) 726 (alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty")))) 727 (alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error"))))))) 728 (catch (lambda (error) 729 (ps:chain console (error "Error loading playlist:" error)) 730 (alert (+ "Error loading playlist: " (ps:@ error message))))))) 731 732 ;; Stream quality configuration 733 (defun get-live-stream-config (stream-base-url quality) 734 (let ((config (ps:create 735 :aac (ps:create 736 :url (+ stream-base-url "/asteroid.aac") 737 :type "audio/aac" 738 :mount "asteroid.aac") 739 :mp3 (ps:create 740 :url (+ stream-base-url "/asteroid.mp3") 741 :type "audio/mpeg" 742 :mount "asteroid.mp3") 743 :low (ps:create 744 :url (+ stream-base-url "/asteroid-low.mp3") 745 :type "audio/mpeg" 746 :mount "asteroid-low.mp3")))) 747 (aref config quality))) 748 749 ;; Change live stream quality 750 (defun change-live-stream-quality () 751 (let ((stream-base-url (ps:chain (ps:chain document (get-element-by-id "stream-base-url")) value)) 752 (selector (ps:chain document (get-element-by-id "live-stream-quality"))) 753 (config (get-live-stream-config 754 (ps:chain (ps:chain document (get-element-by-id "stream-base-url")) value) 755 (ps:chain (ps:chain document (get-element-by-id "live-stream-quality")) value)))) 756 757 ;; Update audio player 758 (let ((audio-element (ps:chain document (get-element-by-id "live-stream-audio"))) 759 (source-element (ps:chain document (get-element-by-id "live-stream-source"))) 760 (was-playing (not (ps:chain (ps:chain document (get-element-by-id "live-stream-audio")) paused)))) 761 762 (setf (ps:@ source-element src) (ps:@ config url)) 763 (setf (ps:@ source-element type) (ps:@ config type)) 764 (ps:chain audio-element (load)) 765 766 ;; Resume playback if it was playing 767 (when was-playing 768 (ps:chain audio-element 769 (play) 770 (catch (lambda (e) (ps:chain console (log "Autoplay prevented:" e))))))))) 771 772 ;; Update now playing information 773 (defun update-now-playing () 774 (ps:chain 775 (fetch "/api/asteroid/partial/now-playing") 776 (then (lambda (response) 777 (let ((content-type (ps:chain response headers (get "content-type")))) 778 (if (ps:chain content-type (includes "text/html")) 779 (ps:chain response (text)) 780 (progn 781 (ps:chain console (log "Error connecting to stream")) 782 ""))))) 783 (then (lambda (data) 784 (setf (ps:chain document (get-element-by-id "now-playing") inner-h-t-m-l) data))) 785 786 (catch (lambda (error) 787 (ps:chain console (log "Could not fetch stream status:" error)))))) 788 789 ;; Initial update after 1 second 790 (set-timeout update-now-playing 1000) 791 ;; Update live stream info every 10 seconds 792 (set-interval update-now-playing 10000) 793 794 ;; Make functions globally accessible for onclick handlers 795 (defvar window (ps:@ window)) 796 (setf (ps:@ window play-track) play-track) 797 (setf (ps:@ window add-to-queue) add-to-queue) 798 (setf (ps:@ window remove-from-queue) remove-from-queue) 799 (setf (ps:@ window library-go-to-page) library-go-to-page) 800 (setf (ps:@ window library-previous-page) library-previous-page) 801 (setf (ps:@ window library-next-page) library-next-page) 802 (setf (ps:@ window library-go-to-last-page) library-go-to-last-page) 803 (setf (ps:@ window change-library-tracks-per-page) change-library-tracks-per-page) 804 (setf (ps:@ window load-playlist) load-playlist) 805 (setf (ps:@ window delete-playlist) delete-playlist) 806 (setf (ps:@ window view-playlist) view-playlist) 807 (setf (ps:@ window show-add-to-playlist-menu) show-add-to-playlist-menu) 808 (setf (ps:@ window add-track-to-playlist) add-track-to-playlist))) 809 "Compiled JavaScript for web player - generated at load time") 810 811 (defun generate-player-js () 812 "Generate JavaScript code for the web player" 813 *player-js*)