;;;; admin.lisp - ParenScript version of admin.js ;;;; Admin Dashboard functionality including track management, queue controls, and player (in-package #:asteroid) (defparameter *admin-js* (ps:ps* '(progn ;; Global variables (defvar *tracks* (array)) (defvar *current-track-id* nil) (defvar *current-page* 1) (defvar *tracks-per-page* 20) (defvar *filtered-tracks* (array)) (defvar *stream-queue* (array)) (defvar *queue-search-timeout* nil) (defvar *audio-player* nil) ;; Initialize admin dashboard on page load (ps:chain document (add-event-listener "DOMContentLoaded" (lambda () (load-tracks) (update-player-status) (setup-event-listeners) (load-stream-queue) ;; Update player status every 5 seconds (set-interval update-player-status 5000)))) ;; Setup all event listeners (defun setup-event-listeners () ;; Main controls (let ((scan-btn (ps:chain document (get-element-by-id "scan-library"))) (refresh-btn (ps:chain document (get-element-by-id "refresh-tracks"))) (search-input (ps:chain document (get-element-by-id "track-search"))) (sort-select (ps:chain document (get-element-by-id "sort-tracks"))) (copy-btn (ps:chain document (get-element-by-id "copy-files"))) (open-btn (ps:chain document (get-element-by-id "open-incoming")))) (when scan-btn (ps:chain scan-btn (add-event-listener "click" scan-library))) (when refresh-btn (ps:chain refresh-btn (add-event-listener "click" load-tracks))) (when search-input (ps:chain search-input (add-event-listener "input" filter-tracks))) (when sort-select (ps:chain sort-select (add-event-listener "change" sort-tracks))) (when copy-btn (ps:chain copy-btn (add-event-listener "click" copy-files))) (when open-btn (ps:chain open-btn (add-event-listener "click" open-incoming-folder)))) ;; Player controls (let ((play-btn (ps:chain document (get-element-by-id "player-play"))) (pause-btn (ps:chain document (get-element-by-id "player-pause"))) (stop-btn (ps:chain document (get-element-by-id "player-stop"))) (resume-btn (ps:chain document (get-element-by-id "player-resume")))) (when play-btn (ps:chain play-btn (add-event-listener "click" (lambda () (play-track *current-track-id*))))) (when pause-btn (ps:chain pause-btn (add-event-listener "click" pause-player))) (when stop-btn (ps:chain stop-btn (add-event-listener "click" stop-player))) (when resume-btn (ps:chain resume-btn (add-event-listener "click" resume-player)))) ;; Queue controls (let ((refresh-queue-btn (ps:chain document (get-element-by-id "refresh-queue"))) (load-m3u-btn (ps:chain document (get-element-by-id "load-from-m3u"))) (clear-queue-btn (ps:chain document (get-element-by-id "clear-queue-btn"))) (add-random-btn (ps:chain document (get-element-by-id "add-random-tracks"))) (queue-search-input (ps:chain document (get-element-by-id "queue-track-search")))) (when refresh-queue-btn (ps:chain refresh-queue-btn (add-event-listener "click" load-stream-queue))) (when load-m3u-btn (ps:chain load-m3u-btn (add-event-listener "click" load-queue-from-m3u))) (when clear-queue-btn (ps:chain clear-queue-btn (add-event-listener "click" clear-stream-queue))) (when add-random-btn (ps:chain add-random-btn (add-event-listener "click" add-random-tracks))) (when queue-search-input (ps:chain queue-search-input (add-event-listener "input" search-tracks-for-queue))))) ;; Load tracks from API (defun load-tracks () (ps:chain (fetch "/api/asteroid/admin/tracks") (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) ;; Handle Radiance API response format (let ((data (or (ps:@ result data) result))) (when (= (ps:@ data status) "success") (setf *tracks* (or (ps:@ data tracks) (array))) (let ((count-el (ps:chain document (get-element-by-id "track-count")))) (when count-el (setf (ps:@ count-el text-content) (ps:@ *tracks* length)))) (display-tracks *tracks*))))) (catch (lambda (error) (ps:chain console (error "Error loading tracks:" error)) (let ((container (ps:chain document (get-element-by-id "tracks-container")))) (when container (setf (ps:@ container inner-h-t-m-l) "
Error loading tracks
"))))))) ;; Display tracks with pagination (defun display-tracks (track-list) (setf *filtered-tracks* track-list) (setf *current-page* 1) (render-page)) ;; Render current page of tracks (defun render-page () (let ((container (ps:chain document (get-element-by-id "tracks-container"))) (pagination-controls (ps:chain document (get-element-by-id "pagination-controls")))) (when (= (ps:@ *filtered-tracks* length) 0) (when container (setf (ps:@ container inner-h-t-m-l) "
No tracks found. Click \"Scan Library\" to add tracks.
")) (when pagination-controls (setf (ps:@ pagination-controls style display) "none")) (return)) ;; Calculate pagination (let* ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*)))) (start-index (* (- *current-page* 1) *tracks-per-page*)) (end-index (+ start-index *tracks-per-page*)) (tracks-to-show (ps:chain *filtered-tracks* (slice start-index end-index)))) ;; Render tracks for current page (let ((tracks-html (ps:chain tracks-to-show (map (lambda (track) (+ "
" "
" "
" (or (ps:@ track title) "Unknown Title") "
" "
" (or (ps:@ track artist) "Unknown Artist") "
" "
" (or (ps:@ track album) "Unknown Album") "
" "
" "
" "" "" "
" "
"))) (join "")))) (when container (setf (ps:@ container inner-h-t-m-l) tracks-html))) ;; Update pagination controls (let ((page-info (ps:chain document (get-element-by-id "page-info")))) (when page-info (setf (ps:@ page-info text-content) (+ "Page " *current-page* " of " total-pages " (" (ps:@ *filtered-tracks* length) " tracks)")))) (when pagination-controls (setf (ps:@ pagination-controls style display) (if (> total-pages 1) "block" "none")))))) ;; Pagination functions (defun go-to-page (page) (let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*))))) (when (and (>= page 1) (<= page total-pages)) (setf *current-page* page) (render-page)))) (defun previous-page () (when (> *current-page* 1) (setf *current-page* (- *current-page* 1)) (render-page))) (defun next-page () (let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*))))) (when (< *current-page* total-pages) (setf *current-page* (+ *current-page* 1)) (render-page)))) (defun go-to-last-page () (let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*))))) (setf *current-page* total-pages) (render-page))) (defun change-tracks-per-page () (let ((select-el (ps:chain document (get-element-by-id "tracks-per-page")))) (when select-el (setf *tracks-per-page* (parse-int (ps:@ select-el value))) (setf *current-page* 1) (render-page)))) ;; Scan music library (defun scan-library () (let ((status-el (ps:chain document (get-element-by-id "scan-status"))) (scan-btn (ps:chain document (get-element-by-id "scan-library")))) (when status-el (setf (ps:@ status-el text-content) "Scanning...")) (when scan-btn (setf (ps:@ scan-btn disabled) t)) (ps:chain (fetch "/api/asteroid/admin/scan-library" (ps:create :method "POST")) (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (if (= (ps:@ data status) "success") (progn (when status-el (setf (ps:@ status-el text-content) (+ "✅ Added " (ps:getprop data "tracks-added") " tracks"))) (load-tracks)) (when status-el (setf (ps:@ status-el text-content) "❌ Scan failed")))))) (catch (lambda (error) (when status-el (setf (ps:@ status-el text-content) "❌ Scan error")) (ps:chain console (error "Error scanning library:" error)))) (finally (lambda () (when scan-btn (setf (ps:@ scan-btn disabled) nil)) (set-timeout (lambda () (when status-el (setf (ps:@ status-el text-content) ""))) 3000)))))) ;; Filter tracks based on search (defun filter-tracks () (let* ((search-input (ps:chain document (get-element-by-id "track-search"))) (query (when search-input (ps:chain (ps:@ search-input value) (to-lower-case)))) (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))) ;; Sort tracks (defun sort-tracks () (let* ((sort-select (ps:chain document (get-element-by-id "sort-tracks"))) (sort-by (when sort-select (ps:@ sort-select value))) (sorted (ps:chain *tracks* (slice) (sort (lambda (a b) (let ((a-val (or (ps:getprop a sort-by) "")) (b-val (or (ps:getprop b sort-by) ""))) (ps:chain a-val (locale-compare b-val)))))))) (display-tracks sorted))) ;; Initialize audio player (defun init-audio-player () (unless *audio-player* (setf *audio-player* (new (-audio))) (ps:chain *audio-player* (add-event-listener "ended" (lambda () (setf *current-track-id* nil) (update-player-status)))) (ps:chain *audio-player* (add-event-listener "error" (lambda (e) (ps:chain console (error "Audio playback error:" e)) (alert "Error playing audio file"))))) *audio-player*) ;; Player functions (defun play-track (track-id) (unless track-id (alert "Please select a track to play") (return)) (ps:chain (-promise (lambda (resolve reject) (let ((player (init-audio-player))) (setf (ps:@ player src) (+ "/asteroid/tracks/" track-id "/stream")) (ps:chain player (play)) (setf *current-track-id* track-id) (update-player-status) (resolve)))) (catch (lambda (error) (ps:chain console (error "Play error:" error)) (alert "Error playing track"))))) (defun pause-player () (ps:chain (-promise (lambda (resolve reject) (when (and *audio-player* (not (ps:@ *audio-player* paused))) (ps:chain *audio-player* (pause)) (update-player-status)) (resolve))) (catch (lambda (error) (ps:chain console (error "Pause error:" error)))))) (defun stop-player () (ps:chain (-promise (lambda (resolve reject) (when *audio-player* (ps:chain *audio-player* (pause)) (setf (ps:@ *audio-player* current-time) 0) (setf *current-track-id* nil) (update-player-status)) (resolve))) (catch (lambda (error) (ps:chain console (error "Stop error:" error)))))) (defun resume-player () (ps:chain (-promise (lambda (resolve reject) (when (and *audio-player* (ps:@ *audio-player* paused) *current-track-id*) (ps:chain *audio-player* (play)) (update-player-status)) (resolve))) (catch (lambda (error) (ps:chain console (error "Resume error:" error)))))) (defun update-player-status () (ps:chain (fetch "/api/asteroid/player/status") (then (lambda (response) (ps:chain response (json)))) (then (lambda (data) (when (= (ps:@ data status) "success") (let ((player (ps:@ data player)) (state-el (ps:chain document (get-element-by-id "player-state"))) (track-el (ps:chain document (get-element-by-id "current-track")))) (when state-el (setf (ps:@ state-el text-content) (ps:@ player state))) (when track-el (setf (ps:@ track-el text-content) (or (ps:getprop player "current-track") "None"))))))) (catch (lambda (error) (ps:chain console (error "Error updating player status:" error)))))) ;; Utility functions (defun stream-track (track-id) (ps:chain window (open (+ "/asteroid/tracks/" track-id "/stream") "_blank"))) (defun delete-track (track-id) (when (confirm "Are you sure you want to delete this track?") (alert "Track deletion not yet implemented"))) (defun copy-files () (ps:chain (fetch "/admin/copy-files") (then (lambda (response) (ps:chain response (json)))) (then (lambda (data) (if (= (ps:@ data status) "success") (progn (alert (ps:@ data message)) (load-tracks)) (alert (+ "Error: " (ps:@ data message)))))) (catch (lambda (error) (ps:chain console (error "Error copying files:" error)) (alert "Failed to copy files"))))) (defun open-incoming-folder () (alert "Copy your MP3 files to: /home/glenn/Projects/Code/asteroid/music/incoming/\n\nThen click \"Copy Files to Library\" to add them to your music collection.")) ;; ======================================== ;; Stream Queue Management ;; ======================================== ;; Load current stream queue (defun load-stream-queue () (ps:chain (fetch "/api/asteroid/stream/queue") (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (when (= (ps:@ data status) "success") (setf *stream-queue* (or (ps:@ data queue) (array))) (display-stream-queue))))) (catch (lambda (error) (ps:chain console (error "Error loading stream queue:" error)) (let ((container (ps:chain document (get-element-by-id "stream-queue-container")))) (when container (setf (ps:@ container inner-h-t-m-l) "
Error loading queue
"))))))) ;; Display stream queue (defun display-stream-queue () (let ((container (ps:chain document (get-element-by-id "stream-queue-container")))) (when container (if (= (ps:@ *stream-queue* length) 0) (setf (ps:@ container inner-h-t-m-l) "
Queue is empty. Add tracks below.
") (let ((html "
")) (ps:chain *stream-queue* (for-each (lambda (item index) (when item (let ((is-first (= index 0)) (is-last (= index (- (ps:@ *stream-queue* length) 1)))) (setf html (+ html "
" "" (+ index 1) "" "
" "
" (or (ps:@ item title) "Unknown") "
" "
" (or (ps:@ item artist) "Unknown Artist") "
" "
" "
" "" "" "" "
" "
"))))))) (setf html (+ html "
")) (setf (ps:@ container inner-h-t-m-l) html)))))) ;; Clear stream queue (defun clear-stream-queue () (unless (confirm "Clear the entire stream queue? This will stop playback until new tracks are added.") (return)) (ps:chain (fetch "/api/asteroid/stream/queue/clear" (ps:create :method "POST")) (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (if (= (ps:@ data status) "success") (progn (alert "Queue cleared successfully") (load-stream-queue)) (alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error"))))))) (catch (lambda (error) (ps:chain console (error "Error clearing queue:" error)) (alert "Error clearing queue"))))) ;; Load queue from M3U file (defun load-queue-from-m3u () (unless (confirm "Load queue from stream-queue.m3u file? This will replace the current queue.") (return)) (ps:chain (fetch "/api/asteroid/stream/queue/load-m3u" (ps:create :method "POST")) (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (if (= (ps:@ data status) "success") (progn (alert (+ "Successfully loaded " (ps:@ data count) " tracks from M3U file!")) (load-stream-queue)) (alert (+ "Error loading from M3U: " (or (ps:@ data message) "Unknown error"))))))) (catch (lambda (error) (ps:chain console (error "Error loading from M3U:" error)) (alert (+ "Error loading from M3U: " (ps:@ error message))))))) ;; Move track up in queue (defun move-track-up (index) (when (= index 0) (return)) ;; Swap with previous track (let ((new-queue (ps:chain *stream-queue* (slice)))) (let ((temp (ps:getprop new-queue (- index 1)))) (setf (ps:getprop new-queue (- index 1)) (ps:getprop new-queue index)) (setf (ps:getprop new-queue index) temp)) (reorder-queue new-queue))) ;; Move track down in queue (defun move-track-down (index) (when (= index (- (ps:@ *stream-queue* length) 1)) (return)) ;; Swap with next track (let ((new-queue (ps:chain *stream-queue* (slice)))) (let ((temp (ps:getprop new-queue index))) (setf (ps:getprop new-queue index) (ps:getprop new-queue (+ index 1))) (setf (ps:getprop new-queue (+ index 1)) temp)) (reorder-queue new-queue))) ;; Reorder the queue (defun reorder-queue (new-queue) (let ((track-ids (ps:chain new-queue (map (lambda (track) (ps:@ track id))) (join ",")))) (ps:chain (fetch (+ "/api/asteroid/stream/queue/reorder?track-ids=" track-ids) (ps:create :method "POST")) (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (if (= (ps:@ data status) "success") (load-stream-queue) (alert (+ "Error reordering queue: " (or (ps:@ data message) "Unknown error"))))))) (catch (lambda (error) (ps:chain console (error "Error reordering queue:" error)) (alert "Error reordering queue")))))) ;; Remove track from queue (defun remove-from-queue (track-id) (ps:chain (fetch "/api/asteroid/stream/queue/remove" (ps:create :method "POST" :headers (ps:create "Content-Type" "application/x-www-form-urlencoded") :body (+ "track-id=" track-id))) (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (if (= (ps:@ data status) "success") (load-stream-queue) (alert (+ "Error removing track: " (or (ps:@ data message) "Unknown error"))))))) (catch (lambda (error) (ps:chain console (error "Error removing track:" error)) (alert "Error removing track"))))) ;; Add track to queue (defun add-to-queue (track-id &optional (position "end") (show-notification t)) (ps:chain (fetch "/api/asteroid/stream/queue/add" (ps:create :method "POST" :headers (ps:create "Content-Type" "application/x-www-form-urlencoded") :body (+ "track-id=" track-id "&position=" position))) (then (lambda (response) (ps:chain response (json)))) (then (lambda (result) (let ((data (or (ps:@ result data) result))) (if (= (ps:@ data status) "success") (progn ;; Only reload queue if we're in the queue management section (let ((queue-container (ps:chain document (get-element-by-id "stream-queue-container")))) (when (and queue-container (not (= (ps:@ queue-container offset-parent) nil))) (load-stream-queue))) ;; Show brief success notification (when show-notification (show-toast "✓ Added to queue")) t) (progn (alert (+ "Error adding track: " (or (ps:@ data message) "Unknown error"))) nil))))) (catch (lambda (error) (ps:chain console (error "Error adding track:" error)) (alert "Error adding track") nil)))) ;; Simple toast notification (defun show-toast (message) (let ((toast (ps:chain document (create-element "div")))) (setf (ps:@ toast text-content) message) (setf (ps:@ toast style css-text) "position: fixed; bottom: 20px; right: 20px; background: #00ff00; color: #000; padding: 12px 20px; border-radius: 4px; font-weight: bold; z-index: 10000; animation: slideIn 0.3s ease-out;") (ps:chain document body (append-child toast)) (set-timeout (lambda () (setf (ps:@ toast style opacity) "0") (setf (ps:@ toast style transition) "opacity 0.3s") (set-timeout (lambda () (ps:chain toast (remove))) 300)) 2000))) ;; Add random tracks to queue (defun add-random-tracks () (when (= (ps:@ *tracks* length) 0) (alert "No tracks available. Please scan the library first.") (return)) (let* ((count 10) (shuffled (ps:chain *tracks* (slice) (sort (lambda () (- (ps:chain -math (random)) 0.5))))) (selected (ps:chain shuffled (slice 0 (ps:chain -math (min count (ps:@ *tracks* length))))))) (ps:chain selected (for-each (lambda (track) (add-to-queue (ps:@ track id) "end" nil)))) (show-toast (+ "✓ Added " (ps:@ selected length) " random tracks to queue")))) ;; Search tracks for adding to queue (defun search-tracks-for-queue (event) (clear-timeout *queue-search-timeout*) (let ((query (ps:chain (ps:@ event target value) (to-lower-case)))) (when (< (ps:@ query length) 2) (let ((results-container (ps:chain document (get-element-by-id "queue-track-results")))) (when results-container (setf (ps:@ results-container inner-h-t-m-l) ""))) (return)) (setf *queue-search-timeout* (set-timeout (lambda () (let ((results (ps:chain *tracks* (filter (lambda (track) (or (and (ps:@ track title) (ps:chain (ps:@ track title) (to-lower-case) (includes query))) (and (ps:@ track artist) (ps:chain (ps:@ track artist) (to-lower-case) (includes query))) (and (ps:@ track album) (ps:chain (ps:@ track album) (to-lower-case) (includes query)))))) (slice 0 20)))) (display-queue-search-results results))) 300)))) ;; Display search results for queue (defun display-queue-search-results (results) (let ((container (ps:chain document (get-element-by-id "queue-track-results")))) (when container (if (= (ps:@ results length) 0) (setf (ps:@ container inner-h-t-m-l) "
No tracks found
") (let ((html "
")) (ps:chain results (for-each (lambda (track) (setf html (+ html "
" "
" "
" (or (ps:@ track title) "Unknown") "
" "
" (or (ps:@ track artist) "Unknown") " - " (or (ps:@ track album) "Unknown Album") "
" "
" "
" "" "" "
" "
"))))) (setf html (+ html "
")) (setf (ps:@ container inner-h-t-m-l) html)))))) ;; Make functions globally accessible for onclick handlers (setf (ps:@ window go-to-page) go-to-page) (setf (ps:@ window previous-page) previous-page) (setf (ps:@ window next-page) next-page) (setf (ps:@ window go-to-last-page) go-to-last-page) (setf (ps:@ window change-tracks-per-page) change-tracks-per-page) (setf (ps:@ window stream-track) stream-track) (setf (ps:@ window delete-track) delete-track) (setf (ps:@ window move-track-up) move-track-up) (setf (ps:@ window move-track-down) move-track-down) (setf (ps:@ window remove-from-queue) remove-from-queue) (setf (ps:@ window add-to-queue) add-to-queue) )) "Compiled JavaScript for admin dashboard - generated at load time") (defun generate-admin-js () "Return the pre-compiled JavaScript for admin dashboard" *admin-js*)