asteroid/parenscript/player.lisp

568 lines
29 KiB
Common Lisp
Raw Blame History

This file contains ambiguous Unicode characters

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

;;;; player.lisp - ParenScript version of player.js
;;;; Web Player functionality including audio playback, playlists, queue management, and live streaming
(in-package #:asteroid)
(defparameter *player-js*
(ps:ps*
'(progn
;; Global variables
(defvar *tracks* (array))
(defvar *current-track* nil)
(defvar *current-track-index* -1)
(defvar *play-queue* (array))
(defvar *is-shuffled* nil)
(defvar *is-repeating* nil)
(defvar *audio-player* nil)
;; Pagination variables for track library
(defvar *library-current-page* 1)
(defvar *library-tracks-per-page* 20)
(defvar *filtered-library-tracks* (array))
;; Initialize player on page load
(ps:chain document
(add-event-listener
"DOMContentLoaded"
(lambda ()
(setf *audio-player* (ps:chain document (get-element-by-id "audio-player")))
(redirect-when-frame)
(load-tracks)
(load-playlists)
(setup-event-listeners)
(update-player-display)
(update-volume)
;; Setup live stream with reduced buffering
(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")))
;; Restore user quality preference
(let ((selector (ps:chain document (get-element-by-id "live-stream-quality")))
(stream-quality (ps:chain (ps:@ 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 (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 *audio-player*
(ps:chain *audio-player*
(add-event-listener "loadedmetadata" update-time-display)
(add-event-listener "timeupdate" update-time-display)
(add-event-listener "ended" handle-track-end)
(add-event-listener "play" (lambda () (update-play-button "⏸️ Pause")))
(add-event-listener "pause" (lambda () (update-play-button "▶️ Play")))))
;; Playlist controls
(ps:chain (ps:chain document (get-element-by-id "create-playlist"))
(add-event-listener "click" create-playlist))
(ps:chain (ps:chain document (get-element-by-id "clear-queue"))
(add-event-listener "click" clear-queue))
(ps:chain (ps:chain document (get-element-by-id "save-queue"))
(add-event-listener "click" save-queue-as-playlist)))
;; Load tracks from API
(defun load-tracks ()
(ps:chain
(ps:chain (fetch "/api/asteroid/tracks"))
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (json))
(progn
(ps:chain console (error (+ "HTTP " (ps:@ response status))))
(ps:create :status "error" :tracks (array))))))
(then (lambda (result)
;; Handle RADIANCE API wrapper format
(let ((data (or (ps:@ result data) result)))
(if (== (ps:@ data status) "success")
(progn
(setf *tracks* (or (ps:@ data tracks) (array)))
(display-tracks *tracks*))
(progn
(ps:chain console (error "Error loading tracks:" (ps:@ data error)))
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-html)
"<div class=\"error\">Error loading tracks</div>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading tracks:" error))
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-html)
"<div class=\"error\">Error loading tracks</div>")))))
;; Display tracks in library
(defun display-tracks (track-list)
(setf *filtered-library-tracks* track-list)
(setf *library-current-page* 1)
(render-library-page))
;; Render current library page
(defun render-library-page ()
(let ((container (ps:chain document (get-element-by-id "track-list")))
(pagination-controls (ps:chain document (get-element-by-id "library-pagination-controls"))))
(if (== (ps:@ *filtered-library-tracks* length) 0)
(progn
(setf (ps:@ container inner-html) "<div class=\"no-tracks\">No tracks found</div>")
(setf (ps:@ pagination-controls style display) "none")
(return)))
;; Calculate pagination
(let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*)))
(start-index (* (* *library-current-page* -1) *library-tracks-per-page* *library-tracks-per-page*))
(end-index (+ start-index *library-tracks-per-page*))
(tracks-to-show (ps:chain *filtered-library-tracks* (slice start-index end-index))))
;; Render tracks for current page
(let ((tracks-html (ps:chain tracks-to-show
(map (lambda (track page-index)
;; Find the actual index in the full tracks array
(let ((actual-index (ps:chain *tracks*
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ track id)))))))
(+ "<div class=\"track-item\" data-track-id=\"" (ps:@ track id) "\" data-index=\"" actual-index "\">"
"<div class=\"track-info\">"
"<div class=\"track-title\">" (or (ps:@ track title 0) "Unknown Title") "</div>"
"<div class=\"track-meta\">" (or (ps:@ track artist 0) "Unknown Artist") " • " (or (ps:@ track album 0) "Unknown Album") "</div>"
"</div>"
"<div class=\"track-actions\">"
"<button onclick=\"playTrack(" actual-index ")\" class=\"btn btn-sm btn-success\">▶️</button>"
"<button onclick=\"addToQueue(" actual-index ")\" class=\"btn btn-sm btn-info\"></button>"
"</div>"
"</div>"))))
(join ""))))
(setf (ps:@ container inner-html) 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*
(parseInt (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 0) "") (to-lower-case) (includes query))
(ps:chain (or (ps:@ track artist 0) "") (to-lower-case) (includes query))
(ps:chain (or (ps:@ track album 0) "") (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 (/ (parseInt (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 0) "Unknown Title"))
(setf (ps:chain (ps:chain document (get-element-by-id "current-artist")) text-content)
(or (ps:@ *current-track* artist 0) "Unknown Artist"))
(setf (ps:chain (ps:chain document (get-element-by-id "current-album")) text-content)
(or (ps:@ *current-track* album 0) "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-html) "<div class=\"empty-queue\">Queue is empty</div>")
(let ((queue-html (ps:chain *play-queue*
(map (lambda (track index)
(+ "<div class=\"queue-item\">"
"<div class=\"track-info\">"
"<div class=\"track-title\">" (or (ps:@ track title 0) "Unknown Title") "</div>"
"<div class=\"track-meta\">" (or (ps:@ track artist 0) "Unknown Artist") "</div>"
"</div>"
"<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>"
"</div>")))
(join ""))))
(setf (ps:@ container inner-html) 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))
;; 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 (new "FormData")))
(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)
(if (== (ps:@ result 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
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
(then (lambda () (load-playlists)))))
(alert (+ "Error creating playlist: " (ps:@ result 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 (new "FormData")))
(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)
(if (== (ps:@ create-result status) "success")
(progn
(alert (+ "Playlist \"" name "\" created successfully!"))
(load-playlists))
(alert (+ "Error creating playlist: " (ps:@ create-result 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
(ps:chain (fetch "/api/asteroid/playlists"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((playlists (cond
((and (ps:@ result data) (== (ps:@ result data status) "success"))
(or (ps:@ result data playlists) (array)))
((== (ps:@ result status) "success")
(or (ps:@ result playlists) (array)))
(t
(array)))))
(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-html) "<div class=\"no-playlists\">No playlists created yet.</div>")
(let ((playlists-html (ps:chain playlists
(map (lambda (playlist)
(+ "<div class=\"playlist-item\">"
"<div class=\"playlist-info\">"
"<div class=\"playlist-name\">" (ps:@ playlist name) "</div>"
"<div class=\"playlist-meta\">" (ps:@ playlist "track-count") " tracks</div>"
"</div>"
"<div class=\"playlist-actions\">"
"<button onclick=\"loadPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-info\">📂 Load</button>"
"</div>"
"</div>"))
(join "")))))
(setf (ps:@ container inner-html) playlists-html)))))
;; 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)
(if (and (== (ps:@ result status) "success") (ps:@ result playlist))
(let ((playlist (ps:@ result playlist)))
;; Clear current queue
(setf *play-queue* (array))
;; Add all playlist tracks to queue
(when (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
(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)
(alert (+ "Loaded " (ps:@ *play-queue* length) " tracks from \"" (ps:@ playlist name) "\" into queue!"))
;; Optionally start playing the first track
(when (> (ps:@ *play-queue* length) 0)
(let ((first-track (ps:chain *play-queue* (shift)))
(track-index (ps:chain *tracks*
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ first-track id))))))
)
(when (>= track-index 0)
(play-track track-index))))))
(when (or (not (ps:@ playlist tracks)) (== (ps:@ playlist tracks length) 0))
(alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
(alert (+ "Error loading playlist: " (or (ps:@ result message) "Unknown error"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlist:" error))
(alert (+ "Error loading playlist: " (ps:@ error message)))))))
;; Stream quality configuration
(defun get-live-stream-config (stream-base-url quality)
(let ((config (ps:create
:aac (ps:create
:url (+ stream-base-url "/asteroid.aac")
:type "audio/aac"
:mount "asteroid.aac")
:mp3 (ps:create
:url (+ stream-base-url "/asteroid.mp3")
:type "audio/mpeg"
:mount "asteroid.mp3")
:low (ps:create
:url (+ stream-base-url "/asteroid-low.mp3")
:type "audio/mpeg"
:mount "asteroid-low.mp3"))))
(aref config quality)))
;; Change live stream quality
(defun change-live-stream-quality ()
(let ((stream-base-url (ps:chain (ps:chain document (get-element-by-id "stream-base-url")) value))
(selector (ps:chain document (get-element-by-id "live-stream-quality")))
(config (get-live-stream-config
(ps:chain (ps:chain document (get-element-by-id "stream-base-url")) value)
(ps:chain (ps:chain document (get-element-by-id "live-stream-quality")) value))))
;; Update audio player
(let ((audio-element (ps:chain document (get-element-by-id "live-stream-audio")))
(source-element (ps:chain document (get-element-by-id "live-stream-source")))
(was-playing (not (ps:chain (ps:chain document (get-element-by-id "live-stream-audio")) paused))))
(setf (ps:@ source-element src) (ps:@ config url))
(setf (ps:@ source-element type) (ps:@ config type))
(ps:chain audio-element (load))
;; Resume playback if it was playing
(when was-playing
(ps:chain audio-element
(play)
(catch (lambda (e) (ps:chain console (log "Autoplay prevented:" e)))))))))
;; Update now playing information
(defun update-now-playing ()
(ps:chain
(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 (ps:chain document (get-element-by-id "now-playing")) inner-html) data)))
(catch (lambda (error)
(ps:chain console (log "Could not fetch stream status:" error))))))
;; Initial update after 1 second
(ps:chain (setTimeout update-now-playing 1000))
;; Update live stream info every 10 seconds
(ps:chain (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)
))
"Compiled JavaScript for web player - generated at load time")
(defun generate-player-js ()
"Generate JavaScript code for the web player"
*player-js*)