Compare commits
7 Commits
5163a577b3
...
0805d8e9fa
| Author | SHA1 | Date |
|---|---|---|
|
|
0805d8e9fa | |
|
|
c53ee7d82a | |
|
|
dede6fef73 | |
|
|
aeea81fbbf | |
|
|
8f7ce8bc96 | |
|
|
af51f7d6e9 | |
|
|
f68c85f0cd |
16
TODO.org
16
TODO.org
|
|
@ -1,4 +1,4 @@
|
|||
** [#C] Rundown to Launch. Still to do:
|
||||
* Rundown to Launch. Still to do:
|
||||
|
||||
* Setup asteroid.radio server at Hetzner [7/7]
|
||||
- [X] Provision a VPS
|
||||
|
|
@ -25,23 +25,23 @@
|
|||
1) [X] Liquidsoap is exposing its management console via telnet on the exterior network interface of b612.asteroid.radio
|
||||
2) [X] icecast is also binding the external interface on b612, which it
|
||||
should not be. HAproxy is there to mediate this flow.
|
||||
3) [X] We're still on the built in i-lambdalite database
|
||||
3) [ ] We're still on the built in i-lambdalite database
|
||||
4) [X] The templates still advertise the default administrator password,
|
||||
which is no bueno.
|
||||
5) [X] We need to work out the TLS situation with letsencrypt, and
|
||||
5) [ ] We need to work out the TLS situation with letsencrypt, and
|
||||
integrate it into HAproxy.
|
||||
|
||||
6) [ ] The administrative interface should be beefed up.
|
||||
6.1) [X] Deactivate users
|
||||
6.2) [X] Change user access permissions
|
||||
6.1) [ ] Deactivate users
|
||||
6.2) [ ] Change user access permissions
|
||||
6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c
|
||||
|
||||
7) [ ] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused.
|
||||
8) [ ] User profile pages should probably be fleshed out.
|
||||
9) [ ] the stream management features aren't there for Admins or DJs.
|
||||
10) [X] The "Scan Library" feature is not working in the main branch
|
||||
11) [X] The player widget should be styled so it fits the site theme on systems running 'light' thmes.
|
||||
12) [X] ensure each info field 'Listeners: ..' &c has only one instance per page.
|
||||
10) [ ] The "Scan Library" feature is not working in the main branch
|
||||
11) [ ] The player widget should be styled so it fits the site theme on systems running 'light' thmes.
|
||||
12) [ ] ensure each info field 'Listeners: ..' &c has only one instance per page.
|
||||
|
||||
* Server runtime configuration [0/1]
|
||||
- [ ] parameterize all configuration for runtime loading [0/2]
|
||||
|
|
|
|||
23
asteroid.asd
23
asteroid.asd
|
|
@ -11,11 +11,15 @@
|
|||
:class "radiance:virtual-module"
|
||||
:depends-on (:slynk
|
||||
:lparallel
|
||||
:alexandria
|
||||
:cl-json
|
||||
:radiance
|
||||
:i-log4cl
|
||||
:r-clip
|
||||
:r-simple-rate
|
||||
:r-simple-profile
|
||||
:lass
|
||||
:parenscript
|
||||
:cl-json
|
||||
:alexandria
|
||||
:local-time
|
||||
:taglib
|
||||
:ironclad
|
||||
|
|
@ -24,12 +28,7 @@
|
|||
:bordeaux-threads
|
||||
:drakma
|
||||
;; radiance interfaces
|
||||
:i-log4cl
|
||||
;; :i-postmodern
|
||||
:r-clip
|
||||
:r-data-model
|
||||
:r-simple-profile
|
||||
:r-simple-rate
|
||||
(:interface :auth)
|
||||
(:interface :database)
|
||||
(:interface :user))
|
||||
|
|
@ -41,8 +40,16 @@
|
|||
(:file "conditions")
|
||||
(:file "database")
|
||||
(:file "template-utils")
|
||||
(:file "parenscript-utils")
|
||||
(:module :parenscript
|
||||
:components ((:file "spectrum-analyzer")))
|
||||
:components ((:file "recently-played")
|
||||
(:file "auth-ui")
|
||||
(:file "front-page")
|
||||
(:file "profile")
|
||||
(:file "users")
|
||||
(:file "admin")
|
||||
(:file "player")
|
||||
(:file "spectrum-analyzer")))
|
||||
(:file "stream-media")
|
||||
(:file "user-management")
|
||||
(:file "playlist-management")
|
||||
|
|
|
|||
110
asteroid.lisp
110
asteroid.lisp
|
|
@ -86,6 +86,26 @@
|
|||
("message" . "Library scan completed")
|
||||
("tracks-added" . ,tracks-added))))))
|
||||
|
||||
(define-api asteroid/recently-played () ()
|
||||
"Get the last 3 played tracks with MusicBrainz links"
|
||||
(with-error-handling
|
||||
(let ((tracks (get-recently-played)))
|
||||
(api-output `(("status" . "success")
|
||||
("tracks" . ,(mapcar (lambda (track)
|
||||
(let* ((title (getf track :title))
|
||||
(timestamp (getf track :timestamp))
|
||||
(unix-timestamp (universal-time-to-unix timestamp))
|
||||
(parsed (parse-track-title title))
|
||||
(artist (getf parsed :artist))
|
||||
(song (getf parsed :song))
|
||||
(search-url (generate-music-search-url artist song)))
|
||||
`(("title" . ,title)
|
||||
("artist" . ,artist)
|
||||
("song" . ,song)
|
||||
("timestamp" . ,unix-timestamp)
|
||||
("search_url" . ,search-url))))
|
||||
tracks)))))))
|
||||
|
||||
(define-api asteroid/admin/tracks () ()
|
||||
"API endpoint to view all tracks in database"
|
||||
(require-authentication)
|
||||
|
|
@ -486,8 +506,8 @@
|
|||
"Main front page"
|
||||
(clip:process-to-string
|
||||
(load-template "front-page")
|
||||
:title "🎵 ASTEROID RADIO 🎵"
|
||||
:station-name "🎵 ASTEROID RADIO 🎵"
|
||||
:title "ASTEROID RADIO"
|
||||
:station-name "ASTEROID RADIO"
|
||||
:status-message "🟢 LIVE - Broadcasting asteroid music for hackers"
|
||||
:listeners "0"
|
||||
:stream-quality "128kbps MP3"
|
||||
|
|
@ -505,15 +525,15 @@
|
|||
"Frameset wrapper with persistent audio player"
|
||||
(clip:process-to-string
|
||||
(load-template "frameset-wrapper")
|
||||
:title "🎵 ASTEROID RADIO 🎵"))
|
||||
:title "ASTEROID RADIO"))
|
||||
|
||||
;; Content frame - front page content without player
|
||||
(define-page front-page-content #@"/content" ()
|
||||
"Front page content (displayed in content frame)"
|
||||
(clip:process-to-string
|
||||
(load-template "front-page-content")
|
||||
:title "🎵 ASTEROID RADIO 🎵"
|
||||
:station-name "🎵 ASTEROID RADIO 🎵"
|
||||
:title "ASTEROID RADIO"
|
||||
:station-name "ASTEROID RADIO"
|
||||
:status-message "🟢 LIVE - Broadcasting asteroid music for hackers"
|
||||
:listeners "0"
|
||||
:stream-quality "128kbps MP3"
|
||||
|
|
@ -533,9 +553,83 @@
|
|||
:default-stream-encoding "audio/aac"))
|
||||
|
||||
;; Configure static file serving for other files
|
||||
;; BUT exclude ParenScript-compiled JS files
|
||||
(define-page static #@"/static/(.*)" (:uri-groups (path))
|
||||
(serve-file (merge-pathnames (format nil "static/~a" path)
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
(cond
|
||||
;; Serve ParenScript-compiled auth-ui.js
|
||||
((string= path "js/auth-ui.js")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-auth-ui-js)))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating auth-ui.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled front-page.js
|
||||
((string= path "js/front-page.js")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-front-page-js)))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating front-page.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled profile.js
|
||||
((string= path "js/profile.js")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-profile-js)))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating profile.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled users.js
|
||||
((string= path "js/users.js")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-users-js)))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating users.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled admin.js
|
||||
((string= path "js/admin.js")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-admin-js)))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating admin.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled player.js
|
||||
((string= path "js/player.js")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-player-js)))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating player.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled recently-played.js
|
||||
((string= path "js/recently-played.js")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-recently-played-js)))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating recently-played.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve regular static file
|
||||
(t
|
||||
(serve-file (merge-pathnames (format nil "static/~a" path)
|
||||
(asdf:system-source-directory :asteroid))))))
|
||||
|
||||
;; Status check functions
|
||||
(defun check-icecast-status ()
|
||||
|
|
@ -586,7 +680,7 @@
|
|||
(require-authentication)
|
||||
(clip:process-to-string
|
||||
(load-template "users")
|
||||
:title "🎵 ASTEROID RADIO - User Management"))
|
||||
:title "ASTEROID RADIO - User Management"))
|
||||
|
||||
;; User Profile page (requires authentication)
|
||||
(define-page user-profile #@"/profile" ()
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
(define-page logout #@"/logout" ()
|
||||
"Handle user logout"
|
||||
(setf (session:field "user-id") nil)
|
||||
(radiance:redirect "/asteroid/"))
|
||||
(radiance:redirect "/"))
|
||||
|
||||
;; API: Get all users (admin only)
|
||||
(define-api asteroid/users () ()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
;; -*-lisp-*-
|
||||
|
||||
(unless *load-pathname*
|
||||
(error "Please LOAD this file."))
|
||||
|
||||
(defpackage #:asteroid-bootstrap
|
||||
(:nicknames #:ab)
|
||||
(:use #:cl)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,13 @@
|
|||
(error-stream-type condition)
|
||||
(error-message condition)))))
|
||||
|
||||
(define-condition stream-connectivity-error (asteroid-error)
|
||||
()
|
||||
(:documentation "Signaled when stream connectivity fails but plain text response is needed")
|
||||
(:report (lambda (condition stream)
|
||||
(format stream "Stream connectivity failed: ~a"
|
||||
(error-message condition)))))
|
||||
|
||||
;;; Error Handling Macros
|
||||
|
||||
(defmacro with-error-handling (&body body)
|
||||
|
|
@ -144,6 +151,10 @@
|
|||
("message" . ,(error-message e)))
|
||||
:message (error-message e)
|
||||
:status 500))
|
||||
(stream-connectivity-error (e)
|
||||
;; For endpoints that need plain text responses (like now-playing-inline)
|
||||
(setf (header "Content-Type") "text/plain")
|
||||
"Stream Offline")
|
||||
(error (e)
|
||||
(format t "Unexpected error: ~a~%" e)
|
||||
(api-output `(("status" . "error")
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@
|
|||
<header-timeout>15</header-timeout>
|
||||
<source-timeout>10</source-timeout>
|
||||
<burst-on-connect>1</burst-on-connect>
|
||||
<!-- Reduced from 65535 to minimize buffer accumulation during pause -->
|
||||
<burst-size>8192</burst-size>
|
||||
<burst-size>65535</burst-size>
|
||||
</limits>
|
||||
|
||||
<authentication>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - API Endpoints Reference
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - API Reference
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Current Interfaces
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - Development Guide
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Development Setup
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - Docker Streaming Setup
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Docker Streaming Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - Installation Guide
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Installation Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,367 @@
|
|||
#+TITLE: ParenScript Conversion Experiment
|
||||
#+AUTHOR: Glenn
|
||||
#+DATE: 2025-11-06
|
||||
|
||||
* Overview
|
||||
|
||||
This branch experiments with converting all JavaScript files to ParenScript, allowing us to write client-side code in Common Lisp that compiles to JavaScript.
|
||||
|
||||
* Goals
|
||||
|
||||
- Replace all =.js= files with ParenScript equivalents
|
||||
- Maintain same functionality
|
||||
- Improve code maintainability by using one language (Lisp) for both frontend and backend
|
||||
- Take advantage of Lisp macros for client-side code generation
|
||||
|
||||
* Current JavaScript Files
|
||||
|
||||
- =static/js/admin.js= - Admin dashboard functionality
|
||||
- =static/js/auth-ui.js= - Authentication UI
|
||||
- =static/js/front-page.js= - Front page interactions
|
||||
- =static/js/player.js= - Audio player controls
|
||||
- =static/js/profile.js= - User profile page
|
||||
- =static/js/users.js= - User management
|
||||
|
||||
* Implementation Plan
|
||||
|
||||
** Phase 1: Setup [DONE]
|
||||
- [X] Add ParenScript dependency to =asteroid.asd=
|
||||
- [X] Create =parenscript-utils.lisp= with helper functions
|
||||
- [X] Create experimental branch
|
||||
|
||||
** Phase 2: Convert Simple Files First
|
||||
- [X] Convert =auth-ui.js= (smallest, simplest) - COMPLETE ✅
|
||||
- [X] Convert =front-page.js= (stream quality, now playing, pop-out, frameset) - COMPLETE ✅
|
||||
- [X] Convert =profile.js= (user profile, stats, history) - COMPLETE ✅
|
||||
- [X] Convert =users.js= (user management, admin) - COMPLETE ✅
|
||||
|
||||
** Phase 3: Convert Complex Files
|
||||
- [X] Convert =player.js= (audio player logic) - COMPLETE ✅
|
||||
- [X] Convert =admin.js= (queue management, track controls) - COMPLETE ✅
|
||||
|
||||
** Phase 4: Testing & Refinement
|
||||
- [ ] Test all functionality
|
||||
- [ ] Optimize generated JavaScript
|
||||
- [ ] Document ParenScript patterns used
|
||||
|
||||
* Benefits
|
||||
|
||||
** Code Reuse
|
||||
- Share utility functions between frontend and backend
|
||||
- Use same data structures and validation logic
|
||||
|
||||
** Macros
|
||||
- Create domain-specific macros for common UI patterns
|
||||
- Generate repetitive JavaScript code programmatically
|
||||
|
||||
** Type Safety
|
||||
- Catch more errors at compile time
|
||||
- Better IDE support with Lisp tooling
|
||||
|
||||
** Maintainability
|
||||
- Single language for entire stack
|
||||
- Easier refactoring across frontend/backend boundary
|
||||
|
||||
* ParenScript Resources
|
||||
|
||||
- [[https://parenscript.common-lisp.dev/][ParenScript Documentation]]
|
||||
- [[https://gitlab.common-lisp.net/parenscript/parenscript][ParenScript GitLab Repository]]
|
||||
- [[https://parenscript.common-lisp.dev/reference.html][ParenScript Reference Manual]]
|
||||
|
||||
* Lessons Learned
|
||||
|
||||
** auth-ui.js Conversion (2025-11-06)
|
||||
|
||||
*** Challenge 1: Route Precedence
|
||||
*Problem:* Radiance routes are matched in load order, not definition order. The general static file route (=/static/(.*)=) was intercepting our specific ParenScript route.
|
||||
|
||||
*Solution:* Intercept the static file route and check if path is =js/auth-ui.js=. If yes, serve ParenScript; otherwise serve regular file.
|
||||
|
||||
*** Challenge 2: Async/Await Syntax
|
||||
*Problem:* ParenScript doesn't support =async/await= syntax. Using =(async lambda ...)= generated invalid JavaScript.
|
||||
|
||||
*Solution:* Use promise chains with =.then()= instead of async/await.
|
||||
|
||||
*** Challenge 3: Compile Time vs Runtime
|
||||
*Problem:* ParenScript compiler (=ps:ps*=) isn't available in saved binary at runtime.
|
||||
|
||||
*Solution:* Compile JavaScript at load time and store in a parameter. The function just returns the pre-compiled string.
|
||||
|
||||
*** Success Metrics
|
||||
- JavaScript compiles correctly (1386 characters)
|
||||
- No browser console errors
|
||||
- Auth UI works perfectly (show/hide elements based on login status)
|
||||
- Generated code is readable and maintainable
|
||||
|
||||
*** Key Patterns
|
||||
|
||||
*Compile at load time:*
|
||||
#+BEGIN_EXAMPLE
|
||||
(defparameter *my-js*
|
||||
(ps:ps* '(progn ...)))
|
||||
|
||||
(defun generate-my-js ()
|
||||
*my-js*)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*Promise chains instead of async/await:*
|
||||
#+BEGIN_EXAMPLE
|
||||
(ps:chain (fetch url)
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (data) (process data)))
|
||||
(catch (lambda (error) (handle error))))
|
||||
#+END_EXAMPLE
|
||||
|
||||
*Intercept static route:*
|
||||
#+BEGIN_EXAMPLE
|
||||
(define-page static #@"/static/(.*)" (:uri-groups (path))
|
||||
(if (string= path "js/my-file.js")
|
||||
(serve-parenscript)
|
||||
(serve-static-file)))
|
||||
#+END_EXAMPLE
|
||||
|
||||
* Notes
|
||||
|
||||
This is an EXPERIMENTAL branch. The goal is to evaluate ParenScript for this project, not to immediately replace all JavaScript.
|
||||
|
||||
If successful, we can merge incrementally, one file at a time.
|
||||
|
||||
** Conversion Progress
|
||||
- *auth-ui.js* (2025-11-06): Successfully converted. 1386 chars. All functionality working.
|
||||
- *front-page.js* (2025-11-06): Successfully converted. 6900 chars. Stream quality, now playing, pop-out player, frameset mode all working.
|
||||
- *profile.js* (2025-11-06): Successfully converted. Profile data, listening stats, recent tracks, top artists, password change all working.
|
||||
- *users.js* (2025-11-06): Successfully converted. User stats, user list, role changes, activate/deactivate, create user all working.
|
||||
|
||||
** front-page.js Conversion Notes
|
||||
|
||||
This was a more complex file with multiple features. Key learnings:
|
||||
|
||||
*** Global Variables
|
||||
ParenScript uses =(defvar *variable-name* value)= for global variables:
|
||||
#+BEGIN_EXAMPLE
|
||||
(defvar *popout-window* nil)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** String Concatenation
|
||||
Use =+= operator for string concatenation:
|
||||
#+BEGIN_EXAMPLE
|
||||
(+ "width=" width ",height=" height)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Conditional Logic
|
||||
Use =cond= for multiple conditions in route interception:
|
||||
#+BEGIN_EXAMPLE
|
||||
(cond
|
||||
((string= path "js/auth-ui.js") ...)
|
||||
((string= path "js/front-page.js") ...)
|
||||
(t ...))
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Object Property Access
|
||||
Use =ps:getprop= for dynamic property access:
|
||||
#+BEGIN_EXAMPLE
|
||||
(ps:getprop config encoding) ; config[encoding]
|
||||
#+END_EXAMPLE
|
||||
|
||||
All features tested and working:
|
||||
- Stream quality selector changes stream correctly
|
||||
- Now playing updates every 10 seconds
|
||||
- Pop-out player functionality works
|
||||
- Frameset mode toggle works
|
||||
- Auto-reconnect on stream errors works
|
||||
|
||||
** profile.js and users.js Conversion Notes
|
||||
|
||||
*** Modulo Operator
|
||||
ParenScript doesn't support =%= for modulo. Use =rem= (remainder) instead:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(% seconds 3600)
|
||||
|
||||
;; CORRECT:
|
||||
(rem seconds 3600)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Property Access with Hyphens
|
||||
For properties with hyphens (like ="last-login"=), use =ps:getprop=:
|
||||
#+BEGIN_EXAMPLE
|
||||
(ps:getprop user "last-login")
|
||||
;; Instead of (ps:@ user last-login)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Template Literals in HTML Generation
|
||||
Build HTML strings with =+= concatenation:
|
||||
#+BEGIN_EXAMPLE
|
||||
(+ "<td>" (ps:@ user username) "</td>"
|
||||
"<td>" (ps:@ user email) "</td>")
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Conditional Attributes
|
||||
Use =if= expressions inline for conditional HTML attributes:
|
||||
#+BEGIN_EXAMPLE
|
||||
(+ "<option value=\"listener\" "
|
||||
(if (= (ps:@ user role) "listener") "selected" "")
|
||||
">Listener</option>")
|
||||
#+END_EXAMPLE
|
||||
|
||||
** player.js Conversion Notes (2025-11-07)
|
||||
|
||||
This was the most challenging conversion due to complex ParenScript compilation errors and server-side error handling issues.
|
||||
|
||||
*** Challenge 1: PUSH Macro Conflict
|
||||
*Problem:* Using =(push item array)= in ParenScript context caused "Error while parsing arguments to DEFMACRO PUSH" because ParenScript doesn't have a PUSH macro like Common Lisp.
|
||||
|
||||
*Solution:* Use array index assignment instead:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(push item *play-queue*)
|
||||
|
||||
;; CORRECT (what I implemented):
|
||||
(setf (aref *play-queue* (ps:@ *play-queue* length)) item)
|
||||
|
||||
;; ALTERNATIVE (more idiomatic, could be used instead):
|
||||
(ps:chain *play-queue* (push item))
|
||||
;; This compiles to: playQueue.push(item);
|
||||
#+END_EXAMPLE
|
||||
|
||||
*Note:* According to the ParenScript reference manual (=/home/glenn/Projects/Code/parenscript/docs/reference.html=, lines 672, 745-750), the =CHAIN= macro is designed to chain together accessors and function calls. This means =(ps:chain array (push item))= is actually valid ParenScript and would call the JavaScript =push= method. Our current implementation using =setf= and =aref= works correctly but is more verbose. The =chain= approach would be more idiomatic JavaScript.
|
||||
|
||||
*** Challenge 2: != Operator
|
||||
*Problem:* ParenScript translates =!=== to a function called =bangequals= which doesn't exist, causing "bangequals is not defined" runtime error.
|
||||
|
||||
*Solution:* Use =(not (== ...))= instead:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(!= value expected)
|
||||
|
||||
;; CORRECT:
|
||||
(not (== value expected))
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Challenge 3: Error Variable Names in handler-case
|
||||
*Problem:* ANY variable name used in error handler clauses (=e=, =err=, =connection-err=, =condition-object=) was being interpreted as an undefined function call, causing errors like "The function ASTEROID::ERR is undefined" or "The function COMMON-LISP:CONDITION is undefined".
|
||||
|
||||
*Root Cause:* When error variables were used in =format= statements within =handler-case= error handlers, something in the error handling chain was trying to evaluate them as function calls instead of variables.
|
||||
|
||||
*Solution:* Remove error variable bindings entirely and don't try to print the error object:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(handler-case
|
||||
(risky-operation)
|
||||
(error (err)
|
||||
(format t "Error: ~a~%" err) ; err gets evaluated as function!
|
||||
nil))
|
||||
|
||||
;; CORRECT:
|
||||
(handler-case
|
||||
(risky-operation)
|
||||
(error ()
|
||||
(format t "Error occurred~%") ; No variable to evaluate
|
||||
nil))
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Challenge 4: Parenthesis Imbalance in handler-case
|
||||
*Problem:* Using =(condition (var) ...)= as error handler type caused "end of file" errors because =condition= is not a valid error type in =handler-case=, and =t= is also invalid.
|
||||
|
||||
*Solution:* Use =error= as the catch-all error type:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(handler-case
|
||||
(risky-operation)
|
||||
(t () ...)) ; t is not valid
|
||||
(condition () ...)) ; condition needs to be a type
|
||||
|
||||
;; CORRECT:
|
||||
(handler-case
|
||||
(risky-operation)
|
||||
(error () ; error is the correct catch-all type
|
||||
(format t "Error occurred~%")
|
||||
nil))
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Challenge 5: let* Structure with handler-case
|
||||
*Problem:* When adding =handler-case= with a =progn= wrapper, the =let*= binding was closed before the =when= block that used its variables, causing "end of file" errors.
|
||||
|
||||
*Solution:* Keep =let*= as the main form and put all logic inside it:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(handler-case
|
||||
(progn
|
||||
(let* ((url "...")
|
||||
(response (fetch url)))
|
||||
(when response ...))) ; let* closed too early!
|
||||
(error () nil))
|
||||
|
||||
;; CORRECT:
|
||||
(let* ((url "...")
|
||||
(response (fetch url)))
|
||||
(when response
|
||||
...)) ; All logic inside let*
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Challenge 6: Icecast Listener Count Aggregation
|
||||
*Problem:* Function only checked =/asteroid.mp3= mount point, missing listeners on =/asteroid.aac= and =/asteroid-low.mp3= streams.
|
||||
|
||||
*Solution:* Modified =icecast-now-playing= function in =frontend-partials.lisp= to loop through all three mount points and aggregate listener counts:
|
||||
#+BEGIN_EXAMPLE
|
||||
(let ((total-listeners 0))
|
||||
(dolist (mount '("/asteroid\\.mp3" "/asteroid\\.aac" "/asteroid-low\\.mp3"))
|
||||
(let ((match-pos (cl-ppcre:scan (format nil "<source mount=\"~a\">" mount) xml-string)))
|
||||
(when match-pos
|
||||
(let* ((source-section (subseq xml-string match-pos ...))
|
||||
(listenersp (cl-ppcre:all-matches "<listeners>" source-section)))
|
||||
(when listenersp
|
||||
(let ((count (parse-integer (cl-ppcre:regex-replace-all
|
||||
".*<listeners>(.*?)</listeners>.*"
|
||||
source-section "\\1")
|
||||
:junk-allowed t)))
|
||||
(incf total-listeners count)))))))
|
||||
total-listeners)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*Additional Changes to frontend-partials.lisp:*
|
||||
- Fixed stray =^= character in =(in-package :asteroid)= form
|
||||
- Added error handler to =define-api asteroid/partial/now-playing= endpoint to catch errors gracefully
|
||||
- Added debug logging to track Icecast stats fetching and parsing
|
||||
- Removed problematic error variable usage in error handlers (see Challenge 3)
|
||||
|
||||
*** Success Metrics
|
||||
- player.lisp compiles without errors
|
||||
- All player functionality works (play, pause, queue, playlists)
|
||||
- Now Playing section displays correctly with live track information
|
||||
- Listener count aggregates across all three streams
|
||||
- No JavaScript runtime errors in browser console
|
||||
- No server-side Lisp errors
|
||||
|
||||
** Summary of Key ParenScript Patterns
|
||||
|
||||
1. *Async/Await*: Use promise chains with =.then()= instead
|
||||
2. *Modulo*: Use =rem= instead of =%=
|
||||
3. *Global Variables*: Use =defvar= with asterisks: =*variable-name*=
|
||||
4. *String Concatenation*: Use =+= operator
|
||||
5. *Property Access*: Use =ps:getprop= for dynamic/hyphenated properties
|
||||
6. *Object Creation*: Use =ps:create= with keyword arguments
|
||||
7. *Array Methods*: Use =ps:chain= for method chaining
|
||||
8. *Route Interception*: Use =cond= in static route handler
|
||||
9. *Compile at Load Time*: Store compiled JS in =defparameter=
|
||||
10. *Return Pre-compiled String*: Function just returns the parameter value
|
||||
11. *Array Push*: Use =(setf (aref array (ps:@ array length)) item)= instead of =push=
|
||||
12. *Not Equal*: Use =(not (== ...))= instead of =!==
|
||||
13. *Error Handlers*: Don't use error variable names in =format= statements; use =error= type for catch-all
|
||||
14. *Parenthesis Balance*: Keep =let*= as main form, don't wrap with =progn= inside =handler-case=
|
||||
|
||||
** Final Status (2025-11-07)
|
||||
|
||||
✅ *ALL JAVASCRIPT FILES SUCCESSFULLY CONVERTED TO PARENSCRIPT*
|
||||
|
||||
The ParenScript migration is complete! All client-side JavaScript is now generated from Common Lisp code. The application maintains 100% of its original functionality while using a single language (Lisp) for both frontend and backend.
|
||||
|
||||
Files converted:
|
||||
- =auth-ui.js= → =parenscript/auth-ui.lisp=
|
||||
- =front-page.js= → =parenscript/front-page.lisp=
|
||||
- =profile.js= → =parenscript/profile.lisp=
|
||||
- =users.js= → =parenscript/users.lisp=
|
||||
- =player.js= → =parenscript/player.lisp=
|
||||
- =admin.js= → =parenscript/admin.lisp=
|
||||
|
||||
The experiment was a success. We can now maintain the entire Asteroid Radio codebase in Common Lisp.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Playlist System - Complete (MVP)
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: PostgreSQL Setup for Asteroid Radio
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - Project Development History
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
#+DESCRIPTION: Comprehensive history of the Asteroid Radio project from inception to present
|
||||
|
||||
* Project Overview
|
||||
|
|
@ -11,8 +11,7 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
- *Backend*: Common Lisp (SBCL), Radiance web framework
|
||||
- *Streaming*: Icecast2, Liquidsoap
|
||||
- *Database*: PostgreSQL (configured, ready for migration)
|
||||
- *Frontend*: HTML5, JavaScript, Parenscript, CLIP templating, LASS (CSS in Lisp)
|
||||
- *Audio Visualization*: Web Audio API, Canvas
|
||||
- *Frontend*: HTML5, JavaScript, CLIP templating, LASS (CSS in Lisp)
|
||||
- *Infrastructure*: Docker, Docker Compose
|
||||
|
||||
* Project Timeline
|
||||
|
|
@ -232,43 +231,19 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
- Synchronized with upstream/main
|
||||
- Prepared comprehensive documentation PR
|
||||
|
||||
** Phase 9: Visual Audio Features (December 2025)
|
||||
|
||||
*** 2025-12-06: Real-Time Spectrum Analyzer
|
||||
- *Lead*: Brian O'Reilly (Fade), Glenn Thompson
|
||||
- Implemented spectrum analyzer using Parenscript
|
||||
- Web Audio API integration for real-time visualization
|
||||
- Dynamic JavaScript generation via API endpoint
|
||||
- Canvas-based frequency display
|
||||
- Works across all player modes (inline, pop-out, frameset)
|
||||
- Lisp-to-JavaScript compilation for maintainability
|
||||
|
||||
* Development Statistics
|
||||
|
||||
** Contributors (by commit count)
|
||||
1. Glenn Thompson (glenneth/Glenneth) - 236 commits
|
||||
2. Brian O'Reilly (Fade) - 109 commits
|
||||
3. Luis Pereira (easilok) - 63 commits
|
||||
1. Glenn Thompson (glenneth/Glenneth) - 135+ commits
|
||||
2. Brian O'Reilly (Fade) - 55+ commits
|
||||
3. Luis Pereira (easilok) - 23+ commits
|
||||
|
||||
** Total Commits: 408 commits
|
||||
|
||||
** Code Statistics
|
||||
- *Total Lines of Code*: ~9,300 lines
|
||||
- *Common Lisp*: 2,753 lines (.lisp, .asd)
|
||||
- *JavaScript*: 2,315 lines (.js)
|
||||
- *Templates*: 1,505 lines (.ctml)
|
||||
- *Other*: 2,720 lines (CSS, Shell, Python, etc.)
|
||||
- *Source Files*: 50 files
|
||||
|
||||
** Release Information
|
||||
- *Current Version*: Development (pre-1.0)
|
||||
- *Tagged Releases*: None (continuous development)
|
||||
- *Deployment Status*: Production-ready
|
||||
** Total Commits: 213+ commits
|
||||
|
||||
** Active Development Period
|
||||
- Start: August 12, 2025
|
||||
- Current: December 6, 2025
|
||||
- Duration: ~4 months of active development
|
||||
- Current: November 1, 2025
|
||||
- Duration: ~2.75 months of active development
|
||||
|
||||
* Major Features Implemented
|
||||
|
||||
|
|
@ -291,7 +266,6 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
- ✅ Multiple quality options (AAC 96k, MP3 128k, MP3 64k)
|
||||
- ✅ ReplayGain volume normalization
|
||||
- ✅ Live now-playing information
|
||||
- ✅ Real-time spectrum analyzer visualization
|
||||
- ✅ Icecast integration
|
||||
- ✅ Liquidsoap DJ controls
|
||||
- ✅ Stream queue management
|
||||
|
|
@ -351,7 +325,7 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
- Parallel music scanning
|
||||
- Client-side caching
|
||||
|
||||
* Current State (December 2025)
|
||||
* Current State (November 2025)
|
||||
|
||||
** Production Ready Features
|
||||
- Full music streaming platform
|
||||
|
|
@ -359,7 +333,6 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
- Admin control panel
|
||||
- DJ controls
|
||||
- Multiple player modes
|
||||
- Real-time spectrum analyzer
|
||||
- Complete Docker deployment (streams + application)
|
||||
- Multi-environment support with dynamic URLs
|
||||
- Comprehensive documentation
|
||||
|
|
@ -419,9 +392,9 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
|
||||
* Conclusion
|
||||
|
||||
Asteroid Radio has evolved from a simple concept into a full-featured internet radio platform in just 4 months of active development. The project demonstrates the power of Common Lisp for web development and the collaborative nature of open-source development.
|
||||
Asteroid Radio has evolved from a simple concept into a full-featured internet radio platform in just 2.75 months of active development. The project demonstrates the power of Common Lisp for web development and the collaborative nature of open-source development.
|
||||
|
||||
With complete Docker deployment, real-time audio visualization, comprehensive documentation, and a growing feature set, Asteroid Radio is ready for production use while continuing to evolve with regular improvements, bug fixes, and new features based on user needs and technical requirements.
|
||||
With complete Docker deployment, comprehensive documentation, and a growing feature set, Asteroid Radio is ready for production use while continuing to evolve with regular improvements, bug fixes, and new features based on user needs and technical requirements.
|
||||
|
||||
** Project Links
|
||||
- Repository: https://github.com/fade/asteroid
|
||||
|
|
@ -430,4 +403,4 @@ With complete Docker deployment, real-time audio visualization, comprehensive do
|
|||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-12-06*
|
||||
*Last Updated: 2025-11-01*
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - Project Overview
|
||||
#+AUTHOR: Glenn Thompson & Brian O'Reilly (Fade)
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* 🎯 Mission
|
||||
|
||||
|
|
@ -45,8 +45,6 @@ Asteroid Radio is a modern, web-based music streaming platform designed for hack
|
|||
- **HTML5** with semantic templates
|
||||
- **CSS3** with dark hacker theme
|
||||
- **JavaScript** for interactive features
|
||||
- **Parenscript** - Lisp-to-JavaScript compiler for spectrum analyzer
|
||||
- **Web Audio API** - Real-time audio visualization
|
||||
- **VT323 Font** for retro terminal aesthetic
|
||||
|
||||
**Streaming:**
|
||||
|
|
@ -83,7 +81,6 @@ Asteroid Radio is a modern, web-based music streaming platform designed for hack
|
|||
- ✅ **Music Library** - Track management with pagination, search, and filtering
|
||||
- ✅ **User Playlists** - Create, manage, and play personal music collections
|
||||
- ✅ **Multiple Player Modes** - Inline, pop-out, and persistent frameset players
|
||||
- ✅ **Real-Time Spectrum Analyzer** - Visual audio frequency display using Web Audio API and Parenscript
|
||||
- ✅ **Stream Queue Control** - Admin control over broadcast stream queue (M3U-based)
|
||||
- ✅ **REST API** - Comprehensive JSON API with 15+ endpoints
|
||||
- ✅ **Music Streaming** - Multiple quality formats (128k MP3, 96k AAC, 64k MP3)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ Pagination system for efficient browsing of large music libraries.
|
|||
- **Music Library**: Track management with pagination, search, and filtering
|
||||
- **Playlists**: User playlists with creation and playback
|
||||
- **Multiple Player Modes**: Inline, pop-out, and persistent frameset players
|
||||
- **Real-Time Spectrum Analyzer**: Visual audio frequency display using Web Audio API
|
||||
- **Stream Queue Control**: Admin control over broadcast stream queue
|
||||
- **Docker Streaming Infrastructure**: Icecast2 + Liquidsoap containers
|
||||
- **Three Quality Streams**: 128kbps MP3, 96kbps AAC, 64kbps MP3
|
||||
|
|
@ -148,5 +147,5 @@ For detailed technical information, see the **[[file:PROJECT-OVERVIEW.org][Proje
|
|||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-12-06*
|
||||
*Documentation Version: 3.1*
|
||||
*Last Updated: 2025-10-26*
|
||||
*Documentation Version: 3.0*
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Stream Queue Control System
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio Testing Guide
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Track Pagination System - Complete
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: User Management System - Complete
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -11,38 +11,40 @@
|
|||
:want-stream nil
|
||||
:basic-authorization '("admin" "asteroid_admin_2024"))))
|
||||
(when response
|
||||
(let ((xml-string (if (stringp response)
|
||||
response
|
||||
(babel:octets-to-string response :encoding :utf-8))))
|
||||
;; Extract total listener count from root <listeners> tag (sums all mount points)
|
||||
;; Extract title from asteroid.mp3 mount point
|
||||
(let* ((total-listeners (multiple-value-bind (match groups)
|
||||
(cl-ppcre:scan-to-strings "<listeners>(\\d+)</listeners>" xml-string)
|
||||
(if (and match groups)
|
||||
(parse-integer (aref groups 0) :junk-allowed t)
|
||||
0)))
|
||||
;; Get title from asteroid.mp3 mount point
|
||||
(mount-start (cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string))
|
||||
(title (if mount-start
|
||||
(let* ((source-section (subseq xml-string mount-start
|
||||
(or (cl-ppcre:scan "</source>" xml-string :start mount-start)
|
||||
(length xml-string)))))
|
||||
(multiple-value-bind (match groups)
|
||||
(cl-ppcre:scan-to-strings "<title>(.*?)</title>" source-section)
|
||||
(if (and match groups)
|
||||
(aref groups 0)
|
||||
"Unknown")))
|
||||
"Unknown")))
|
||||
;; Track recently played if title changed
|
||||
(when (and title
|
||||
(not (string= title "Unknown"))
|
||||
(not (equal title *last-known-track*)))
|
||||
(setf *last-known-track* title)
|
||||
(add-recently-played (list :title title
|
||||
:timestamp (get-universal-time))))
|
||||
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||
(:title . ,title)
|
||||
(:listeners . ,total-listeners)))))))
|
||||
(let ((xml-string (if (stringp response)
|
||||
response
|
||||
(babel:octets-to-string response :encoding :utf-8))))
|
||||
;; Extract total listener count from root <listeners> tag (sums all mount points)
|
||||
;; Extract title from asteroid.mp3 mount point
|
||||
(let* ((total-listeners (multiple-value-bind (match groups)
|
||||
(cl-ppcre:scan-to-strings "<listeners>(\\d+)</listeners>" xml-string)
|
||||
(if (and match groups)
|
||||
(parse-integer (aref groups 0) :junk-allowed t)
|
||||
0)))
|
||||
;; Get title from asteroid.mp3 mount point
|
||||
(mount-start (cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string))
|
||||
(title (if mount-start
|
||||
(let* ((source-section (subseq xml-string mount-start
|
||||
(or (cl-ppcre:scan "</source>" xml-string :start mount-start)
|
||||
(length xml-string)))))
|
||||
(multiple-value-bind (match groups)
|
||||
(cl-ppcre:scan-to-strings "<title>(.*?)</title>" source-section)
|
||||
(if (and match groups)
|
||||
(plump:decode-entities (aref groups 0))
|
||||
"Unknown")))
|
||||
"Unknown")))
|
||||
|
||||
;; Track recently played if title changed
|
||||
(when (and title
|
||||
(not (string= title "Unknown"))
|
||||
(not (equal title *last-known-track*)))
|
||||
(setf *last-known-track* title)
|
||||
(add-recently-played (list :title title
|
||||
:timestamp (get-universal-time))))
|
||||
|
||||
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||
(:title . ,title)
|
||||
(:listeners . ,total-listeners)))))))
|
||||
|
||||
(define-api asteroid/partial/now-playing () ()
|
||||
"Get Partial HTML with live status from Icecast server"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
;;;; parenscript-utils.lisp
|
||||
;;;; Utilities for generating JavaScript from ParenScript
|
||||
|
||||
(in-package :asteroid)
|
||||
|
||||
;;; ParenScript compilation utilities
|
||||
|
||||
(defun compile-ps-to-js (ps-code)
|
||||
"Compile ParenScript code to JavaScript string"
|
||||
(ps:ps* ps-code))
|
||||
|
||||
(defmacro define-js-route (name (&rest args) &body parenscript-body)
|
||||
"Define a route that serves compiled ParenScript as JavaScript"
|
||||
`(define-page ,name (,@args)
|
||||
(:content-type "application/javascript")
|
||||
(ps:ps ,@parenscript-body)))
|
||||
|
||||
;;; Common ParenScript macros and utilities
|
||||
|
||||
(defmacro ps-defun (name args &body body)
|
||||
"Define a ParenScript function"
|
||||
`(ps:defun ,name ,args ,@body))
|
||||
|
||||
(defmacro ps-api-call (endpoint method data success-callback error-callback)
|
||||
"Generate ParenScript for making API calls with fetch"
|
||||
`(ps:ps
|
||||
(fetch ,endpoint
|
||||
(ps:create :method ,method
|
||||
:headers (ps:create "Content-Type" "application/json")
|
||||
:body (ps:chain -j-s-o-n (stringify ,data))))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then ,success-callback)
|
||||
(catch ,error-callback)))
|
||||
|
|
@ -0,0 +1,665 @@
|
|||
;;;; 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)
|
||||
(setup-live-stream-monitor)
|
||||
(update-live-stream-info)
|
||||
;; Update live stream info every 10 seconds
|
||||
(set-interval update-live-stream-info 10000)
|
||||
;; 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)
|
||||
"<div class=\"error\">Error loading tracks</div>")))))))
|
||||
|
||||
;; 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)
|
||||
"<div class=\"no-tracks\">No tracks found. Click \"Scan Library\" to add tracks.</div>"))
|
||||
(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)
|
||||
(+ "<div class=\"track-item\" data-track-id=\"" (ps:@ track id) "\">"
|
||||
"<div class=\"track-info\">"
|
||||
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>"
|
||||
"<div class=\"track-artist\">" (or (ps:@ track artist) "Unknown Artist") "</div>"
|
||||
"<div class=\"track-album\">" (or (ps:@ track album) "Unknown Album") "</div>"
|
||||
"</div>"
|
||||
"<div class=\"track-actions\">"
|
||||
"<button onclick=\"addToQueue(" (ps:@ track id) ", 'end')\" class=\"btn btn-sm btn-primary\">➕ Add to Queue</button>"
|
||||
"<button onclick=\"deleteTrack(" (ps:@ track id) ")\" class=\"btn btn-sm btn-danger\">🗑️ Delete</button>"
|
||||
"</div>"
|
||||
"</div>")))
|
||||
(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."))
|
||||
|
||||
;; Setup live stream monitor
|
||||
(defun setup-live-stream-monitor ()
|
||||
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
|
||||
(when live-audio
|
||||
(setf (ps:@ live-audio preload) "none"))))
|
||||
|
||||
;; Live stream info update
|
||||
(defun update-live-stream-info ()
|
||||
;; Don't update if stream is paused
|
||||
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
|
||||
(when (and live-audio (ps:@ live-audio paused))
|
||||
(return)))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/partial/now-playing-inline")
|
||||
(then (lambda (response)
|
||||
(let ((content-type (ps:chain response headers (get "content-type"))))
|
||||
(unless (ps:chain content-type (includes "text/plain"))
|
||||
(ps:chain console (error "Unexpected content type:" content-type))
|
||||
(return))
|
||||
(ps:chain response (text)))))
|
||||
(then (lambda (now-playing-text)
|
||||
(let ((now-playing-el (ps:chain document (get-element-by-id "live-now-playing"))))
|
||||
(when now-playing-el
|
||||
(setf (ps:@ now-playing-el text-content) now-playing-text)))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Could not fetch stream info:" error))
|
||||
(let ((now-playing-el (ps:chain document (get-element-by-id "live-now-playing"))))
|
||||
(when now-playing-el
|
||||
(setf (ps:@ now-playing-el text-content) "Error loading stream info")))))))
|
||||
|
||||
;; ========================================
|
||||
;; 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)
|
||||
"<div class=\"error\">Error loading queue</div>")))))))
|
||||
|
||||
;; 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)
|
||||
"<div class=\"empty-state\">Queue is empty. Add tracks below.</div>")
|
||||
(let ((html "<div class=\"queue-items\">"))
|
||||
(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
|
||||
"<div class=\"queue-item\" data-track-id=\"" (ps:@ item id) "\" data-index=\"" index "\">"
|
||||
"<span class=\"queue-position\">" (+ index 1) "</span>"
|
||||
"<div class=\"queue-track-info\">"
|
||||
"<div class=\"track-title\">" (or (ps:@ item title) "Unknown") "</div>"
|
||||
"<div class=\"track-artist\">" (or (ps:@ item artist) "Unknown Artist") "</div>"
|
||||
"</div>"
|
||||
"<div class=\"queue-actions\">"
|
||||
"<button class=\"btn btn-sm btn-secondary\" onclick=\"moveTrackUp(" index ")\" " (if is-first "disabled" "") ">⬆️</button>"
|
||||
"<button class=\"btn btn-sm btn-secondary\" onclick=\"moveTrackDown(" index ")\" " (if is-last "disabled" "") ">⬇️</button>"
|
||||
"<button class=\"btn btn-sm btn-danger\" onclick=\"removeFromQueue(" (ps:@ item id) ")\">Remove</button>"
|
||||
"</div>"
|
||||
"</div>")))))))
|
||||
(setf html (+ html "</div>"))
|
||||
(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)
|
||||
"<div class=\"empty-state\">No tracks found</div>")
|
||||
(let ((html "<div class=\"search-results\">"))
|
||||
(ps:chain results
|
||||
(for-each (lambda (track)
|
||||
(setf html
|
||||
(+ html
|
||||
"<div class=\"search-result-item\">"
|
||||
"<div class=\"track-info\">"
|
||||
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown") "</div>"
|
||||
"<div class=\"track-artist\">" (or (ps:@ track artist) "Unknown") " - " (or (ps:@ track album) "Unknown Album") "</div>"
|
||||
"</div>"
|
||||
"<div class=\"track-actions\">"
|
||||
"<button class=\"btn btn-sm btn-primary\" onclick=\"addToQueue(" (ps:@ track id) ", 'end')\">Add to End</button>"
|
||||
"<button class=\"btn btn-sm btn-success\" onclick=\"addToQueue(" (ps:@ track id) ", 'next')\">Play Next</button>"
|
||||
"</div>"
|
||||
"</div>")))))
|
||||
(setf html (+ html "</div>"))
|
||||
(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*)
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
;;;; auth-ui.lisp - ParenScript version of auth-ui.js
|
||||
;;;; Handle authentication UI state across all pages
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *auth-ui-js*
|
||||
(ps:ps*
|
||||
'(progn
|
||||
|
||||
;; Check if user is logged in by calling the API
|
||||
(defun check-auth-status ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/auth-status")
|
||||
(then (lambda (response)
|
||||
(ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; api-output wraps response in {status, message, data}
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
data)))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error checking auth status:" error))
|
||||
(ps:create :logged-in false
|
||||
:is-admin false)))))
|
||||
|
||||
;; Update UI based on authentication status
|
||||
(defun update-auth-ui (auth-status)
|
||||
;; Show/hide elements based on login status
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-logged-in]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status logged-in)
|
||||
"inline-block"
|
||||
"none")))))
|
||||
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-logged-out]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status logged-in)
|
||||
"none"
|
||||
"inline-block")))))
|
||||
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-admin]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status is-admin)
|
||||
"inline-block"
|
||||
"none"))))))
|
||||
|
||||
;; Initialize auth UI on page load
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
(lambda ()
|
||||
(ps:chain console (log "Auth UI initializing..."))
|
||||
(ps:chain (check-auth-status)
|
||||
(then (lambda (auth-status)
|
||||
(ps:chain console (log "Auth status:" auth-status))
|
||||
(update-auth-ui auth-status)
|
||||
(ps:chain console (log "Auth UI updated")))))))))
|
||||
"Compiled JavaScript for auth UI - generated at load time"))
|
||||
|
||||
(defun generate-auth-ui-js ()
|
||||
"Return the pre-compiled JavaScript for authentication UI"
|
||||
*auth-ui-js*)
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
;;;; front-page.lisp - ParenScript version of front-page.js
|
||||
;;;; Stream quality, now playing, pop-out player, frameset mode
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *front-page-js*
|
||||
(ps:ps*
|
||||
'(progn
|
||||
|
||||
;; Stream quality configuration
|
||||
(defun get-stream-config (stream-base-url encoding)
|
||||
(let ((config (ps:create
|
||||
:aac (ps:create
|
||||
:url (+ stream-base-url "/asteroid.aac")
|
||||
:format "AAC 96kbps Stereo"
|
||||
:type "audio/aac"
|
||||
:mount "asteroid.aac")
|
||||
:mp3 (ps:create
|
||||
:url (+ stream-base-url "/asteroid.mp3")
|
||||
:format "MP3 128kbps Stereo"
|
||||
:type "audio/mpeg"
|
||||
:mount "asteroid.mp3")
|
||||
:low (ps:create
|
||||
:url (+ stream-base-url "/asteroid-low.mp3")
|
||||
:format "MP3 64kbps Stereo"
|
||||
:type "audio/mpeg"
|
||||
:mount "asteroid-low.mp3"))))
|
||||
(ps:getprop config encoding)))
|
||||
|
||||
;; Change stream quality
|
||||
(defun change-stream-quality ()
|
||||
(let* ((selector (ps:chain document (get-element-by-id "stream-quality")))
|
||||
(stream-base-url (ps:chain document (get-element-by-id "stream-base-url")))
|
||||
(config (get-stream-config (ps:@ stream-base-url value) (ps:@ selector value)))
|
||||
(audio-element (ps:chain document (get-element-by-id "live-audio")))
|
||||
(source-element (ps:chain document (get-element-by-id "audio-source")))
|
||||
(was-playing (not (ps:@ audio-element paused)))
|
||||
(current-time (ps:@ audio-element current-time)))
|
||||
|
||||
;; Save preference
|
||||
(ps:chain local-storage (set-item "stream-quality" (ps:@ selector value)))
|
||||
|
||||
;; Update stream information
|
||||
(update-stream-information)
|
||||
|
||||
;; Update audio player
|
||||
(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 (ps:chain audio-element (play))
|
||||
(catch (lambda (e)
|
||||
(ps:chain console (log "Autoplay prevented:" e))))))))
|
||||
|
||||
;; Update now playing info from API
|
||||
(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))
|
||||
(throw (ps:new (-error "Error connecting to stream")))))))
|
||||
(then (lambda (data)
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "now-playing")) inner-h-t-m-l)
|
||||
data)))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (log "Could not fetch stream status:" error))))))
|
||||
|
||||
;; Update stream information
|
||||
(defun update-stream-information ()
|
||||
(let* ((selector (ps:chain document (get-element-by-id "stream-quality")))
|
||||
(stream-base-url (ps:chain document (get-element-by-id "stream-base-url")))
|
||||
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac")))
|
||||
|
||||
;; Update selector if needed
|
||||
(when (and selector (not (= (ps:@ selector value) stream-quality)))
|
||||
(setf (ps:@ selector value) stream-quality)
|
||||
(ps:chain selector (dispatch-event (ps:new (-event "change")))))
|
||||
|
||||
;; Update stream info display
|
||||
(when stream-base-url
|
||||
(let ((config (get-stream-config (ps:@ stream-base-url value) stream-quality)))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "stream-url")) text-content)
|
||||
(ps:@ config url))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "stream-format")) text-content)
|
||||
(ps:@ config format))
|
||||
(let ((status-quality (ps:chain document (query-selector "[data-text=\"stream-quality\"]"))))
|
||||
(when status-quality
|
||||
(setf (ps:@ status-quality text-content) (ps:@ config format))))))))
|
||||
|
||||
;; Pop-out player functionality
|
||||
(defvar *popout-window* nil)
|
||||
|
||||
(defun open-popout-player ()
|
||||
;; Check if popout is already open
|
||||
(when (and *popout-window* (not (ps:@ *popout-window* closed)))
|
||||
(ps:chain *popout-window* (focus))
|
||||
(return))
|
||||
|
||||
;; Calculate centered position
|
||||
(let* ((width 420)
|
||||
(height 300)
|
||||
(left (/ (- (ps:@ screen width) width) 2))
|
||||
(top (/ (- (ps:@ screen height) height) 2))
|
||||
(features (+ "width=" width ",height=" height ",left=" left ",top=" top
|
||||
",resizable=yes,scrollbars=no,status=no,menubar=no,toolbar=no,location=no")))
|
||||
|
||||
;; Open popout window
|
||||
(setf *popout-window*
|
||||
(ps:chain window (open "/asteroid/popout-player" "AsteroidPlayer" features)))
|
||||
|
||||
;; Update button state
|
||||
(update-popout-button t)))
|
||||
|
||||
(defun update-popout-button (is-open)
|
||||
(let ((btn (ps:chain document (get-element-by-id "popout-btn"))))
|
||||
(when btn
|
||||
(if is-open
|
||||
(progn
|
||||
(setf (ps:@ btn text-content) "✓ Player Open")
|
||||
(ps:chain btn class-list (remove "btn-info"))
|
||||
(ps:chain btn class-list (add "btn-success")))
|
||||
(progn
|
||||
(setf (ps:@ btn text-content) "🗗 Pop Out Player")
|
||||
(ps:chain btn class-list (remove "btn-success"))
|
||||
(ps:chain btn class-list (add "btn-info")))))))
|
||||
|
||||
;; Frameset mode functionality
|
||||
(defun enable-frameset-mode ()
|
||||
(ps:chain local-storage (set-item "useFrameset" "true"))
|
||||
(setf (ps:@ window location href) "/asteroid/frameset"))
|
||||
|
||||
(defun disable-frameset-mode ()
|
||||
(ps:chain local-storage (remove-item "useFrameset"))
|
||||
(setf (ps:@ window location href) "/asteroid/"))
|
||||
|
||||
(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 "asteroid/content"))))
|
||||
|
||||
(when (and is-frameset-page (not is-content-frame))
|
||||
(setf (ps:@ window location href) "/asteroid/content"))
|
||||
|
||||
(when (and (not is-frameset-page) is-content-frame)
|
||||
(setf (ps:@ window location href) "/asteroid"))))
|
||||
|
||||
;; Initialize on page load
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
(lambda ()
|
||||
;; Update stream information
|
||||
(update-stream-information)
|
||||
|
||||
;; Periodically update stream info if in frameset
|
||||
(let ((is-frameset-page (not (= (ps:@ window parent) (ps:@ window self)))))
|
||||
(when is-frameset-page
|
||||
(set-interval update-stream-information 1000)))
|
||||
|
||||
;; Update now playing
|
||||
(update-now-playing)
|
||||
|
||||
;; Auto-reconnect on stream errors
|
||||
(let ((audio-element (ps:chain document (get-element-by-id "live-audio"))))
|
||||
(when audio-element
|
||||
(ps:chain audio-element
|
||||
(add-event-listener
|
||||
"error"
|
||||
(lambda (err)
|
||||
(ps:chain console (log "Stream error, attempting reconnect in 3 seconds..." err))
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain audio-element (load))
|
||||
(ps:chain (ps:chain audio-element (play))
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Reconnect failed:" err))))))
|
||||
3000))))
|
||||
|
||||
(ps:chain audio-element
|
||||
(add-event-listener
|
||||
"stalled"
|
||||
(lambda ()
|
||||
(ps:chain console (log "Stream stalled, reloading..."))
|
||||
(ps:chain audio-element (load))
|
||||
(ps:chain (ps:chain audio-element (play))
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Reload failed:" err))))))))
|
||||
|
||||
(let ((pause-timestamp nil)
|
||||
(is-reconnecting false)
|
||||
(needs-reconnect false)
|
||||
(pause-reconnect-threshold 10000))
|
||||
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "pause"
|
||||
(lambda ()
|
||||
(setf pause-timestamp (ps:chain |Date| (now)))
|
||||
(ps:chain console (log "Stream paused at:" pause-timestamp)))))
|
||||
|
||||
(ps:chain audio-element
|
||||
(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 audio-element
|
||||
(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 stream after long pause to clear stale buffers..."))
|
||||
|
||||
(ps:chain audio-element (pause))
|
||||
|
||||
(when (ps:@ window |resetSpectrumAnalyzer|)
|
||||
(ps:chain window (reset-spectrum-analyzer)))
|
||||
|
||||
(ps:chain audio-element (load))
|
||||
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain audio-element (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))))))))
|
||||
|
||||
;; Check frameset preference
|
||||
(let ((path (ps:@ window location pathname))
|
||||
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self)))))
|
||||
(when (and (= (ps:chain local-storage (get-item "useFrameset")) "true")
|
||||
(not is-frameset-page)
|
||||
(ps:chain path (includes "/asteroid")))
|
||||
(setf (ps:@ window location href) "/asteroid/frameset"))
|
||||
|
||||
(redirect-when-frame)))))
|
||||
|
||||
;; Update now playing every 10 seconds
|
||||
(set-interval update-now-playing 10000)
|
||||
|
||||
;; Listen for messages from popout window
|
||||
(ps:chain window
|
||||
(add-event-listener
|
||||
"message"
|
||||
(lambda (event)
|
||||
(cond
|
||||
((= (ps:@ event data type) "popout-opened")
|
||||
(update-popout-button t))
|
||||
((= (ps:@ event data type) "popout-closed")
|
||||
(update-popout-button nil)
|
||||
(setf *popout-window* nil))))))
|
||||
|
||||
;; Check if popout is still open periodically
|
||||
(set-interval
|
||||
(lambda ()
|
||||
(when (and *popout-window* (ps:@ *popout-window* closed))
|
||||
(update-popout-button nil)
|
||||
(setf *popout-window* nil)))
|
||||
1000)))
|
||||
"Compiled JavaScript for front-page - generated at load time")
|
||||
|
||||
(defun generate-front-page-js ()
|
||||
"Return the pre-compiled JavaScript for front page"
|
||||
*front-page-js*)
|
||||
|
|
@ -0,0 +1,667 @@
|
|||
;;;; player.lisp - ParenScript version of player.js
|
||||
;;;; Web Player functionality including audio playback, playlists, queue management, and live streaming
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *player-js*
|
||||
(ps:ps*
|
||||
'(progn
|
||||
|
||||
;; Global variables
|
||||
(defvar *tracks* (array))
|
||||
(defvar *current-track* nil)
|
||||
(defvar *current-track-index* -1)
|
||||
(defvar *play-queue* (array))
|
||||
(defvar *is-shuffled* nil)
|
||||
(defvar *is-repeating* nil)
|
||||
(defvar *audio-player* nil)
|
||||
|
||||
;; Pagination variables for track library
|
||||
(defvar *library-current-page* 1)
|
||||
(defvar *library-tracks-per-page* 20)
|
||||
(defvar *filtered-library-tracks* (array))
|
||||
|
||||
;; Initialize player on page load
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
(lambda ()
|
||||
(setf *audio-player* (ps:chain document (get-element-by-id "audio-player")))
|
||||
(redirect-when-frame)
|
||||
(load-tracks)
|
||||
(load-playlists)
|
||||
(setup-event-listeners)
|
||||
(update-player-display)
|
||||
(update-volume)
|
||||
|
||||
;; Setup live stream with reduced buffering and reconnect logic
|
||||
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
|
||||
(when live-audio
|
||||
;; Reduce buffer to minimize delay
|
||||
(setf (ps:@ live-audio preload) "none")
|
||||
|
||||
;; Add reconnect logic for long pauses
|
||||
(let ((pause-timestamp nil)
|
||||
(is-reconnecting false)
|
||||
(needs-reconnect false)
|
||||
(pause-reconnect-threshold 10000))
|
||||
|
||||
(ps:chain live-audio
|
||||
(add-event-listener "pause"
|
||||
(lambda ()
|
||||
(setf pause-timestamp (ps:chain |Date| (now)))
|
||||
(ps:chain console (log "Live stream paused at:" pause-timestamp)))))
|
||||
|
||||
(ps:chain live-audio
|
||||
(add-event-listener "play"
|
||||
(lambda ()
|
||||
(when (and (not is-reconnecting)
|
||||
pause-timestamp
|
||||
(> (- (ps:chain |Date| (now)) pause-timestamp) pause-reconnect-threshold))
|
||||
(setf needs-reconnect true)
|
||||
(ps:chain console (log "Long pause detected, will reconnect when playing starts...")))
|
||||
(setf pause-timestamp nil))))
|
||||
|
||||
(ps:chain live-audio
|
||||
(add-event-listener "playing"
|
||||
(lambda ()
|
||||
(when (and needs-reconnect (not is-reconnecting))
|
||||
(setf is-reconnecting true)
|
||||
(setf needs-reconnect false)
|
||||
(ps:chain console (log "Reconnecting live stream after long pause to clear stale buffers..."))
|
||||
|
||||
(ps:chain live-audio (pause))
|
||||
|
||||
(when (ps:@ window |resetSpectrumAnalyzer|)
|
||||
(ps:chain window (reset-spectrum-analyzer)))
|
||||
|
||||
(ps:chain live-audio (load))
|
||||
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain live-audio (play)
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Reconnect play failed:" err)))))
|
||||
|
||||
(when (ps:@ window |initSpectrumAnalyzer|)
|
||||
(ps:chain window (init-spectrum-analyzer))
|
||||
(ps:chain console (log "Spectrum analyzer reinitialized after reconnect")))
|
||||
|
||||
(setf is-reconnecting false))
|
||||
200)))))
|
||||
)))
|
||||
|
||||
;; Restore user quality preference
|
||||
(let ((selector (ps:chain document (get-element-by-id "live-stream-quality")))
|
||||
(stream-quality (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) "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-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) "Unknown Title") "</div>"
|
||||
"<div class=\"track-meta\">" (or (ps:@ track artist) "Unknown Artist") "</div>"
|
||||
"</div>"
|
||||
"<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>"
|
||||
"</div>")))
|
||||
(join ""))))
|
||||
(setf (ps:@ container inner-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)
|
||||
;; 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
|
||||
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
|
||||
(then (lambda () (load-playlists)))))
|
||||
(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 (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)
|
||||
;; 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
|
||||
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
|
||||
(then (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 (new "FormData")))
|
||||
(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"))))))))
|
||||
(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
|
||||
(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)
|
||||
;; 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
|
||||
(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:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading playlist:" error))
|
||||
(alert (+ "Error loading playlist: " (ps:@ error message)))))))
|
||||
|
||||
;; Stream quality configuration
|
||||
(defun get-live-stream-config (stream-base-url quality)
|
||||
(let ((config (ps:create
|
||||
:aac (ps:create
|
||||
:url (+ stream-base-url "/asteroid.aac")
|
||||
:type "audio/aac"
|
||||
:mount "asteroid.aac")
|
||||
:mp3 (ps:create
|
||||
:url (+ stream-base-url "/asteroid.mp3")
|
||||
:type "audio/mpeg"
|
||||
:mount "asteroid.mp3")
|
||||
:low (ps:create
|
||||
:url (+ stream-base-url "/asteroid-low.mp3")
|
||||
:type "audio/mpeg"
|
||||
:mount "asteroid-low.mp3"))))
|
||||
(aref config quality)))
|
||||
|
||||
;; Change live stream quality
|
||||
(defun change-live-stream-quality ()
|
||||
(let ((stream-base-url (ps:chain (ps:chain document (get-element-by-id "stream-base-url")) value))
|
||||
(selector (ps:chain document (get-element-by-id "live-stream-quality")))
|
||||
(config (get-live-stream-config
|
||||
(ps:chain (ps:chain document (get-element-by-id "stream-base-url")) value)
|
||||
(ps:chain (ps:chain document (get-element-by-id "live-stream-quality")) value))))
|
||||
|
||||
;; Update audio player
|
||||
(let ((audio-element (ps:chain document (get-element-by-id "live-stream-audio")))
|
||||
(source-element (ps:chain document (get-element-by-id "live-stream-source")))
|
||||
(was-playing (not (ps:chain (ps:chain document (get-element-by-id "live-stream-audio")) paused))))
|
||||
|
||||
(setf (ps:@ source-element src) (ps:@ config url))
|
||||
(setf (ps:@ source-element type) (ps:@ config type))
|
||||
(ps:chain audio-element (load))
|
||||
|
||||
;; Resume playback if it was playing
|
||||
(when was-playing
|
||||
(ps:chain audio-element
|
||||
(play)
|
||||
(catch (lambda (e) (ps:chain console (log "Autoplay prevented:" e)))))))))
|
||||
|
||||
;; Update now playing information
|
||||
(defun update-now-playing ()
|
||||
(ps:chain
|
||||
(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*)
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
;;;; profile.lisp - ParenScript version of profile.js
|
||||
;;;; User profile page with listening stats and history
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *profile-js*
|
||||
(ps:ps*
|
||||
'(progn
|
||||
|
||||
;; Global state
|
||||
(defvar *current-user* nil)
|
||||
(defvar *listening-data* nil)
|
||||
|
||||
;; Utility functions
|
||||
(defun update-element (data-text value)
|
||||
(let ((element (ps:chain document (query-selector (+ "[data-text=\"" data-text "\"]")))))
|
||||
(when (and element (not (= value undefined)) (not (= value null)))
|
||||
(setf (ps:@ element text-content) value))))
|
||||
|
||||
(defun format-role (role)
|
||||
(let ((role-map (ps:create
|
||||
"admin" "👑 Admin"
|
||||
"dj" "🎧 DJ"
|
||||
"listener" "🎵 Listener")))
|
||||
(or (ps:getprop role-map role) role)))
|
||||
|
||||
(defun format-date (date-string)
|
||||
(let ((date (ps:new (-date date-string))))
|
||||
(ps:chain date (to-locale-date-string "en-US"
|
||||
(ps:create :year "numeric"
|
||||
:month "long"
|
||||
:day "numeric")))))
|
||||
|
||||
(defun format-relative-time (date-string)
|
||||
(let* ((date (ps:new (-date date-string)))
|
||||
(now (ps:new (-date)))
|
||||
(diff-ms (- now date))
|
||||
(diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24)))))
|
||||
(diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60)))))
|
||||
(diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60))))))
|
||||
(cond
|
||||
((> diff-days 0)
|
||||
(+ diff-days " day" (if (> diff-days 1) "s" "") " ago"))
|
||||
((> diff-hours 0)
|
||||
(+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago"))
|
||||
((> diff-minutes 0)
|
||||
(+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago"))
|
||||
(t "Just now"))))
|
||||
|
||||
(defun format-duration (seconds)
|
||||
(let ((hours (ps:chain -math (floor (/ seconds 3600))))
|
||||
(minutes (ps:chain -math (floor (/ (rem seconds 3600) 60)))))
|
||||
(if (> hours 0)
|
||||
(+ hours "h " minutes "m")
|
||||
(+ minutes "m"))))
|
||||
|
||||
(defun show-message (message &optional (type "info"))
|
||||
(let ((toast (ps:chain document (create-element "div")))
|
||||
(colors (ps:create
|
||||
"info" "#007bff"
|
||||
"success" "#28a745"
|
||||
"error" "#dc3545"
|
||||
"warning" "#ffc107")))
|
||||
(setf (ps:@ toast class-name) (+ "toast toast-" type))
|
||||
(setf (ps:@ toast text-content) message)
|
||||
(setf (ps:@ toast style css-text)
|
||||
"position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 4px; color: white; font-weight: bold; z-index: 1000; opacity: 0; transition: opacity 0.3s ease;")
|
||||
(setf (ps:@ toast style background-color) (or (ps:getprop colors type) (ps:getprop colors "info")))
|
||||
|
||||
(ps:chain document body (append-child toast))
|
||||
|
||||
(set-timeout (lambda () (setf (ps:@ toast style opacity) "1")) 100)
|
||||
(set-timeout (lambda ()
|
||||
(setf (ps:@ toast style opacity) "0")
|
||||
(set-timeout (lambda () (ps:chain document body (remove-child toast))) 300))
|
||||
3000)))
|
||||
|
||||
(defun show-error (message)
|
||||
(show-message message "error"))
|
||||
|
||||
;; Profile data loading
|
||||
(defun update-profile-display (user)
|
||||
(update-element "username" (or (ps:@ user username) "Unknown User"))
|
||||
(update-element "user-role" (format-role (or (ps:@ user role) "listener")))
|
||||
(update-element "join-date" (format-date (or (ps:@ user created_at) (ps:new (-date)))))
|
||||
(update-element "last-active" (format-relative-time (or (ps:@ user last_active) (ps:new (-date)))))
|
||||
|
||||
(let ((admin-link (ps:chain document (query-selector "[data-show-if-admin]"))))
|
||||
(when admin-link
|
||||
(setf (ps:@ admin-link style display)
|
||||
(if (= (ps:@ user role) "admin") "inline" "none")))))
|
||||
|
||||
(defun load-listening-stats ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/listening-stats")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when (= (ps:@ data status) "success")
|
||||
(let ((stats (ps:@ data stats)))
|
||||
(update-element "total-listen-time" (format-duration (or (ps:@ stats total_listen_time) 0)))
|
||||
(update-element "tracks-played" (or (ps:@ stats tracks_played) 0))
|
||||
(update-element "session-count" (or (ps:@ stats session_count) 0))
|
||||
(update-element "favorite-genre" (or (ps:@ stats favorite_genre) "Unknown")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading listening stats:" error))
|
||||
(update-element "total-listen-time" "0h 0m")
|
||||
(update-element "tracks-played" "0")
|
||||
(update-element "session-count" "0")
|
||||
(update-element "favorite-genre" "Unknown")))))
|
||||
|
||||
(defun load-recent-tracks ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/recent-tracks?limit=3")
|
||||
(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 tracks)
|
||||
(> (ps:@ data tracks length) 0))
|
||||
(ps:chain data tracks
|
||||
(for-each (lambda (track index)
|
||||
(let ((track-num (+ index 1)))
|
||||
(update-element (+ "recent-track-" track-num "-title")
|
||||
(or (ps:@ track title) "Unknown Track"))
|
||||
(update-element (+ "recent-track-" track-num "-artist")
|
||||
(or (ps:@ track artist) "Unknown Artist"))
|
||||
(update-element (+ "recent-track-" track-num "-duration")
|
||||
(format-duration (or (ps:@ track duration) 0)))
|
||||
(update-element (+ "recent-track-" track-num "-played-at")
|
||||
(format-relative-time (ps:@ track played_at)))))))
|
||||
(loop for i from 1 to 3
|
||||
do (let* ((track-item-selector (+ "[data-text=\"recent-track-" i "-title\"]"))
|
||||
(track-item-el (ps:chain document (query-selector track-item-selector)))
|
||||
(track-item (when track-item-el (ps:chain track-item-el (closest ".track-item")))))
|
||||
(when (and track-item
|
||||
(or (not (ps:@ data tracks))
|
||||
(not (ps:getprop (ps:@ data tracks) (- i 1)))))
|
||||
(setf (ps:@ track-item style display) "none"))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading recent tracks:" error))))))
|
||||
|
||||
(defun load-top-artists ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/top-artists?limit=5")
|
||||
(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 artists)
|
||||
(> (ps:@ data artists length) 0))
|
||||
(ps:chain data artists
|
||||
(for-each (lambda (artist index)
|
||||
(let ((artist-num (+ index 1)))
|
||||
(update-element (+ "top-artist-" artist-num)
|
||||
(or (ps:@ artist name) "Unknown Artist"))
|
||||
(update-element (+ "top-artist-" artist-num "-plays")
|
||||
(+ (or (ps:@ artist play_count) 0) " plays"))))))
|
||||
(loop for i from 1 to 5
|
||||
do (let* ((artist-item-selector (+ "[data-text=\"top-artist-" i "\"]"))
|
||||
(artist-item-el (ps:chain document (query-selector artist-item-selector)))
|
||||
(artist-item (when artist-item-el (ps:chain artist-item-el (closest ".artist-item")))))
|
||||
(when (and artist-item
|
||||
(or (not (ps:@ data artists))
|
||||
(not (ps:getprop (ps:@ data artists) (- i 1)))))
|
||||
(setf (ps:@ artist-item style display) "none"))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading top artists:" error))))))
|
||||
|
||||
(defun load-profile-data ()
|
||||
(ps:chain console (log "Loading profile data..."))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/profile")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(setf *current-user* (ps:@ data user))
|
||||
(update-profile-display (ps:@ data user)))
|
||||
(progn
|
||||
(ps:chain console (error "Failed to load profile:" (ps:@ data message)))
|
||||
(show-error "Failed to load profile data"))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading profile:" error))
|
||||
(show-error "Error loading profile data"))))
|
||||
|
||||
(load-listening-stats)
|
||||
(load-recent-tracks)
|
||||
(load-top-artists))
|
||||
|
||||
;; Action functions
|
||||
(defun load-more-recent-tracks ()
|
||||
(ps:chain console (log "Loading more recent tracks..."))
|
||||
(show-message "Loading more tracks..." "info"))
|
||||
|
||||
(defun edit-profile ()
|
||||
(ps:chain console (log "Edit profile clicked"))
|
||||
(show-message "Profile editing coming soon!" "info"))
|
||||
|
||||
(defun export-listening-data ()
|
||||
(ps:chain console (log "Exporting listening data..."))
|
||||
(show-message "Preparing data export..." "info")
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/export-data" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (blob))))
|
||||
(then (lambda (blob)
|
||||
(let* ((url (ps:chain window -u-r-l (create-object-u-r-l blob)))
|
||||
(a (ps:chain document (create-element "a"))))
|
||||
(setf (ps:@ a style display) "none")
|
||||
(setf (ps:@ a href) url)
|
||||
(setf (ps:@ a download) (+ "asteroid-listening-data-"
|
||||
(or (ps:@ *current-user* username) "user")
|
||||
".json"))
|
||||
(ps:chain document body (append-child a))
|
||||
(ps:chain a (click))
|
||||
(ps:chain window -u-r-l (revoke-object-u-r-l url))
|
||||
(show-message "Data exported successfully!" "success"))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error exporting data:" error))
|
||||
(show-message "Failed to export data" "error")))))
|
||||
|
||||
(defun clear-listening-history ()
|
||||
(when (not (confirm "Are you sure you want to clear your listening history? This action cannot be undone."))
|
||||
(return))
|
||||
|
||||
(ps:chain console (log "Clearing listening history..."))
|
||||
(show-message "Clearing listening history..." "info")
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/clear-history" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (data)
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(show-message "Listening history cleared successfully!" "success")
|
||||
(set-timeout (lambda () (ps:chain location (reload))) 1500))
|
||||
(show-message (+ "Failed to clear history: " (ps:@ data message)) "error"))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error clearing history:" error))
|
||||
(show-message "Failed to clear history" "error")))))
|
||||
|
||||
;; Password change
|
||||
(defun change-password (event)
|
||||
(ps:chain event (prevent-default))
|
||||
|
||||
(let ((current-password (ps:@ (ps:chain document (get-element-by-id "current-password")) value))
|
||||
(new-password (ps:@ (ps:chain document (get-element-by-id "new-password")) value))
|
||||
(confirm-password (ps:@ (ps:chain document (get-element-by-id "confirm-password")) value))
|
||||
(message-div (ps:chain document (get-element-by-id "password-message"))))
|
||||
|
||||
;; Client-side validation
|
||||
(cond
|
||||
((< (ps:@ new-password length) 8)
|
||||
(setf (ps:@ message-div text-content) "New password must be at least 8 characters")
|
||||
(setf (ps:@ message-div class-name) "message error")
|
||||
(return false))
|
||||
((not (= new-password confirm-password))
|
||||
(setf (ps:@ message-div text-content) "New passwords do not match")
|
||||
(setf (ps:@ message-div class-name) "message error")
|
||||
(return false)))
|
||||
|
||||
;; Send request to API
|
||||
(let ((form-data (ps:new (-form-data))))
|
||||
(ps:chain form-data (append "current-password" current-password))
|
||||
(ps:chain form-data (append "new-password" new-password))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/change-password"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (data)
|
||||
(if (or (= (ps:@ data status) "success")
|
||||
(and (ps:@ data data) (= (ps:@ data data status) "success")))
|
||||
(progn
|
||||
(setf (ps:@ message-div text-content) "Password changed successfully!")
|
||||
(setf (ps:@ message-div class-name) "message success")
|
||||
(ps:chain (ps:chain document (get-element-by-id "change-password-form")) (reset)))
|
||||
(progn
|
||||
(setf (ps:@ message-div text-content)
|
||||
(or (ps:@ data message)
|
||||
(ps:@ data data message)
|
||||
"Failed to change password"))
|
||||
(setf (ps:@ message-div class-name) "message error")))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error changing password:" error))
|
||||
(setf (ps:@ message-div text-content) "Error changing password")
|
||||
(setf (ps:@ message-div class-name) "message error")))))
|
||||
|
||||
false))
|
||||
|
||||
;; Initialize on page load
|
||||
(ps:chain window
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
load-profile-data))))
|
||||
"Compiled JavaScript for profile page - generated at load time")
|
||||
|
||||
(defun generate-profile-js ()
|
||||
"Return the pre-compiled JavaScript for profile page"
|
||||
*profile-js*)
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
;;;; recently-played.lisp - ParenScript version of recently-played.js
|
||||
;;;; Recently Played Tracks functionality
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *recently-played-js*
|
||||
(ps:ps
|
||||
(progn
|
||||
|
||||
;; Update recently played tracks display
|
||||
(defun update-recently-played ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/recently-played")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Radiance wraps API responses in a data envelope
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (and (equal (ps:@ data status) "success")
|
||||
(ps:@ data tracks)
|
||||
(> (ps:@ data tracks length) 0))
|
||||
(let ((list-el (ps:chain document (get-element-by-id "recently-played-list"))))
|
||||
(when list-el
|
||||
;; Build HTML for tracks
|
||||
(let ((html "<ul class=\"track-list\">"))
|
||||
(ps:chain (ps:@ data tracks)
|
||||
(for-each (lambda (track index)
|
||||
(let ((time-ago (format-time-ago (ps:@ track timestamp))))
|
||||
(setf html
|
||||
(+ html
|
||||
"<li class=\"track-item\">"
|
||||
"<div class=\"track-info\">"
|
||||
"<div class=\"track-title\">"
|
||||
"<a href=\"" (ps:@ track search_url) "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"track-link\">"
|
||||
(escape-html (ps:@ track song))
|
||||
"<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" class=\"external-icon\">"
|
||||
"<path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"></path>"
|
||||
"<polyline points=\"15 3 21 3 21 9\"></polyline>"
|
||||
"<line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\"></line>"
|
||||
"</svg>"
|
||||
"</a>"
|
||||
"</div>"
|
||||
"<div class=\"track-artist\">" (escape-html (ps:@ track artist)) "</div>"
|
||||
"<span class=\"track-time\">" time-ago "</span>"
|
||||
"</div>"
|
||||
"</li>"))))))
|
||||
(setf html (+ html "</ul>"))
|
||||
(setf (aref list-el "innerHTML") html))))
|
||||
(let ((list-el (ps:chain document (get-element-by-id "recently-played-list"))))
|
||||
(when list-el
|
||||
(setf (aref list-el "innerHTML") "<p class=\"no-tracks\">No tracks played yet</p>")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error fetching recently played:" error))
|
||||
(let ((list-el (ps:chain document (get-element-by-id "recently-played-list"))))
|
||||
(when list-el
|
||||
(setf (aref list-el "innerHTML") "<p class=\"error\">Error loading recently played tracks</p>"))))))))
|
||||
|
||||
;; Format timestamp as relative time
|
||||
(defun format-time-ago (timestamp)
|
||||
(let* ((now (floor (/ (ps:chain *date (now)) 1000)))
|
||||
(diff (- now timestamp)))
|
||||
(cond
|
||||
((< diff 60) "Just now")
|
||||
((< diff 3600) (+ (floor (/ diff 60)) "m ago"))
|
||||
((< diff 86400) (+ (floor (/ diff 3600)) "h ago"))
|
||||
(t (+ (floor (/ diff 86400)) "d ago")))))
|
||||
|
||||
;; Escape HTML to prevent XSS
|
||||
(defun escape-html (text)
|
||||
(when (ps:@ window document)
|
||||
(let ((div (ps:chain document (create-element "div"))))
|
||||
(setf (ps:@ div text-content) text)
|
||||
(aref div "innerHTML"))))
|
||||
|
||||
;; Initialize on page load
|
||||
(when (ps:@ window document)
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
(lambda ()
|
||||
(let ((panel (ps:chain document (get-element-by-id "recently-played-panel"))))
|
||||
(if panel
|
||||
(progn
|
||||
(update-recently-played)
|
||||
;; Update every 30 seconds
|
||||
(set-interval update-recently-played 30000))
|
||||
(let ((list (ps:chain document (get-element-by-id "recently-played-list"))))
|
||||
(when list
|
||||
(update-recently-played)
|
||||
(set-interval update-recently-played 30000))))))))))
|
||||
"Compiled JavaScript for recently played tracks - generated at load time"
|
||||
)
|
||||
|
||||
(defun generate-recently-played-js ()
|
||||
"Generate JavaScript code for recently played tracks"
|
||||
*recently-played-js*)
|
||||
|
|
@ -14,6 +14,19 @@
|
|||
(defvar *animation-id* nil)
|
||||
(defvar *media-source* nil)
|
||||
(defvar *current-audio-element* nil)
|
||||
(defvar *current-theme* "green")
|
||||
(defvar *current-style* "bars")
|
||||
|
||||
;; Color themes for spectrum analyzer
|
||||
(defvar *themes*
|
||||
(ps:create
|
||||
"monotone" (ps:create "top" "#0047ab" "mid" "#002966" "bottom" "#000d1a")
|
||||
"green" (ps:create "top" "#00ff00" "mid" "#00aa00" "bottom" "#005500")
|
||||
"blue" (ps:create "top" "#00ffff" "mid" "#0088ff" "bottom" "#0044aa")
|
||||
"purple" (ps:create "top" "#ff00ff" "mid" "#aa00aa" "bottom" "#550055")
|
||||
"red" (ps:create "top" "#ff0000" "mid" "#aa0000" "bottom" "#550000")
|
||||
"amber" (ps:create "top" "#ffaa00" "mid" "#ff6600" "bottom" "#aa3300")
|
||||
"rainbow" (ps:create "top" "#ff00ff" "mid" "#00ffff" "bottom" "#00ff00")))
|
||||
|
||||
(defun reset-spectrum-analyzer ()
|
||||
"Reset the spectrum analyzer to allow reconnection after audio element reload"
|
||||
|
|
@ -89,7 +102,8 @@
|
|||
(height (ps:@ *canvas* height))
|
||||
(bar-width (/ width buffer-length))
|
||||
(bar-height 0)
|
||||
(x 0))
|
||||
(x 0)
|
||||
(is-muted (and *current-audio-element* (ps:@ *current-audio-element* muted))))
|
||||
|
||||
(ps:chain *analyser* (get-byte-frequency-data data-array))
|
||||
|
||||
|
|
@ -97,21 +111,68 @@
|
|||
(setf (ps:@ *canvas-ctx* |fillStyle|) "rgba(0, 0, 0, 0.2)")
|
||||
(ps:chain *canvas-ctx* (fill-rect 0 0 width height))
|
||||
|
||||
;; Draw bars
|
||||
(dotimes (i buffer-length)
|
||||
(setf bar-height (/ (* (aref data-array i) height) 256))
|
||||
|
||||
;; Create gradient for each bar
|
||||
(let ((gradient (ps:chain *canvas-ctx*
|
||||
(create-linear-gradient 0 (- height bar-height) 0 height))))
|
||||
(ps:chain gradient (add-color-stop 0 "#00ff00"))
|
||||
(ps:chain gradient (add-color-stop 0.5 "#00aa00"))
|
||||
(ps:chain gradient (add-color-stop 1 "#005500"))
|
||||
;; Get current theme colors
|
||||
(let ((theme (ps:getprop *themes* *current-theme*)))
|
||||
(cond
|
||||
;; Bar graph style
|
||||
((= *current-style* "bars")
|
||||
(setf x 0)
|
||||
(dotimes (i buffer-length)
|
||||
(setf bar-height (/ (* (aref data-array i) height) 256))
|
||||
|
||||
;; Create gradient for each bar using theme colors
|
||||
(let ((gradient (ps:chain *canvas-ctx*
|
||||
(create-linear-gradient 0 (- height bar-height) 0 height))))
|
||||
(ps:chain gradient (add-color-stop 0 (ps:@ theme top)))
|
||||
(ps:chain gradient (add-color-stop 0.5 (ps:@ theme mid)))
|
||||
(ps:chain gradient (add-color-stop 1 (ps:@ theme bottom)))
|
||||
|
||||
(setf (ps:@ *canvas-ctx* |fillStyle|) gradient)
|
||||
(ps:chain *canvas-ctx* (fill-rect x (- height bar-height) bar-width bar-height))
|
||||
|
||||
(incf x bar-width))))
|
||||
|
||||
;; Wave/line style
|
||||
((= *current-style* "wave")
|
||||
(setf x 0)
|
||||
(ps:chain *canvas-ctx* (begin-path))
|
||||
(setf (ps:@ *canvas-ctx* |lineWidth|) 2)
|
||||
(setf (ps:@ *canvas-ctx* |strokeStyle|) (ps:@ theme top))
|
||||
|
||||
(setf (ps:@ *canvas-ctx* |fillStyle|) gradient)
|
||||
(ps:chain *canvas-ctx* (fill-rect x (- height bar-height) bar-width bar-height))
|
||||
(dotimes (i buffer-length)
|
||||
(setf bar-height (/ (* (aref data-array i) height) 256))
|
||||
(let ((y (- height bar-height)))
|
||||
(if (= i 0)
|
||||
(ps:chain *canvas-ctx* (move-to x y))
|
||||
(ps:chain *canvas-ctx* (line-to x y)))
|
||||
(incf x bar-width)))
|
||||
|
||||
(incf x bar-width)))))
|
||||
(ps:chain *canvas-ctx* (stroke)))
|
||||
|
||||
;; Dots/particles style
|
||||
((= *current-style* "dots")
|
||||
(setf x 0)
|
||||
(setf (ps:@ *canvas-ctx* |fillStyle|) (ps:@ theme top))
|
||||
(dotimes (i buffer-length)
|
||||
(let* ((value (aref data-array i))
|
||||
(normalized-height (/ (* value height) 256))
|
||||
(y (- height normalized-height))
|
||||
(dot-radius (ps:max 2 (/ normalized-height 20))))
|
||||
|
||||
(when (> value 0)
|
||||
(ps:chain *canvas-ctx* (begin-path))
|
||||
(ps:chain *canvas-ctx* (arc x y dot-radius 0 6.283185307179586))
|
||||
(ps:chain *canvas-ctx* (fill)))
|
||||
|
||||
(incf x bar-width))))))
|
||||
|
||||
;; Draw MUTED indicator if audio is muted
|
||||
(when is-muted
|
||||
(setf (ps:@ *canvas-ctx* |fillStyle|) "rgba(255, 0, 0, 0.8)")
|
||||
(setf (ps:@ *canvas-ctx* font) "bold 20px monospace")
|
||||
(setf (ps:@ *canvas-ctx* |textAlign|) "right")
|
||||
(setf (ps:@ *canvas-ctx* |textBaseline|) "top")
|
||||
(ps:chain *canvas-ctx* (fill-text "MUTED" (- width 10) 10)))))
|
||||
|
||||
(defun stop-spectrum-analyzer ()
|
||||
"Stop the spectrum analyzer"
|
||||
|
|
@ -119,9 +180,73 @@
|
|||
(cancel-animation-frame *animation-id*)
|
||||
(setf *animation-id* nil)))
|
||||
|
||||
(defun set-spectrum-theme (theme-name)
|
||||
"Change the spectrum analyzer color theme and update dropdown colors"
|
||||
(when (ps:getprop *themes* theme-name)
|
||||
(setf *current-theme* theme-name)
|
||||
(ps:chain local-storage (set-item "spectrum-theme" theme-name))
|
||||
|
||||
(let ((theme (ps:getprop *themes* theme-name)))
|
||||
;; Update canvas border color to match theme
|
||||
(when *canvas*
|
||||
(setf (ps:@ *canvas* style border-color) (ps:@ theme top)))
|
||||
|
||||
;; Update dropdown box colors
|
||||
(let ((theme-selector (ps:chain document (get-element-by-id "spectrum-theme-selector")))
|
||||
(style-selector (ps:chain document (get-element-by-id "spectrum-style-selector"))))
|
||||
(when theme-selector
|
||||
(setf (ps:@ theme-selector style color) (ps:@ theme top))
|
||||
(setf (ps:@ theme-selector style border-color) (ps:@ theme top)))
|
||||
(when style-selector
|
||||
(setf (ps:@ style-selector style color) (ps:@ theme top))
|
||||
(setf (ps:@ style-selector style border-color) (ps:@ theme top)))))
|
||||
|
||||
(ps:chain console (log (+ "Spectrum theme changed to: " theme-name)))))
|
||||
|
||||
(defun get-available-themes ()
|
||||
"Return array of available theme names"
|
||||
(ps:chain |Object| (keys *themes*)))
|
||||
|
||||
(defun set-spectrum-style (style-name)
|
||||
"Change the spectrum analyzer visualization style"
|
||||
(when (or (= style-name "bars") (= style-name "wave") (= style-name "dots"))
|
||||
(setf *current-style* style-name)
|
||||
(ps:chain local-storage (set-item "spectrum-style" style-name))
|
||||
(ps:chain console (log (+ "Spectrum style changed to: " style-name)))))
|
||||
|
||||
(defun get-available-styles ()
|
||||
"Return array of available visualization styles"
|
||||
(array "bars" "wave" "dots"))
|
||||
|
||||
;; Initialize when audio starts playing
|
||||
(ps:chain document (add-event-listener "DOMContentLoaded"
|
||||
(lambda ()
|
||||
;; Load saved theme and style preferences
|
||||
(let ((saved-theme (ps:chain local-storage (get-item "spectrum-theme")))
|
||||
(saved-style (ps:chain local-storage (get-item "spectrum-style"))))
|
||||
(when (and saved-theme (ps:getprop *themes* saved-theme))
|
||||
(setf *current-theme* saved-theme))
|
||||
(when (and saved-style (or (= saved-style "bars") (= saved-style "wave") (= saved-style "dots")))
|
||||
(setf *current-style* saved-style))
|
||||
|
||||
;; Update UI selectors, canvas border, and dropdown colors
|
||||
(let ((theme-selector (ps:chain document (get-element-by-id "spectrum-theme-selector")))
|
||||
(style-selector (ps:chain document (get-element-by-id "spectrum-style-selector")))
|
||||
(canvas (ps:chain document (get-element-by-id "spectrum-canvas")))
|
||||
(theme (ps:getprop *themes* *current-theme*)))
|
||||
(when theme-selector
|
||||
(setf (ps:@ theme-selector value) *current-theme*)
|
||||
(setf (ps:@ theme-selector style color) (ps:@ theme top))
|
||||
(setf (ps:@ theme-selector style border-color) (ps:@ theme top)))
|
||||
(when style-selector
|
||||
(setf (ps:@ style-selector value) *current-style*)
|
||||
(setf (ps:@ style-selector style color) (ps:@ theme top))
|
||||
(setf (ps:@ style-selector style border-color) (ps:@ theme top)))
|
||||
|
||||
;; Set initial canvas border color
|
||||
(when canvas
|
||||
(setf (ps:@ canvas style border-color) (ps:@ theme top)))))
|
||||
|
||||
(let ((audio-element (or (ps:chain document (get-element-by-id "live-audio"))
|
||||
(ps:chain document (get-element-by-id "persistent-audio")))))
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
;;;; users.lisp - ParenScript version of users.js
|
||||
;;;; User management page for admins
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *users-js*
|
||||
(ps:ps*
|
||||
'(progn
|
||||
|
||||
;; Load user stats
|
||||
(defun load-user-stats ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user-stats")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when (and (= (ps:@ data status) "success") (ps:@ data stats))
|
||||
(let ((stats (ps:@ data stats)))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "total-users")) text-content)
|
||||
(or (ps:getprop stats "total-users") 0))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "active-users")) text-content)
|
||||
(or (ps:getprop stats "active-users") 0))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "admin-users")) text-content)
|
||||
(or (ps:getprop stats "admins") 0))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "dj-users")) text-content)
|
||||
(or (ps:getprop stats "djs") 0)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading user stats:" error))))))
|
||||
|
||||
;; Load users list
|
||||
(defun load-users ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/users")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when (= (ps:@ data status) "success")
|
||||
(show-users-table (ps:@ data users))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "users-list-section")) style display) "block")))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading users:" error))
|
||||
(alert "Error loading users. Please try again.")))))
|
||||
|
||||
;; Show users table
|
||||
(defun show-users-table (users)
|
||||
(let ((container (ps:chain document (get-element-by-id "users-container")))
|
||||
(users-html (ps:chain users
|
||||
(map (lambda (user)
|
||||
(+ "<tr>"
|
||||
"<td>" (ps:@ user username) "</td>"
|
||||
"<td>" (ps:@ user email) "</td>"
|
||||
"<td>"
|
||||
"<select onchange=\"updateUserRole('" (ps:@ user id) "', this.value)\">"
|
||||
"<option value=\"listener\" " (if (= (ps:@ user role) "listener") "selected" "") ">Listener</option>"
|
||||
"<option value=\"dj\" " (if (= (ps:@ user role) "dj") "selected" "") ">DJ</option>"
|
||||
"<option value=\"admin\" " (if (= (ps:@ user role) "admin") "selected" "") ">Admin</option>"
|
||||
"</select>"
|
||||
"</td>"
|
||||
"<td>" (if (ps:@ user active) "✅ Active" "❌ Inactive") "</td>"
|
||||
"<td>" (if (ps:getprop user "last-login")
|
||||
(ps:chain (ps:new (-date (* (ps:getprop user "last-login") 1000))) (to-locale-string))
|
||||
"Never") "</td>"
|
||||
"<td class=\"user-actions\">"
|
||||
(if (ps:@ user active)
|
||||
(+ "<button class=\"btn btn-danger\" onclick=\"deactivateUser('" (ps:@ user id) "')\">Deactivate</button>")
|
||||
(+ "<button class=\"btn btn-success\" onclick=\"activateUser('" (ps:@ user id) "')\">Activate</button>"))
|
||||
"</td>"
|
||||
"</tr>")))
|
||||
(join ""))))
|
||||
(setf (ps:@ container inner-h-t-m-l)
|
||||
(+ "<table class=\"users-table\">"
|
||||
"<thead>"
|
||||
"<tr>"
|
||||
"<th>Username</th>"
|
||||
"<th>Email</th>"
|
||||
"<th>Role</th>"
|
||||
"<th>Status</th>"
|
||||
"<th>Last Login</th>"
|
||||
"<th>Actions</th>"
|
||||
"</tr>"
|
||||
"</thead>"
|
||||
"<tbody>"
|
||||
users-html
|
||||
"</tbody>"
|
||||
"</table>"
|
||||
"<button class=\"btn btn-secondary\" onclick=\"hideUsersTable()\">Close</button>"))))
|
||||
|
||||
(defun hide-users-table ()
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "users-list-section")) style display) "none"))
|
||||
|
||||
;; Update user role
|
||||
(defun update-user-role (user-id new-role)
|
||||
(let ((form-data (ps:new (-form-data))))
|
||||
(ps:chain form-data (append "user-id" user-id))
|
||||
(ps:chain form-data (append "role" new-role))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/role"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Handle Radiance API data wrapping
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(load-user-stats)
|
||||
(alert (ps:@ data message)))
|
||||
(alert (+ "Error updating user role: " (ps:@ data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error updating user role:" error))
|
||||
(alert "Error updating user role. Please try again."))))))
|
||||
|
||||
;; Deactivate user
|
||||
(defun deactivate-user (user-id)
|
||||
(when (not (confirm "Are you sure you want to deactivate this user?"))
|
||||
(return))
|
||||
|
||||
(let ((form-data (ps:new (-form-data))))
|
||||
(ps:chain form-data (append "user-id" user-id))
|
||||
(ps:chain form-data (append "active" 0))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/activate"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Handle Radiance API data wrapping
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(load-users)
|
||||
(load-user-stats)
|
||||
(alert (ps:@ data message)))
|
||||
(alert (+ "Error deactivating user: " (ps:@ data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error deactivating user:" error))
|
||||
(alert "Error deactivating user. Please try again."))))))
|
||||
|
||||
;; Activate user
|
||||
(defun activate-user (user-id)
|
||||
(when (not (confirm "Are you sure you want to activate this user?"))
|
||||
(return))
|
||||
|
||||
(let ((form-data (ps:new (-form-data))))
|
||||
(ps:chain form-data (append "user-id" user-id))
|
||||
(ps:chain form-data (append "active" 1))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/activate"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Handle Radiance API data wrapping
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(load-users)
|
||||
(load-user-stats)
|
||||
(alert (ps:@ data message)))
|
||||
(alert (+ "Error activating user: " (ps:@ data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error activating user:" error))
|
||||
(alert "Error activating user. Please try again."))))))
|
||||
|
||||
;; Toggle create user form
|
||||
(defun toggle-create-user-form ()
|
||||
(let ((form (ps:chain document (get-element-by-id "create-user-form"))))
|
||||
(if (= (ps:@ form style display) "none")
|
||||
(progn
|
||||
(setf (ps:@ form style display) "block")
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "new-username")) value) "")
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "new-email")) value) "")
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "new-password")) value) "")
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "new-role")) value) "listener"))
|
||||
(setf (ps:@ form style display) "none"))))
|
||||
|
||||
;; Create new user
|
||||
(defun create-new-user (event)
|
||||
(ps:chain event (prevent-default))
|
||||
|
||||
(let ((username (ps:@ (ps:chain document (get-element-by-id "new-username")) value))
|
||||
(email (ps:@ (ps:chain document (get-element-by-id "new-email")) value))
|
||||
(password (ps:@ (ps:chain document (get-element-by-id "new-password")) value))
|
||||
(role (ps:@ (ps:chain document (get-element-by-id "new-role")) value))
|
||||
(form-data (ps:new (-form-data))))
|
||||
|
||||
(ps:chain form-data (append "username" username))
|
||||
(ps:chain form-data (append "email" email))
|
||||
(ps:chain form-data (append "password" password))
|
||||
(ps:chain form-data (append "role" role))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/users/create"
|
||||
(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 (+ "User \"" username "\" created successfully!"))
|
||||
(toggle-create-user-form)
|
||||
(load-user-stats)
|
||||
(load-users))
|
||||
(alert (+ "Error creating user: " (or (ps:@ data message) (ps:@ result message))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error creating user:" error))
|
||||
(alert "Error creating user. Please try again."))))))
|
||||
|
||||
;; Initialize on page load
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
load-user-stats))
|
||||
|
||||
;; Update user stats every 30 seconds
|
||||
(set-interval load-user-stats 30000)))
|
||||
"Compiled JavaScript for users management - generated at load time")
|
||||
|
||||
(defun generate-users-js ()
|
||||
"Return the pre-compiled JavaScript for users page"
|
||||
*users-js*)
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
#EXTM3U
|
||||
#EXTINF:370,Vector Lovers - City Lights From a Train
|
||||
/app/music/Vector Lovers/City Lights From a Train.flac
|
||||
#EXTINF:400,The Black Dog - Psil-Cosyin
|
||||
/app/music/The Black Dog/Psil-Cosyin.flac
|
||||
#EXTINF:320,Plaid - Eyen
|
||||
/app/music/Plaid/Eyen.flac
|
||||
#EXTINF:330,ISAN - Birds Over Barges
|
||||
/app/music/ISAN/Birds Over Barges.flac
|
||||
#EXTINF:360,Ochre - Bluebottle Farm
|
||||
/app/music/Ochre/Bluebottle Farm.flac
|
||||
#EXTINF:390,Arovane - Theme
|
||||
/app/music/Arovane/Theme.flac
|
||||
#EXTINF:380,Proem - Deep Like Airline Failure
|
||||
/app/music/Proem/Deep Like Airline Failure.flac
|
||||
#EXTINF:310,Solvent - My Radio (Remix)
|
||||
/app/music/Solvent/My Radio (Remix).flac
|
||||
#EXTINF:350,Bochum Welt - Marylebone (7th)
|
||||
/app/music/Bochum Welt/Marylebone (7th).flac
|
||||
#EXTINF:290,Mrs Jynx - Shibuya Lullaby
|
||||
/app/music/Mrs Jynx/Shibuya Lullaby.flac
|
||||
#EXTINF:340,Kettel - Whisper Me Wishes
|
||||
/app/music/Kettel/Whisper Me Wishes.flac
|
||||
#EXTINF:360,Christ. - Perlandine Friday
|
||||
/app/music/Christ./Perlandine Friday.flac
|
||||
#EXTINF:330,Cepia - Ithaca
|
||||
/app/music/Cepia/Ithaca.flac
|
||||
#EXTINF:340,Datassette - Vacuform
|
||||
/app/music/Datassette/Vacuform.flac
|
||||
#EXTINF:390,Plant43 - Dreams of the Sentient City
|
||||
/app/music/Plant43/Dreams of the Sentient City.flac
|
||||
#EXTINF:410,Claro Intelecto - Peace of Mind (Electrosoul)
|
||||
/app/music/Claro Intelecto/Peace of Mind (Electrosoul).flac
|
||||
#EXTINF:430,E.R.P. - Evoked
|
||||
/app/music/E.R.P./Evoked.flac
|
||||
#EXTINF:310,Der Zyklus - Formenverwandler
|
||||
/app/music/Der Zyklus/Formenverwandler.flac
|
||||
#EXTINF:330,Dopplereffekt - Infophysix
|
||||
/app/music/Dopplereffekt/Infophysix.flac
|
||||
#EXTINF:350,Drexciya - Wavejumper
|
||||
/app/music/Drexciya/Wavejumper.flac
|
||||
#EXTINF:375,The Other People Place - Sorrow & A Cup of Joe
|
||||
/app/music/The Other People Place/Sorrow & A Cup of Joe.flac
|
||||
#EXTINF:340,Arpanet - Wireless Internet
|
||||
/app/music/Arpanet/Wireless Internet.flac
|
||||
#EXTINF:380,Legowelt - Sturmvogel
|
||||
/app/music/Legowelt/Sturmvogel.flac
|
||||
#EXTINF:310,DMX Krew - Space Paranoia
|
||||
/app/music/DMX Krew/Space Paranoia.flac
|
||||
#EXTINF:360,Skywave Theory - Nova Drift
|
||||
/app/music/Skywave Theory/Nova Drift.flac
|
||||
#EXTINF:460,Pye Corner Audio - Transmission Four
|
||||
/app/music/Pye Corner Audio/Transmission Four.flac
|
||||
#EXTINF:390,B12 - Heaven Sent
|
||||
/app/music/B12/Heaven Sent.flac
|
||||
#EXTINF:450,Higher Intelligence Agency - Tortoise
|
||||
/app/music/Higher Intelligence Agency/Tortoise.flac
|
||||
#EXTINF:420,Biosphere - Kobresia
|
||||
/app/music/Biosphere/Kobresia.flac
|
||||
#EXTINF:870,Global Communication - 14:31
|
||||
/app/music/Global Communication/14:31.flac
|
||||
#EXTINF:500,Monolake - Cyan
|
||||
/app/music/Monolake/Cyan.flac
|
||||
#EXTINF:660,Deepchord - Electromagnetic
|
||||
/app/music/Deepchord/Electromagnetic.flac
|
||||
#EXTINF:1020,GAS - Pop 4
|
||||
/app/music/GAS/Pop 4.flac
|
||||
#EXTINF:600,Yagya - Rigning Nýju
|
||||
/app/music/Yagya/Rigning Nýju.flac
|
||||
#EXTINF:990,Voices From The Lake - Velo di Maya
|
||||
/app/music/Voices From The Lake/Velo di Maya.flac
|
||||
#EXTINF:3720,ASC - Time Heals All
|
||||
/app/music/ASC/Time Heals All.flac
|
||||
#EXTINF:540,36 - Room 237
|
||||
/app/music/36/Room 237.flac
|
||||
#EXTINF:900,Loscil - Endless Falls
|
||||
/app/music/Loscil/Endless Falls.flac
|
||||
#EXTINF:450,Kiasmos - Looped
|
||||
/app/music/Kiasmos/Looped.flac
|
||||
#EXTINF:590,Underworld - Rez
|
||||
/app/music/Underworld/Rez.flac
|
||||
#EXTINF:570,Orbital - Halcyon + On + On
|
||||
/app/music/Orbital/Halcyon + On + On.flac
|
||||
#EXTINF:1080,The Orb - A Huge Ever Growing Pulsating Brain
|
||||
/app/music/The Orb/A Huge Ever Growing Pulsating Brain.flac
|
||||
#EXTINF:360,Autechre - Slip
|
||||
/app/music/Autechre/Slip.flac
|
||||
#EXTINF:400,Labradford - S (Mi Media Naranja)
|
||||
/app/music/Labradford/S (Mi Media Naranja).flac
|
||||
#EXTINF:350,Vector Lovers - Rusting Cars and Wildflowers
|
||||
/app/music/Vector Lovers/Rusting Cars and Wildflowers.flac
|
||||
#EXTINF:390,The Black Dog - Raxmus
|
||||
/app/music/The Black Dog/Raxmus.flac
|
||||
#EXTINF:315,Plaid - Hawkmoth
|
||||
/app/music/Plaid/Hawkmoth.flac
|
||||
#EXTINF:320,ISAN - What This Button Did
|
||||
/app/music/ISAN/What This Button Did.flac
|
||||
#EXTINF:370,Ochre - Circadies
|
||||
/app/music/Ochre/Circadies.flac
|
||||
#EXTINF:420,Arovane - Tides
|
||||
/app/music/Arovane/Tides.flac
|
||||
#EXTINF:370,Proem - Nothing is as It Seems
|
||||
/app/music/Proem/Nothing is as It Seems.flac
|
||||
#EXTINF:300,Solvent - Loss For Words
|
||||
/app/music/Solvent/Loss For Words.flac
|
||||
#EXTINF:340,Bochum Welt - Saint (77sunset)
|
||||
/app/music/Bochum Welt/Saint (77sunset).flac
|
||||
#EXTINF:280,Mrs Jynx - Stay Home
|
||||
/app/music/Mrs Jynx/Stay Home.flac
|
||||
#EXTINF:330,Kettel - Church
|
||||
/app/music/Kettel/Church.flac
|
||||
#EXTINF:370,Christ. - Cordate
|
||||
/app/music/Christ./Cordate.flac
|
||||
#EXTINF:350,Datassette - Computers Elevate
|
||||
/app/music/Datassette/Computers Elevate.flac
|
||||
#EXTINF:420,Plant43 - The Cold Surveyor
|
||||
/app/music/Plant43/The Cold Surveyor.flac
|
||||
#EXTINF:380,Claro Intelecto - Section
|
||||
/app/music/Claro Intelecto/Section.flac
|
||||
#EXTINF:440,E.R.P. - Vox Automaton
|
||||
/app/music/E.R.P./Vox Automaton.flac
|
||||
#EXTINF:300,Dopplereffekt - Z-Boson
|
||||
/app/music/Dopplereffekt/Z-Boson.flac
|
||||
#EXTINF:380,Drexciya - Digital Tsunami
|
||||
/app/music/Drexciya/Digital Tsunami.flac
|
||||
#EXTINF:350,The Other People Place - You Said You Want Me
|
||||
/app/music/The Other People Place/You Said You Want Me.flac
|
||||
#EXTINF:370,Legowelt - Star Gazing
|
||||
/app/music/Legowelt/Star Gazing.flac
|
||||
#EXTINF:440,Pye Corner Audio - Electronic Rhythm Number 3
|
||||
/app/music/Pye Corner Audio/Electronic Rhythm Number 3.flac
|
||||
#EXTINF:460,B12 - Infinite Lites (Classic Mix)
|
||||
/app/music/B12/Infinite Lites (Classic Mix).flac
|
||||
#EXTINF:390,Biosphere - The Things I Tell You
|
||||
/app/music/Biosphere/The Things I Tell You.flac
|
||||
#EXTINF:580,Global Communication - 9:39
|
||||
/app/music/Global Communication/9:39.flac
|
||||
#EXTINF:460,Monolake - T-Channel
|
||||
/app/music/Monolake/T-Channel.flac
|
||||
#EXTINF:690,Deepchord - Vantage Isle (Variant)
|
||||
/app/music/Deepchord/Vantage Isle (Variant).flac
|
||||
#EXTINF:840,GAS - Königsforst 5
|
||||
/app/music/GAS/Königsforst 5.flac
|
||||
#EXTINF:520,Yagya - The Salt on Her Cheeks
|
||||
/app/music/Yagya/The Salt on Her Cheeks.flac
|
||||
#EXTINF:720,Voices From The Lake - Dream State
|
||||
/app/music/Voices From The Lake/Dream State.flac
|
||||
#EXTINF:510,36 - Night Rain
|
||||
/app/music/36/Night Rain.flac
|
||||
#EXTINF:470,Loscil - First Narrows
|
||||
/app/music/Loscil/First Narrows.flac
|
||||
#EXTINF:400,Kiasmos - Burnt
|
||||
/app/music/Kiasmos/Burnt.flac
|
||||
#EXTINF:570,Underworld - Jumbo (Extended)
|
||||
/app/music/Underworld/Jumbo (Extended).flac
|
||||
#EXTINF:480,Orbital - Belfast
|
||||
/app/music/Orbital/Belfast.flac
|
||||
#EXTINF:540,The Orb - Little Fluffy Clouds (Ambient Mix)
|
||||
/app/music/The Orb/Little Fluffy Clouds (Ambient Mix).flac
|
||||
#EXTINF:390,Autechre - Nine
|
||||
/app/music/Autechre/Nine.flac
|
||||
#EXTINF:380,Labradford - G (Mi Media Naranja)
|
||||
/app/music/Labradford/G (Mi Media Naranja).flac
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
#EXTM3U
|
||||
#EXTINF:370,Vector Lovers - City Lights From a Train
|
||||
Vector Lovers/City Lights From a Train.flac
|
||||
#EXTINF:400,The Black Dog - Psil-Cosyin
|
||||
The Black Dog/Psil-Cosyin.flac
|
||||
#EXTINF:320,Plaid - Eyen
|
||||
Plaid/Eyen.flac
|
||||
#EXTINF:330,ISAN - Birds Over Barges
|
||||
ISAN/Birds Over Barges.flac
|
||||
#EXTINF:360,Ochre - Bluebottle Farm
|
||||
Ochre/Bluebottle Farm.flac
|
||||
#EXTINF:390,Arovane - Theme
|
||||
Arovane/Theme.flac
|
||||
#EXTINF:380,Proem - Deep Like Airline Failure
|
||||
Proem/Deep Like Airline Failure.flac
|
||||
#EXTINF:310,Solvent - My Radio (Remix)
|
||||
Solvent/My Radio (Remix).flac
|
||||
#EXTINF:350,Bochum Welt - Marylebone (7th)
|
||||
Bochum Welt/Marylebone (7th).flac
|
||||
#EXTINF:290,Mrs Jynx - Shibuya Lullaby
|
||||
Mrs Jynx/Shibuya Lullaby.flac
|
||||
#EXTINF:340,Kettel - Whisper Me Wishes
|
||||
Kettel/Whisper Me Wishes.flac
|
||||
#EXTINF:360,Christ. - Perlandine Friday
|
||||
Christ./Perlandine Friday.flac
|
||||
#EXTINF:330,Cepia - Ithaca
|
||||
Cepia/Ithaca.flac
|
||||
#EXTINF:340,Datassette - Vacuform
|
||||
Datassette/Vacuform.flac
|
||||
#EXTINF:390,Plant43 - Dreams of the Sentient City
|
||||
Plant43/Dreams of the Sentient City.flac
|
||||
#EXTINF:410,Claro Intelecto - Peace of Mind (Electrosoul)
|
||||
Claro Intelecto/Peace of Mind (Electrosoul).flac
|
||||
#EXTINF:430,E.R.P. - Evoked
|
||||
E.R.P./Evoked.flac
|
||||
#EXTINF:310,Der Zyklus - Formenverwandler
|
||||
Der Zyklus/Formenverwandler.flac
|
||||
#EXTINF:330,Dopplereffekt - Infophysix
|
||||
Dopplereffekt/Infophysix.flac
|
||||
#EXTINF:350,Drexciya - Wavejumper
|
||||
Drexciya/Wavejumper.flac
|
||||
#EXTINF:375,The Other People Place - Sorrow & A Cup of Joe
|
||||
The Other People Place/Sorrow & A Cup of Joe.flac
|
||||
#EXTINF:340,Arpanet - Wireless Internet
|
||||
Arpanet/Wireless Internet.flac
|
||||
#EXTINF:380,Legowelt - Sturmvogel
|
||||
Legowelt/Sturmvogel.flac
|
||||
#EXTINF:310,DMX Krew - Space Paranoia
|
||||
DMX Krew/Space Paranoia.flac
|
||||
#EXTINF:360,Skywave Theory - Nova Drift
|
||||
Skywave Theory/Nova Drift.flac
|
||||
#EXTINF:460,Pye Corner Audio - Transmission Four
|
||||
Pye Corner Audio/Transmission Four.flac
|
||||
#EXTINF:390,B12 - Heaven Sent
|
||||
B12/Heaven Sent.flac
|
||||
#EXTINF:450,Higher Intelligence Agency - Tortoise
|
||||
Higher Intelligence Agency/Tortoise.flac
|
||||
#EXTINF:420,Biosphere - Kobresia
|
||||
Biosphere/Kobresia.flac
|
||||
#EXTINF:870,Global Communication - 14:31
|
||||
Global Communication/14:31.flac
|
||||
#EXTINF:500,Monolake - Cyan
|
||||
Monolake/Cyan.flac
|
||||
#EXTINF:660,Deepchord - Electromagnetic
|
||||
Deepchord/Electromagnetic.flac
|
||||
#EXTINF:1020,GAS - Pop 4
|
||||
GAS/Pop 4.flac
|
||||
#EXTINF:600,Yagya - Rigning Nýju
|
||||
Yagya/Rigning Nýju.flac
|
||||
#EXTINF:990,Voices From The Lake - Velo di Maya
|
||||
Voices From The Lake/Velo di Maya.flac
|
||||
#EXTINF:3720,ASC - Time Heals All
|
||||
ASC/Time Heals All.flac
|
||||
#EXTINF:540,36 - Room 237
|
||||
36/Room 237.flac
|
||||
#EXTINF:900,Loscil - Endless Falls
|
||||
Loscil/Endless Falls.flac
|
||||
#EXTINF:450,Kiasmos - Looped
|
||||
Kiasmos/Looped.flac
|
||||
#EXTINF:590,Underworld - Rez
|
||||
Underworld/Rez.flac
|
||||
#EXTINF:570,Orbital - Halcyon + On + On
|
||||
Orbital/Halcyon + On + On.flac
|
||||
#EXTINF:1080,The Orb - A Huge Ever Growing Pulsating Brain
|
||||
The Orb/A Huge Ever Growing Pulsating Brain.flac
|
||||
#EXTINF:360,Autechre - Slip
|
||||
Autechre/Slip.flac
|
||||
#EXTINF:400,Labradford - S (Mi Media Naranja)
|
||||
Labradford/S (Mi Media Naranja).flac
|
||||
#EXTINF:350,Vector Lovers - Rusting Cars and Wildflowers
|
||||
Vector Lovers/Rusting Cars and Wildflowers.flac
|
||||
#EXTINF:390,The Black Dog - Raxmus
|
||||
The Black Dog/Raxmus.flac
|
||||
#EXTINF:315,Plaid - Hawkmoth
|
||||
Plaid/Hawkmoth.flac
|
||||
#EXTINF:320,ISAN - What This Button Did
|
||||
ISAN/What This Button Did.flac
|
||||
#EXTINF:370,Ochre - Circadies
|
||||
Ochre/Circadies.flac
|
||||
#EXTINF:420,Arovane - Tides
|
||||
Arovane/Tides.flac
|
||||
#EXTINF:370,Proem - Nothing is as It Seems
|
||||
Proem/Nothing is as It Seems.flac
|
||||
#EXTINF:300,Solvent - Loss For Words
|
||||
Solvent/Loss For Words.flac
|
||||
#EXTINF:340,Bochum Welt - Saint (77sunset)
|
||||
Bochum Welt/Saint (77sunset).flac
|
||||
#EXTINF:280,Mrs Jynx - Stay Home
|
||||
Mrs Jynx/Stay Home.flac
|
||||
#EXTINF:330,Kettel - Church
|
||||
Kettel/Church.flac
|
||||
#EXTINF:370,Christ. - Cordate
|
||||
Christ./Cordate.flac
|
||||
#EXTINF:350,Datassette - Computers Elevate
|
||||
Datassette/Computers Elevate.flac
|
||||
#EXTINF:420,Plant43 - The Cold Surveyor
|
||||
Plant43/The Cold Surveyor.flac
|
||||
#EXTINF:380,Claro Intelecto - Section
|
||||
Claro Intelecto/Section.flac
|
||||
#EXTINF:440,E.R.P. - Vox Automaton
|
||||
E.R.P./Vox Automaton.flac
|
||||
#EXTINF:300,Dopplereffekt - Z-Boson
|
||||
Dopplereffekt/Z-Boson.flac
|
||||
#EXTINF:380,Drexciya - Digital Tsunami
|
||||
Drexciya/Digital Tsunami.flac
|
||||
#EXTINF:350,The Other People Place - You Said You Want Me
|
||||
The Other People Place/You Said You Want Me.flac
|
||||
#EXTINF:370,Legowelt - Star Gazing
|
||||
Legowelt/Star Gazing.flac
|
||||
#EXTINF:440,Pye Corner Audio - Electronic Rhythm Number 3
|
||||
Pye Corner Audio/Electronic Rhythm Number 3.flac
|
||||
#EXTINF:460,B12 - Infinite Lites (Classic Mix)
|
||||
B12/Infinite Lites (Classic Mix).flac
|
||||
#EXTINF:390,Biosphere - The Things I Tell You
|
||||
Biosphere/The Things I Tell You.flac
|
||||
#EXTINF:580,Global Communication - 9:39
|
||||
Global Communication/9:39.flac
|
||||
#EXTINF:460,Monolake - T-Channel
|
||||
Monolake/T-Channel.flac
|
||||
#EXTINF:690,Deepchord - Vantage Isle (Variant)
|
||||
Deepchord/Vantage Isle (Variant).flac
|
||||
#EXTINF:840,GAS - Königsforst 5
|
||||
GAS/Königsforst 5.flac
|
||||
#EXTINF:520,Yagya - The Salt on Her Cheeks
|
||||
Yagya/The Salt on Her Cheeks.flac
|
||||
#EXTINF:720,Voices From The Lake - Dream State
|
||||
Voices From The Lake/Dream State.flac
|
||||
#EXTINF:510,36 - Night Rain
|
||||
36/Night Rain.flac
|
||||
#EXTINF:470,Loscil - First Narrows
|
||||
Loscil/First Narrows.flac
|
||||
#EXTINF:400,Kiasmos - Burnt
|
||||
Kiasmos/Burnt.flac
|
||||
#EXTINF:570,Underworld - Jumbo (Extended)
|
||||
Underworld/Jumbo (Extended).flac
|
||||
#EXTINF:480,Orbital - Belfast
|
||||
Orbital/Belfast.flac
|
||||
#EXTINF:540,The Orb - Little Fluffy Clouds (Ambient Mix)
|
||||
The Orb/Little Fluffy Clouds (Ambient Mix).flac
|
||||
#EXTINF:390,Autechre - Nine
|
||||
Autechre/Nine.flac
|
||||
#EXTINF:380,Labradford - G (Mi Media Naranja)
|
||||
Labradford/G (Mi Media Naranja).flac
|
||||
Binary file not shown.
|
|
@ -0,0 +1,80 @@
|
|||
#+TITLE: Asteroid Low Orbit Playlist
|
||||
#+AUTHOR: Glenn
|
||||
#+DATE: 2025-11-06
|
||||
|
||||
* Files
|
||||
|
||||
- *Asteroid-Low-Orbit.m3u* - Original playlist with relative paths
|
||||
- *Asteroid-Low-Orbit-DOCKER.m3u* - Ready for VPS deployment (Docker container paths)
|
||||
|
||||
* For VPS Deployment
|
||||
|
||||
The =Asteroid-Low-Orbit-DOCKER.m3u= file is ready to use on the VPS (b612).
|
||||
|
||||
** Installation Steps
|
||||
|
||||
1. *Copy the file to the VPS:*
|
||||
|
||||
#+begin_src bash
|
||||
scp scripts/Asteroid-Low-Orbit-DOCKER.m3u glenneth@b612:~/asteroid/stream-queue.m3u
|
||||
#+end_src
|
||||
|
||||
2. *Ensure music files exist on VPS:*
|
||||
- Music should be in =/home/glenneth/Music/=
|
||||
- The directory structure should match the paths in the playlist
|
||||
- Example: =/home/glenneth/Music/Vector Lovers/City Lights From a Train.flac=
|
||||
|
||||
3. *Restart Liquidsoap container:*
|
||||
|
||||
#+begin_src bash
|
||||
cd ~/asteroid/docker
|
||||
docker-compose restart liquidsoap
|
||||
#+end_src
|
||||
|
||||
** How It Works
|
||||
|
||||
- *Host path*: =/home/glenneth/Music/= (on VPS)
|
||||
- *Container path*: =/app/music/= (inside Liquidsoap Docker container)
|
||||
- *Playlist paths*: Use =/app/music/...= because Liquidsoap reads from inside the container
|
||||
|
||||
The docker-compose.yml mounts the music directory:
|
||||
|
||||
#+begin_src yaml
|
||||
volumes:
|
||||
- ${MUSIC_LIBRARY:-../music/library}:/app/music:ro
|
||||
#+end_src
|
||||
|
||||
* Playlist Contents
|
||||
|
||||
This playlist contains ~50 tracks of ambient/IDM music curated for Asteroid Radio's "Low Orbit" programming block.
|
||||
|
||||
** Artists Featured
|
||||
|
||||
- Vector Lovers
|
||||
- The Black Dog
|
||||
- Plaid
|
||||
- ISAN
|
||||
- Ochre
|
||||
- Arovane
|
||||
- Proem
|
||||
- Solvent
|
||||
- Bochum Welt
|
||||
- Mrs Jynx
|
||||
- Kettel
|
||||
- Christ.
|
||||
- Cepia
|
||||
- Datassette
|
||||
- Plant43
|
||||
- Claro Intelecto
|
||||
- E.R.P.
|
||||
- Der Zyklus
|
||||
- Dopplereffekt
|
||||
- And more...
|
||||
|
||||
* Notes for Fade & easilok
|
||||
|
||||
- This playlist is ready to deploy to b612
|
||||
- All paths are formatted for the Docker container setup
|
||||
- Music files need to be present in =/home/glenneth/Music/= on the VPS
|
||||
- The playlist can be manually copied to replace =stream-queue.m3u= when ready
|
||||
- No changes to the main project repository required
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fix M3U file paths for VPS or Docker deployment
|
||||
Usage: python3 fix-m3u-paths.py input.m3u output.m3u [--docker|--vps]
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def fix_m3u_paths(input_file, output_file, mode='vps'):
|
||||
"""Convert relative paths to absolute paths for VPS or Docker"""
|
||||
|
||||
if mode == 'docker':
|
||||
base_path = '/app/music'
|
||||
else: # vps
|
||||
base_path = '/home/glenneth/Music'
|
||||
|
||||
with open(input_file, 'r', encoding='utf-8') as f_in:
|
||||
with open(output_file, 'w', encoding='utf-8') as f_out:
|
||||
for line in f_in:
|
||||
line = line.rstrip('\n')
|
||||
|
||||
# Keep #EXTM3U and #EXTINF lines as-is
|
||||
if line.startswith('#'):
|
||||
f_out.write(line + '\n')
|
||||
# Convert file paths
|
||||
elif line.strip():
|
||||
# Remove leading/trailing whitespace
|
||||
path = line.strip()
|
||||
# If it's already an absolute path, keep it
|
||||
if path.startswith('/'):
|
||||
f_out.write(path + '\n')
|
||||
else:
|
||||
# Make it absolute
|
||||
full_path = f"{base_path}/{path}"
|
||||
f_out.write(full_path + '\n')
|
||||
else:
|
||||
f_out.write('\n')
|
||||
|
||||
print(f"Converted {input_file} -> {output_file}")
|
||||
print(f"Mode: {mode}")
|
||||
print(f"Base path: {base_path}")
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python3 fix-m3u-paths.py input.m3u output.m3u [--docker|--vps]")
|
||||
print(" --docker: Use /app/music/ prefix (for Docker container)")
|
||||
print(" --vps: Use /home/glenneth/Music/ prefix (default)")
|
||||
sys.exit(1)
|
||||
|
||||
input_file = sys.argv[1]
|
||||
output_file = sys.argv[2]
|
||||
mode = 'vps'
|
||||
|
||||
if len(sys.argv) > 3:
|
||||
if sys.argv[3] == '--docker':
|
||||
mode = 'docker'
|
||||
elif sys.argv[3] == '--vps':
|
||||
mode = 'vps'
|
||||
|
||||
if not Path(input_file).exists():
|
||||
print(f"Error: Input file '{input_file}' not found")
|
||||
sys.exit(1)
|
||||
|
||||
fix_m3u_paths(input_file, output_file, mode)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
#!/bin/bash
|
||||
# Basic music library tree generator (no external tools required)
|
||||
# Shows file structure with sizes only
|
||||
# Usage: ./music-library-tree-basic.sh [music-directory] [output-file]
|
||||
|
||||
MUSIC_DIR="${1:-/home/glenneth/Music}"
|
||||
OUTPUT_FILE="${2:-music-library-tree.txt}"
|
||||
|
||||
# Check if music directory exists
|
||||
if [ ! -d "$MUSIC_DIR" ]; then
|
||||
echo "Error: Music directory '$MUSIC_DIR' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to format file size
|
||||
format_size() {
|
||||
local size=$1
|
||||
if [ $size -ge 1073741824 ]; then
|
||||
awk "BEGIN {printf \"%.1fG\", $size/1073741824}"
|
||||
elif [ $size -ge 1048576 ]; then
|
||||
awk "BEGIN {printf \"%.1fM\", $size/1048576}"
|
||||
elif [ $size -ge 1024 ]; then
|
||||
awk "BEGIN {printf \"%.0fK\", $size/1024}"
|
||||
else
|
||||
printf "%dB" $size
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to recursively build tree
|
||||
build_tree() {
|
||||
local dir="$1"
|
||||
local prefix="$2"
|
||||
|
||||
# Get all entries sorted
|
||||
local entries=()
|
||||
while IFS= read -r -d $'\0' entry; do
|
||||
entries+=("$entry")
|
||||
done < <(find "$dir" -maxdepth 1 -mindepth 1 -print0 2>/dev/null | sort -z)
|
||||
|
||||
# Separate directories and files
|
||||
local dirs=()
|
||||
local files=()
|
||||
|
||||
for entry in "${entries[@]}"; do
|
||||
if [ -d "$entry" ]; then
|
||||
dirs+=("$entry")
|
||||
else
|
||||
files+=("$entry")
|
||||
fi
|
||||
done
|
||||
|
||||
# Combine: directories first, then files
|
||||
local all_entries=("${dirs[@]}" "${files[@]}")
|
||||
local count=${#all_entries[@]}
|
||||
local index=0
|
||||
|
||||
for entry in "${all_entries[@]}"; do
|
||||
index=$((index + 1))
|
||||
local basename=$(basename "$entry")
|
||||
local is_last=false
|
||||
[ $index -eq $count ] && is_last=true
|
||||
|
||||
if [ -d "$entry" ]; then
|
||||
# Directory - count files inside
|
||||
local file_count=$(find "$entry" -type f 2>/dev/null | wc -l)
|
||||
if $is_last; then
|
||||
echo "${prefix}└── $basename/ ($file_count files)" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix} "
|
||||
else
|
||||
echo "${prefix}├── $basename/ ($file_count files)" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix}│ "
|
||||
fi
|
||||
else
|
||||
# File
|
||||
local ext="${basename##*.}"
|
||||
ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
|
||||
local size=$(stat -c%s "$entry" 2>/dev/null || stat -f%z "$entry" 2>/dev/null || echo "0")
|
||||
local size_fmt=$(format_size $size)
|
||||
|
||||
if [[ "$ext" =~ ^(mp3|flac|ogg|m4a|wav|aac|opus|wma)$ ]]; then
|
||||
if $is_last; then
|
||||
echo "${prefix}└── ♪ $basename ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── ♪ $basename ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
else
|
||||
if $is_last; then
|
||||
echo "${prefix}└── $basename ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── $basename ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
echo "Generating music library tree (basic mode - no duration info)..."
|
||||
|
||||
# Start generating the tree
|
||||
{
|
||||
echo "Music Library Tree"
|
||||
echo "=================="
|
||||
echo "Generated: $(date)"
|
||||
echo "Directory: $MUSIC_DIR"
|
||||
echo "Note: Duration info not available (requires mediainfo/ffprobe)"
|
||||
echo ""
|
||||
|
||||
# Count total files
|
||||
total_audio=$(find "$MUSIC_DIR" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.ogg" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.aac" -o -iname "*.opus" -o -iname "*.wma" \) 2>/dev/null | wc -l)
|
||||
total_dirs=$(find "$MUSIC_DIR" -type d 2>/dev/null | wc -l)
|
||||
total_size=$(du -sh "$MUSIC_DIR" 2>/dev/null | cut -f1)
|
||||
|
||||
echo "Total audio files: $total_audio"
|
||||
echo "Total directories: $total_dirs"
|
||||
echo "Total size: $total_size"
|
||||
echo ""
|
||||
|
||||
# Build the tree
|
||||
echo "$(basename "$MUSIC_DIR")/"
|
||||
} > "$OUTPUT_FILE"
|
||||
|
||||
build_tree "$MUSIC_DIR" ""
|
||||
|
||||
echo ""
|
||||
echo "Tree generated successfully!"
|
||||
echo "Output saved to: $OUTPUT_FILE"
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
#!/bin/bash
|
||||
# Simple music library tree generator using 'tree' command
|
||||
# Usage: ./music-library-tree-simple.sh [music-directory] [output-file]
|
||||
|
||||
MUSIC_DIR="${1:-/home/glenn/Projects/Code/asteroid/music}"
|
||||
OUTPUT_FILE="${2:-music-library-tree.txt}"
|
||||
|
||||
# Check if music directory exists
|
||||
if [ ! -d "$MUSIC_DIR" ]; then
|
||||
echo "Error: Music directory '$MUSIC_DIR' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if tree command is available
|
||||
if ! command -v tree &> /dev/null; then
|
||||
echo "Error: 'tree' command not found. Please install it:"
|
||||
echo " Ubuntu/Debian: sudo apt-get install tree"
|
||||
echo " CentOS/RHEL: sudo yum install tree"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Generating music library tree..."
|
||||
|
||||
# Generate header
|
||||
{
|
||||
echo "Music Library Tree"
|
||||
echo "=================="
|
||||
echo "Generated: $(date)"
|
||||
echo "Directory: $MUSIC_DIR"
|
||||
echo ""
|
||||
|
||||
# Count audio files
|
||||
total_audio=$(find "$MUSIC_DIR" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.ogg" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.aac" -o -iname "*.opus" -o -iname "*.wma" \) 2>/dev/null | wc -l)
|
||||
echo "Total audio files: $total_audio"
|
||||
echo ""
|
||||
|
||||
# Generate tree with file sizes
|
||||
tree -h -F --dirsfirst "$MUSIC_DIR"
|
||||
|
||||
} > "$OUTPUT_FILE"
|
||||
|
||||
echo ""
|
||||
echo "Tree generated successfully!"
|
||||
echo "Output saved to: $OUTPUT_FILE"
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
#!/bin/bash
|
||||
# Music library tree generator for VPS (no ffprobe required)
|
||||
# Usage: ./music-library-tree-vps.sh [music-directory] [output-file]
|
||||
|
||||
MUSIC_DIR="${1:-/home/glenneth/Music}"
|
||||
OUTPUT_FILE="${2:-music-library-tree.txt}"
|
||||
|
||||
# Check if music directory exists
|
||||
if [ ! -d "$MUSIC_DIR" ]; then
|
||||
echo "Error: Music directory '$MUSIC_DIR' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to get duration using available tools
|
||||
get_duration() {
|
||||
local file="$1"
|
||||
|
||||
# Try mediainfo first
|
||||
if command -v mediainfo &> /dev/null; then
|
||||
duration=$(mediainfo --Inform="General;%Duration%" "$file" 2>/dev/null)
|
||||
if [ -n "$duration" ] && [ "$duration" != "" ]; then
|
||||
# Convert milliseconds to minutes:seconds
|
||||
duration_sec=$((duration / 1000))
|
||||
printf "%02d:%02d" $((duration_sec/60)) $((duration_sec%60))
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try mp3info for MP3 files
|
||||
if [[ "$file" == *.mp3 ]] && command -v mp3info &> /dev/null; then
|
||||
duration=$(mp3info -p "%m:%02s" "$file" 2>/dev/null)
|
||||
if [ -n "$duration" ]; then
|
||||
echo "$duration"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try soxi (from sox package)
|
||||
if command -v soxi &> /dev/null; then
|
||||
duration=$(soxi -D "$file" 2>/dev/null)
|
||||
if [ -n "$duration" ]; then
|
||||
duration_sec=${duration%.*}
|
||||
printf "%02d:%02d" $((duration_sec/60)) $((duration_sec%60))
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# No duration available
|
||||
echo "--:--"
|
||||
}
|
||||
|
||||
# Function to format file size
|
||||
format_size() {
|
||||
local size=$1
|
||||
if [ $size -ge 1073741824 ]; then
|
||||
printf "%.1fG" $(awk "BEGIN {printf \"%.1f\", $size/1073741824}")
|
||||
elif [ $size -ge 1048576 ]; then
|
||||
printf "%.1fM" $(awk "BEGIN {printf \"%.1f\", $size/1048576}")
|
||||
elif [ $size -ge 1024 ]; then
|
||||
printf "%.0fK" $(awk "BEGIN {printf \"%.0f\", $size/1024}")
|
||||
else
|
||||
printf "%dB" $size
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to recursively build tree
|
||||
build_tree() {
|
||||
local dir="$1"
|
||||
local prefix="$2"
|
||||
|
||||
# Get all entries sorted (directories first, then files)
|
||||
local entries=($(find "$dir" -maxdepth 1 -mindepth 1 | sort))
|
||||
local dirs=()
|
||||
local files=()
|
||||
|
||||
# Separate directories and files
|
||||
for entry in "${entries[@]}"; do
|
||||
if [ -d "$entry" ]; then
|
||||
dirs+=("$entry")
|
||||
else
|
||||
files+=("$entry")
|
||||
fi
|
||||
done
|
||||
|
||||
# Combine: directories first, then files
|
||||
local all_entries=("${dirs[@]}" "${files[@]}")
|
||||
local count=${#all_entries[@]}
|
||||
local index=0
|
||||
|
||||
for entry in "${all_entries[@]}"; do
|
||||
index=$((index + 1))
|
||||
local basename=$(basename "$entry")
|
||||
local is_last=false
|
||||
[ $index -eq $count ] && is_last=true
|
||||
|
||||
if [ -d "$entry" ]; then
|
||||
# Directory
|
||||
if $is_last; then
|
||||
echo "${prefix}└── $basename/" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix} "
|
||||
else
|
||||
echo "${prefix}├── $basename/" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix}│ "
|
||||
fi
|
||||
else
|
||||
# File - check if it's an audio file
|
||||
local ext="${basename##*.}"
|
||||
ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
if [[ "$ext" =~ ^(mp3|flac|ogg|m4a|wav|aac|opus|wma)$ ]]; then
|
||||
local duration=$(get_duration "$entry")
|
||||
local size=$(stat -c%s "$entry" 2>/dev/null || stat -f%z "$entry" 2>/dev/null)
|
||||
local size_fmt=$(format_size $size)
|
||||
|
||||
if $is_last; then
|
||||
echo "${prefix}└── $basename [$duration] ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── $basename [$duration] ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
else
|
||||
# Non-audio file
|
||||
if $is_last; then
|
||||
echo "${prefix}└── $basename" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── $basename" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Detect available tools
|
||||
echo "Checking for available metadata tools..."
|
||||
TOOLS_AVAILABLE=""
|
||||
command -v mediainfo &> /dev/null && TOOLS_AVAILABLE="$TOOLS_AVAILABLE mediainfo"
|
||||
command -v mp3info &> /dev/null && TOOLS_AVAILABLE="$TOOLS_AVAILABLE mp3info"
|
||||
command -v soxi &> /dev/null && TOOLS_AVAILABLE="$TOOLS_AVAILABLE soxi"
|
||||
|
||||
if [ -z "$TOOLS_AVAILABLE" ]; then
|
||||
echo "Warning: No metadata tools found (mediainfo, mp3info, soxi)"
|
||||
echo "Duration information will not be available"
|
||||
else
|
||||
echo "Found tools:$TOOLS_AVAILABLE"
|
||||
fi
|
||||
|
||||
echo "Generating music library tree..."
|
||||
|
||||
# Start generating the tree
|
||||
{
|
||||
echo "Music Library Tree"
|
||||
echo "=================="
|
||||
echo "Generated: $(date)"
|
||||
echo "Directory: $MUSIC_DIR"
|
||||
echo "Tools available:$TOOLS_AVAILABLE"
|
||||
echo ""
|
||||
|
||||
# Count total files
|
||||
total_audio=$(find "$MUSIC_DIR" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.ogg" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.aac" -o -iname "*.opus" -o -iname "*.wma" \) 2>/dev/null | wc -l)
|
||||
echo "Total audio files: $total_audio"
|
||||
echo ""
|
||||
|
||||
# Build the tree
|
||||
echo "$(basename "$MUSIC_DIR")/"
|
||||
} > "$OUTPUT_FILE"
|
||||
|
||||
build_tree "$MUSIC_DIR" ""
|
||||
|
||||
echo ""
|
||||
echo "Tree generated successfully!"
|
||||
echo "Output saved to: $OUTPUT_FILE"
|
||||
echo "Total audio files: $(find "$MUSIC_DIR" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.ogg" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.aac" -o -iname "*.opus" -o -iname "*.wma" \) 2>/dev/null | wc -l)"
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a tree view of the music library with track durations
|
||||
Usage: python3 music-library-tree.py [music-directory] [output-file]
|
||||
|
||||
Requires: mutagen (install with: pip3 install mutagen)
|
||||
If mutagen not available, falls back to showing file info without duration
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Try to import mutagen for audio metadata
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
MUTAGEN_AVAILABLE = True
|
||||
except ImportError:
|
||||
MUTAGEN_AVAILABLE = False
|
||||
print("Warning: mutagen not installed. Duration info will not be available.")
|
||||
print("Install with: pip3 install mutagen")
|
||||
|
||||
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.m4a', '.wav', '.aac', '.opus', '.wma'}
|
||||
|
||||
def get_duration(file_path):
|
||||
"""Get duration of audio file using mutagen"""
|
||||
if not MUTAGEN_AVAILABLE:
|
||||
return "--:--"
|
||||
|
||||
try:
|
||||
audio = MutagenFile(str(file_path))
|
||||
if audio is not None and hasattr(audio.info, 'length'):
|
||||
duration_sec = int(audio.info.length)
|
||||
minutes = duration_sec // 60
|
||||
seconds = duration_sec % 60
|
||||
return f"{minutes:02d}:{seconds:02d}"
|
||||
except Exception:
|
||||
pass
|
||||
return "--:--"
|
||||
|
||||
def format_size(size):
|
||||
"""Format file size in human-readable format"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size < 1024.0:
|
||||
return f"{size:.2f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.2f} TB"
|
||||
|
||||
def build_tree(directory, output_file, prefix="", is_last=True):
|
||||
"""Recursively build tree structure"""
|
||||
try:
|
||||
entries = sorted(Path(directory).iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
||||
except PermissionError:
|
||||
return
|
||||
|
||||
for i, entry in enumerate(entries):
|
||||
is_last_entry = (i == len(entries) - 1)
|
||||
connector = "└── " if is_last_entry else "├── "
|
||||
|
||||
if entry.is_dir():
|
||||
output_file.write(f"{prefix}{connector}📁 {entry.name}/\n")
|
||||
extension = " " if is_last_entry else "│ "
|
||||
build_tree(entry, output_file, prefix + extension, is_last_entry)
|
||||
else:
|
||||
ext = entry.suffix.lower()
|
||||
if ext in AUDIO_EXTENSIONS:
|
||||
duration = get_duration(entry)
|
||||
size = entry.stat().st_size
|
||||
size_fmt = format_size(size)
|
||||
output_file.write(f"{prefix}{connector}🎵 {entry.name} [{duration}] ({size_fmt})\n")
|
||||
else:
|
||||
output_file.write(f"{prefix}{connector}📄 {entry.name}\n")
|
||||
|
||||
def main():
|
||||
music_dir = sys.argv[1] if len(sys.argv) > 1 else "/home/glenneth/Music"
|
||||
output_path = sys.argv[2] if len(sys.argv) > 2 else "music-library-tree.txt"
|
||||
|
||||
music_path = Path(music_dir)
|
||||
if not music_path.exists():
|
||||
print(f"Error: Music directory '{music_dir}' does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
print("Generating music library tree...")
|
||||
|
||||
# Count audio files
|
||||
audio_files = []
|
||||
for ext in AUDIO_EXTENSIONS:
|
||||
audio_files.extend(music_path.rglob(f"*{ext}"))
|
||||
total_audio = len(audio_files)
|
||||
|
||||
# Generate tree
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write("Music Library Tree\n")
|
||||
f.write("==================\n")
|
||||
f.write(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
f.write(f"Directory: {music_dir}\n")
|
||||
f.write(f"Mutagen available: {'Yes' if MUTAGEN_AVAILABLE else 'No (install with: pip3 install mutagen)'}\n")
|
||||
f.write(f"\nTotal audio files: {total_audio}\n\n")
|
||||
f.write(f"📁 {music_path.name}/\n")
|
||||
build_tree(music_path, f, "", True)
|
||||
|
||||
print(f"\nTree generated successfully!")
|
||||
print(f"Output saved to: {output_path}")
|
||||
print(f"Total audio files: {total_audio}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
#!/bin/bash
|
||||
# Generate a tree view of the music library with track durations
|
||||
# Usage: ./music-library-tree.sh [music-directory] [output-file]
|
||||
|
||||
MUSIC_DIR="${1:-/home/glenn/Projects/Code/asteroid/music}"
|
||||
OUTPUT_FILE="${2:-music-library-tree.txt}"
|
||||
|
||||
# Check if music directory exists
|
||||
if [ ! -d "$MUSIC_DIR" ]; then
|
||||
echo "Error: Music directory '$MUSIC_DIR' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to get duration using ffprobe
|
||||
get_duration() {
|
||||
local file="$1"
|
||||
if command -v ffprobe &> /dev/null; then
|
||||
duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file" 2>/dev/null)
|
||||
if [ -n "$duration" ]; then
|
||||
# Convert to minutes:seconds
|
||||
printf "%02d:%02d" $((${duration%.*}/60)) $((${duration%.*}%60))
|
||||
else
|
||||
echo "??:??"
|
||||
fi
|
||||
else
|
||||
echo "??:??"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to format file size
|
||||
format_size() {
|
||||
local size=$1
|
||||
if [ $size -ge 1073741824 ]; then
|
||||
printf "%.2f GB" $(echo "scale=2; $size/1073741824" | bc)
|
||||
elif [ $size -ge 1048576 ]; then
|
||||
printf "%.2f MB" $(echo "scale=2; $size/1048576" | bc)
|
||||
elif [ $size -ge 1024 ]; then
|
||||
printf "%.2f KB" $(echo "scale=2; $size/1024" | bc)
|
||||
else
|
||||
printf "%d B" $size
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to recursively build tree
|
||||
build_tree() {
|
||||
local dir="$1"
|
||||
local prefix="$2"
|
||||
local is_last="$3"
|
||||
|
||||
local entries=()
|
||||
while IFS= read -r -d '' entry; do
|
||||
entries+=("$entry")
|
||||
done < <(find "$dir" -maxdepth 1 -mindepth 1 -print0 | sort -z)
|
||||
|
||||
local count=${#entries[@]}
|
||||
local index=0
|
||||
|
||||
for entry in "${entries[@]}"; do
|
||||
index=$((index + 1))
|
||||
local basename=$(basename "$entry")
|
||||
local is_last_entry=false
|
||||
[ $index -eq $count ] && is_last_entry=true
|
||||
|
||||
if [ -d "$entry" ]; then
|
||||
# Directory
|
||||
if $is_last_entry; then
|
||||
echo "${prefix}└── 📁 $basename/" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix} " true
|
||||
else
|
||||
echo "${prefix}├── 📁 $basename/" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix}│ " false
|
||||
fi
|
||||
else
|
||||
# File - check if it's an audio file
|
||||
local ext="${basename##*.}"
|
||||
ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
if [[ "$ext" =~ ^(mp3|flac|ogg|m4a|wav|aac|opus|wma)$ ]]; then
|
||||
local duration=$(get_duration "$entry")
|
||||
local size=$(stat -f%z "$entry" 2>/dev/null || stat -c%s "$entry" 2>/dev/null)
|
||||
local size_fmt=$(format_size $size)
|
||||
|
||||
if $is_last_entry; then
|
||||
echo "${prefix}└── 🎵 $basename [$duration] ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── 🎵 $basename [$duration] ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
else
|
||||
# Non-audio file
|
||||
if $is_last_entry; then
|
||||
echo "${prefix}└── 📄 $basename" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── 📄 $basename" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Start generating the tree
|
||||
echo "Generating music library tree..."
|
||||
echo "Music Library Tree" > "$OUTPUT_FILE"
|
||||
echo "==================" >> "$OUTPUT_FILE"
|
||||
echo "Generated: $(date)" >> "$OUTPUT_FILE"
|
||||
echo "Directory: $MUSIC_DIR" >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
|
||||
# Count total files
|
||||
total_audio=$(find "$MUSIC_DIR" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.ogg" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.aac" -o -iname "*.opus" -o -iname "*.wma" \) | wc -l)
|
||||
echo "Total audio files: $total_audio" >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
|
||||
# Build the tree
|
||||
echo "📁 $(basename "$MUSIC_DIR")/" >> "$OUTPUT_FILE"
|
||||
build_tree "$MUSIC_DIR" "" true
|
||||
|
||||
echo ""
|
||||
echo "Tree generated successfully!"
|
||||
echo "Output saved to: $OUTPUT_FILE"
|
||||
echo "Total audio files: $total_audio"
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a tree view of the music library with track durations
|
||||
No external dependencies required - reads file headers directly
|
||||
Usage: python3 music-library-tree-standalone.py [music-directory] [output-file]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.m4a', '.wav', '.aac', '.opus', '.wma'}
|
||||
|
||||
def get_mp3_duration(file_path):
|
||||
"""Get MP3 duration by reading frame headers"""
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
# Skip ID3v2 tag if present
|
||||
header = f.read(10)
|
||||
if header[:3] == b'ID3':
|
||||
size = struct.unpack('>I', b'\x00' + header[6:9])[0]
|
||||
f.seek(size + 10)
|
||||
else:
|
||||
f.seek(0)
|
||||
|
||||
# Read first frame to get bitrate and sample rate
|
||||
frame_header = f.read(4)
|
||||
if len(frame_header) < 4:
|
||||
return None
|
||||
|
||||
# Parse MP3 frame header
|
||||
if frame_header[0] != 0xFF or (frame_header[1] & 0xE0) != 0xE0:
|
||||
return None
|
||||
|
||||
# Bitrate table (MPEG1 Layer III)
|
||||
bitrates = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0]
|
||||
bitrate_index = (frame_header[2] >> 4) & 0x0F
|
||||
bitrate = bitrates[bitrate_index] * 1000
|
||||
|
||||
if bitrate == 0:
|
||||
return None
|
||||
|
||||
# Get file size
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
|
||||
# Estimate duration
|
||||
duration = (file_size * 8) / bitrate
|
||||
return int(duration)
|
||||
except:
|
||||
return None
|
||||
|
||||
def get_flac_duration(file_path):
|
||||
"""Get FLAC duration by reading metadata block"""
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
# Check FLAC signature
|
||||
if f.read(4) != b'fLaC':
|
||||
return None
|
||||
|
||||
# Read metadata blocks
|
||||
while True:
|
||||
block_header = f.read(4)
|
||||
if len(block_header) < 4:
|
||||
return None
|
||||
|
||||
is_last = (block_header[0] & 0x80) != 0
|
||||
block_type = block_header[0] & 0x7F
|
||||
block_size = struct.unpack('>I', b'\x00' + block_header[1:4])[0]
|
||||
|
||||
if block_type == 0: # STREAMINFO
|
||||
streaminfo = f.read(block_size)
|
||||
# Sample rate is at bytes 10-13 (20 bits)
|
||||
sample_rate = (struct.unpack('>I', streaminfo[10:14])[0] >> 12) & 0xFFFFF
|
||||
# Total samples is at bytes 13-17 (36 bits)
|
||||
total_samples = struct.unpack('>Q', b'\x00\x00\x00' + streaminfo[13:18])[0] & 0xFFFFFFFFF
|
||||
|
||||
if sample_rate > 0:
|
||||
duration = total_samples / sample_rate
|
||||
return int(duration)
|
||||
return None
|
||||
|
||||
if is_last:
|
||||
break
|
||||
f.seek(block_size, 1)
|
||||
except:
|
||||
return None
|
||||
|
||||
def get_wav_duration(file_path):
|
||||
"""Get WAV duration by reading RIFF header"""
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
# Check RIFF header
|
||||
if f.read(4) != b'RIFF':
|
||||
return None
|
||||
f.read(4) # File size
|
||||
if f.read(4) != b'WAVE':
|
||||
return None
|
||||
|
||||
# Find fmt chunk
|
||||
while True:
|
||||
chunk_id = f.read(4)
|
||||
if len(chunk_id) < 4:
|
||||
return None
|
||||
chunk_size = struct.unpack('<I', f.read(4))[0]
|
||||
|
||||
if chunk_id == b'fmt ':
|
||||
fmt_data = f.read(chunk_size)
|
||||
sample_rate = struct.unpack('<I', fmt_data[4:8])[0]
|
||||
byte_rate = struct.unpack('<I', fmt_data[8:12])[0]
|
||||
break
|
||||
else:
|
||||
f.seek(chunk_size, 1)
|
||||
|
||||
# Find data chunk
|
||||
while True:
|
||||
chunk_id = f.read(4)
|
||||
if len(chunk_id) < 4:
|
||||
return None
|
||||
chunk_size = struct.unpack('<I', f.read(4))[0]
|
||||
|
||||
if chunk_id == b'data':
|
||||
if byte_rate > 0:
|
||||
duration = chunk_size / byte_rate
|
||||
return int(duration)
|
||||
return None
|
||||
else:
|
||||
f.seek(chunk_size, 1)
|
||||
except:
|
||||
return None
|
||||
|
||||
def get_duration(file_path):
|
||||
"""Get duration of audio file by reading file headers"""
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
if ext == '.mp3':
|
||||
duration_sec = get_mp3_duration(file_path)
|
||||
elif ext == '.flac':
|
||||
duration_sec = get_flac_duration(file_path)
|
||||
elif ext == '.wav':
|
||||
duration_sec = get_wav_duration(file_path)
|
||||
else:
|
||||
# For other formats, we can't easily read without libraries
|
||||
return "--:--"
|
||||
|
||||
if duration_sec is not None:
|
||||
minutes = duration_sec // 60
|
||||
seconds = duration_sec % 60
|
||||
return f"{minutes:02d}:{seconds:02d}"
|
||||
|
||||
return "--:--"
|
||||
|
||||
def format_size(size):
|
||||
"""Format file size in human-readable format"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size < 1024.0:
|
||||
return f"{size:.2f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.2f} TB"
|
||||
|
||||
def build_tree(directory, output_file, prefix="", is_last=True):
|
||||
"""Recursively build tree structure"""
|
||||
try:
|
||||
entries = sorted(Path(directory).iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
||||
except PermissionError:
|
||||
return
|
||||
|
||||
for i, entry in enumerate(entries):
|
||||
is_last_entry = (i == len(entries) - 1)
|
||||
connector = "└── " if is_last_entry else "├── "
|
||||
|
||||
if entry.is_dir():
|
||||
output_file.write(f"{prefix}{connector}📁 {entry.name}/\n")
|
||||
extension = " " if is_last_entry else "│ "
|
||||
build_tree(entry, output_file, prefix + extension, is_last_entry)
|
||||
else:
|
||||
ext = entry.suffix.lower()
|
||||
if ext in AUDIO_EXTENSIONS:
|
||||
duration = get_duration(entry)
|
||||
size = entry.stat().st_size
|
||||
size_fmt = format_size(size)
|
||||
output_file.write(f"{prefix}{connector}🎵 {entry.name} [{duration}] ({size_fmt})\n")
|
||||
else:
|
||||
output_file.write(f"{prefix}{connector}📄 {entry.name}\n")
|
||||
|
||||
def main():
|
||||
music_dir = sys.argv[1] if len(sys.argv) > 1 else "/home/glenneth/Music"
|
||||
output_path = sys.argv[2] if len(sys.argv) > 2 else "music-library-tree.txt"
|
||||
|
||||
music_path = Path(music_dir)
|
||||
if not music_path.exists():
|
||||
print(f"Error: Music directory '{music_dir}' does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
print("Generating music library tree...")
|
||||
print("Reading durations from file headers (MP3, FLAC, WAV supported)")
|
||||
|
||||
# Count audio files
|
||||
audio_files = []
|
||||
for ext in AUDIO_EXTENSIONS:
|
||||
audio_files.extend(music_path.rglob(f"*{ext}"))
|
||||
total_audio = len(audio_files)
|
||||
|
||||
# Generate tree
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write("Music Library Tree\n")
|
||||
f.write("==================\n")
|
||||
f.write(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
f.write(f"Directory: {music_dir}\n")
|
||||
f.write(f"Duration support: MP3, FLAC, WAV (no external libraries needed)\n")
|
||||
f.write(f"\nTotal audio files: {total_audio}\n\n")
|
||||
f.write(f"📁 {music_path.name}/\n")
|
||||
build_tree(music_path, f, "", True)
|
||||
|
||||
print(f"\nTree generated successfully!")
|
||||
print(f"Output saved to: {output_path}")
|
||||
print(f"Total audio files: {total_audio}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1440
static/asteroid.css
1440
static/asteroid.css
File diff suppressed because it is too large
Load Diff
|
|
@ -1161,10 +1161,12 @@
|
|||
:opacity 1
|
||||
:background-color #(color-accented-black)))
|
||||
|
||||
;; Live stream blink animation
|
||||
;; Live stream pulse animation (like old MacBook sleep indicator)
|
||||
(.live-stream-indicator
|
||||
:animation "asteroid-blink 1s steps(5, start) infinite")
|
||||
:animation "asteroid-pulse 2s ease-in-out infinite")
|
||||
|
||||
(:keyframes asteroid-blink
|
||||
(to :visibility "hidden"))
|
||||
(:keyframes asteroid-pulse
|
||||
(0% :opacity 1)
|
||||
(50% :opacity 0.3)
|
||||
(100% :opacity 1))
|
||||
) ;; End of let block
|
||||
|
|
|
|||
|
|
@ -635,12 +635,6 @@ function displayQueueSearchResults(results) {
|
|||
|
||||
// Live stream info update
|
||||
async function updateLiveStreamInfo() {
|
||||
// Don't update if stream is paused
|
||||
const audioElement = document.getElementById('live-stream-audio');
|
||||
if (audioElement && audioElement.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing-inline');
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
|
@ -55,12 +55,6 @@ function changeStreamQuality() {
|
|||
|
||||
// Update now playing info from Icecast
|
||||
async function updateNowPlaying() {
|
||||
// Don't update if stream is paused
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
if (audioElement && audioElement.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing')
|
||||
const contentType = response.headers.get("content-type")
|
||||
|
|
@ -108,60 +102,9 @@ window.addEventListener('DOMContentLoaded', function() {
|
|||
// Update playing information right after load
|
||||
updateNowPlaying();
|
||||
|
||||
// Auto-reconnect on stream errors and after long pauses
|
||||
// Auto-reconnect on stream errors
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
if (audioElement) {
|
||||
// Track pause timestamp to detect long pauses and reconnect
|
||||
let pauseTimestamp = null;
|
||||
let isReconnecting = false;
|
||||
let needsReconnect = false;
|
||||
const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds
|
||||
|
||||
audioElement.addEventListener('pause', function() {
|
||||
pauseTimestamp = Date.now();
|
||||
console.log('Stream paused at:', pauseTimestamp);
|
||||
});
|
||||
|
||||
audioElement.addEventListener('play', function() {
|
||||
// Check if we need to reconnect after long pause
|
||||
if (!isReconnecting && pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) {
|
||||
needsReconnect = true;
|
||||
console.log('Long pause detected, will reconnect when playing starts...');
|
||||
}
|
||||
pauseTimestamp = null;
|
||||
});
|
||||
|
||||
// Intercept the playing event to stop stale audio
|
||||
audioElement.addEventListener('playing', function() {
|
||||
if (needsReconnect && !isReconnecting) {
|
||||
isReconnecting = true;
|
||||
needsReconnect = false;
|
||||
console.log('Reconnecting stream after long pause to clear stale buffers...');
|
||||
|
||||
// Stop the stale audio immediately
|
||||
audioElement.pause();
|
||||
|
||||
// Reset spectrum analyzer before reconnect
|
||||
if (typeof resetSpectrumAnalyzer === 'function') {
|
||||
resetSpectrumAnalyzer();
|
||||
}
|
||||
|
||||
audioElement.load(); // Force reconnect to clear accumulated buffer
|
||||
|
||||
// Start playing the fresh stream and reinitialize spectrum analyzer
|
||||
setTimeout(function() {
|
||||
audioElement.play().catch(err => console.log('Reconnect play failed:', err));
|
||||
|
||||
if (typeof initSpectrumAnalyzer === 'function') {
|
||||
initSpectrumAnalyzer();
|
||||
console.log('Spectrum analyzer reinitialized after reconnect');
|
||||
}
|
||||
|
||||
isReconnecting = false;
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('error', function(e) {
|
||||
console.log('Stream error, attempting reconnect in 3 seconds...');
|
||||
setTimeout(function() {
|
||||
|
|
@ -26,57 +26,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (liveAudio) {
|
||||
// Reduce buffer to minimize delay
|
||||
liveAudio.preload = 'none';
|
||||
|
||||
// Track pause timestamp to detect long pauses and reconnect
|
||||
let pauseTimestamp = null;
|
||||
let isReconnecting = false;
|
||||
let needsReconnect = false;
|
||||
const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds
|
||||
|
||||
liveAudio.addEventListener('pause', function() {
|
||||
pauseTimestamp = Date.now();
|
||||
console.log('Live stream paused at:', pauseTimestamp);
|
||||
});
|
||||
|
||||
liveAudio.addEventListener('play', function() {
|
||||
// Check if we need to reconnect after long pause
|
||||
if (!isReconnecting && pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) {
|
||||
needsReconnect = true;
|
||||
console.log('Long pause detected, will reconnect when playing starts...');
|
||||
}
|
||||
pauseTimestamp = null;
|
||||
});
|
||||
|
||||
// Intercept the playing event to stop stale audio
|
||||
liveAudio.addEventListener('playing', function() {
|
||||
if (needsReconnect && !isReconnecting) {
|
||||
isReconnecting = true;
|
||||
needsReconnect = false;
|
||||
console.log('Reconnecting live stream after long pause to clear stale buffers...');
|
||||
|
||||
// Stop the stale audio immediately
|
||||
liveAudio.pause();
|
||||
|
||||
// Reset spectrum analyzer before reconnect
|
||||
if (typeof resetSpectrumAnalyzer === 'function') {
|
||||
resetSpectrumAnalyzer();
|
||||
}
|
||||
|
||||
liveAudio.load(); // Force reconnect to clear accumulated buffer
|
||||
|
||||
// Start playing the fresh stream and reinitialize spectrum analyzer
|
||||
setTimeout(function() {
|
||||
liveAudio.play().catch(err => console.log('Reconnect play failed:', err));
|
||||
|
||||
if (typeof initSpectrumAnalyzer === 'function') {
|
||||
initSpectrumAnalyzer();
|
||||
console.log('Spectrum analyzer reinitialized after reconnect');
|
||||
}
|
||||
|
||||
isReconnecting = false;
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Restore user quality preference
|
||||
const selector = document.getElementById('live-stream-quality');
|
||||
|
|
@ -181,8 +130,8 @@ function renderLibraryPage() {
|
|||
return `
|
||||
<div class="track-item" data-track-id="${track.id}" data-index="${actualIndex}">
|
||||
<div class="track-info">
|
||||
<div class="track-title">${track.title || 'Unknown Title'}</div>
|
||||
<div class="track-meta">${track.artist || 'Unknown Artist'} • ${track.album || 'Unknown Album'}</div>
|
||||
<div class="track-title">${track.title[0] || 'Unknown Title'}</div>
|
||||
<div class="track-meta">${track.artist[0] || 'Unknown Artist'} • ${track.album[0] || 'Unknown Album'}</div>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button onclick="playTrack(${actualIndex})" class="btn btn-sm btn-success">▶️</button>
|
||||
|
|
@ -237,9 +186,9 @@ function changeLibraryTracksPerPage() {
|
|||
function filterTracks() {
|
||||
const query = document.getElementById('search-tracks').value.toLowerCase();
|
||||
const filtered = tracks.filter(track =>
|
||||
(track.title || '').toLowerCase().includes(query) ||
|
||||
(track.artist || '').toLowerCase().includes(query) ||
|
||||
(track.album || '').toLowerCase().includes(query)
|
||||
(track.title[0] || '').toLowerCase().includes(query) ||
|
||||
(track.artist[0] || '').toLowerCase().includes(query) ||
|
||||
(track.album[0] || '').toLowerCase().includes(query)
|
||||
);
|
||||
displayTracks(filtered);
|
||||
}
|
||||
|
|
@ -355,9 +304,9 @@ function updatePlayButton(text) {
|
|||
|
||||
function updatePlayerDisplay() {
|
||||
if (currentTrack) {
|
||||
document.getElementById('current-title').textContent = currentTrack.title || 'Unknown Title';
|
||||
document.getElementById('current-artist').textContent = currentTrack.artist || 'Unknown Artist';
|
||||
document.getElementById('current-album').textContent = currentTrack.album || 'Unknown Album';
|
||||
document.getElementById('current-title').textContent = currentTrack.title[0] || 'Unknown Title';
|
||||
document.getElementById('current-artist').textContent = currentTrack.artist[0] || 'Unknown Artist';
|
||||
document.getElementById('current-album').textContent = currentTrack.album[0] || 'Unknown Album';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,8 +328,8 @@ function updateQueueDisplay() {
|
|||
const queueHtml = playQueue.map((track, index) => `
|
||||
<div class="queue-item">
|
||||
<div class="track-info">
|
||||
<div class="track-title">${track.title || 'Unknown Title'}</div>
|
||||
<div class="track-meta">${track.artist || 'Unknown Artist'}</div>
|
||||
<div class="track-title">${track.title[0] || 'Unknown Title'}</div>
|
||||
<div class="track-meta">${track.artist[0] || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<button onclick="removeFromQueue(${index})" class="btn btn-sm btn-danger">✖️</button>
|
||||
</div>
|
||||
|
|
@ -417,10 +366,8 @@ async function createPlaylist() {
|
|||
});
|
||||
|
||||
const result = await response.json();
|
||||
// Handle RADIANCE API wrapper format
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success') {
|
||||
if (result.status === 'success') {
|
||||
alert(`Playlist "${name}" created successfully!`);
|
||||
document.getElementById('new-playlist-name').value = '';
|
||||
|
||||
|
|
@ -428,7 +375,7 @@ async function createPlaylist() {
|
|||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
loadPlaylists();
|
||||
} else {
|
||||
alert('Error creating playlist: ' + data.message);
|
||||
alert('Error creating playlist: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating playlist:', error);
|
||||
|
|
@ -457,28 +404,24 @@ async function saveQueueAsPlaylist() {
|
|||
});
|
||||
|
||||
const createResult = await createResponse.json();
|
||||
// Handle RADIANCE API wrapper format
|
||||
const createData = createResult.data || createResult;
|
||||
|
||||
if (createData.status === 'success') {
|
||||
if (createResult.status === 'success') {
|
||||
// Wait a moment for database to update
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Get the new playlist ID by fetching playlists
|
||||
const playlistsResponse = await fetch('/api/asteroid/playlists');
|
||||
const playlistsResult = await playlistsResponse.json();
|
||||
// Handle RADIANCE API wrapper format
|
||||
const playlistResultData = playlistsResult.data || playlistsResult;
|
||||
|
||||
if (playlistResultData.status === 'success' && playlistResultData.playlists.length > 0) {
|
||||
if (playlistsResult.status === 'success' && playlistsResult.playlists.length > 0) {
|
||||
// Find the playlist with matching name (most recent)
|
||||
const newPlaylist = playlistResultData.playlists.find(p => p.name === name) ||
|
||||
playlistResultData.playlists[playlistResultData.playlists.length - 1];
|
||||
const newPlaylist = playlistsResult.playlists.find(p => p.name === name) ||
|
||||
playlistsResult.playlists[playlistsResult.playlists.length - 1];
|
||||
|
||||
// Add all tracks from queue to playlist
|
||||
let addedCount = 0;
|
||||
for (const track of playQueue) {
|
||||
const trackId = track.id;
|
||||
const trackId = track.id || (Array.isArray(track.id) ? track.id[0] : null);
|
||||
|
||||
if (trackId) {
|
||||
const addFormData = new FormData();
|
||||
|
|
@ -492,7 +435,7 @@ async function saveQueueAsPlaylist() {
|
|||
|
||||
const addResult = await addResponse.json();
|
||||
|
||||
if (addResult.data?.status === 'success') {
|
||||
if (addResult.status === 'success') {
|
||||
addedCount++;
|
||||
}
|
||||
} else {
|
||||
|
|
@ -503,11 +446,10 @@ async function saveQueueAsPlaylist() {
|
|||
alert(`Playlist "${name}" created with ${addedCount} tracks!`);
|
||||
loadPlaylists();
|
||||
} else {
|
||||
alert('Playlist created but could not add tracks. Error: ' + (playlistResultData.message || 'Unknown'));
|
||||
loadPlaylists();
|
||||
alert('Playlist created but could not add tracks. Error: ' + (playlistsResult.message || 'Unknown'));
|
||||
}
|
||||
} else {
|
||||
alert('Error creating playlist: ' + createData.message);
|
||||
alert('Error creating playlist: ' + createResult.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving queue as playlist:', error);
|
||||
|
|
@ -519,11 +461,11 @@ async function loadPlaylists() {
|
|||
try {
|
||||
const response = await fetch('/api/asteroid/playlists');
|
||||
const result = await response.json();
|
||||
// Handle RADIANCE API wrapper format
|
||||
const data = result.data || result;
|
||||
|
||||
if (data && data.status === 'success') {
|
||||
displayPlaylists(data.playlists || []);
|
||||
if (result.data && result.data.status === 'success') {
|
||||
displayPlaylists(result.data.playlists || []);
|
||||
} else if (result.status === 'success') {
|
||||
displayPlaylists(result.playlists || []);
|
||||
} else {
|
||||
displayPlaylists([]);
|
||||
}
|
||||
|
|
@ -560,11 +502,9 @@ async function loadPlaylist(playlistId) {
|
|||
try {
|
||||
const response = await fetch(`/api/asteroid/playlists/get?playlist-id=${playlistId}`);
|
||||
const result = await response.json();
|
||||
// Handle RADIANCE API wrapper format
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success' && data.playlist) {
|
||||
const playlist = data.playlist;
|
||||
if (result.status === 'success' && result.playlist) {
|
||||
const playlist = result.playlist;
|
||||
|
||||
// Clear current queue
|
||||
playQueue = [];
|
||||
|
|
@ -594,7 +534,7 @@ async function loadPlaylist(playlistId) {
|
|||
alert(`Playlist "${playlist.name}" is empty`);
|
||||
}
|
||||
} else {
|
||||
alert('Error loading playlist: ' + (data.message || 'Unknown error'));
|
||||
alert('Error loading playlist: ' + (result.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading playlist:', error);
|
||||
|
|
@ -649,12 +589,6 @@ function changeLiveStreamQuality() {
|
|||
|
||||
// Live stream informatio update
|
||||
async function updateNowPlaying() {
|
||||
// Don't update if stream is paused
|
||||
const liveAudio = document.getElementById('live-stream-audio');
|
||||
if (liveAudio && liveAudio.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing')
|
||||
const contentType = response.headers.get("content-type")
|
||||
|
|
@ -47,64 +47,15 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Track pause timestamp to detect long pauses and reconnect
|
||||
let pauseTimestamp = null;
|
||||
let isReconnecting = false;
|
||||
let needsReconnect = false;
|
||||
const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds
|
||||
|
||||
audioElement.addEventListener('pause', function() {
|
||||
pauseTimestamp = Date.now();
|
||||
console.log('Frame player stream paused at:', pauseTimestamp);
|
||||
});
|
||||
|
||||
audioElement.addEventListener('play', function() {
|
||||
// Check if we need to reconnect after long pause
|
||||
if (!isReconnecting && pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) {
|
||||
needsReconnect = true;
|
||||
console.log('Long pause detected, will reconnect when playing starts...');
|
||||
}
|
||||
pauseTimestamp = null;
|
||||
});
|
||||
|
||||
// Intercept the playing event to stop stale audio
|
||||
audioElement.addEventListener('playing', function() {
|
||||
if (needsReconnect && !isReconnecting) {
|
||||
isReconnecting = true;
|
||||
needsReconnect = false;
|
||||
console.log('Reconnecting frame player stream after long pause to clear stale buffers...');
|
||||
|
||||
// Stop the stale audio immediately
|
||||
audioElement.pause();
|
||||
|
||||
// Reset spectrum analyzer before reconnect
|
||||
if (typeof resetSpectrumAnalyzer === 'function') {
|
||||
resetSpectrumAnalyzer();
|
||||
}
|
||||
|
||||
audioElement.load(); // Force reconnect to clear accumulated buffer
|
||||
|
||||
// Start playing the fresh stream and reinitialize spectrum analyzer
|
||||
setTimeout(function() {
|
||||
audioElement.play().catch(err => console.log('Reconnect play failed:', err));
|
||||
|
||||
if (typeof initSpectrumAnalyzer === 'function') {
|
||||
initSpectrumAnalyzer();
|
||||
console.log('Spectrum analyzer reinitialized after reconnect');
|
||||
}
|
||||
|
||||
isReconnecting = false;
|
||||
}, 200);
|
||||
} else {
|
||||
console.log('Audio playing');
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listeners for debugging
|
||||
audioElement.addEventListener('waiting', function() {
|
||||
console.log('Audio buffering...');
|
||||
});
|
||||
|
||||
audioElement.addEventListener('playing', function() {
|
||||
console.log('Audio playing');
|
||||
});
|
||||
|
||||
audioElement.addEventListener('error', function(e) {
|
||||
console.error('Audio error:', e);
|
||||
});
|
||||
|
|
@ -161,12 +112,6 @@
|
|||
|
||||
// Update mini now playing display
|
||||
async function updateMiniNowPlaying() {
|
||||
// Don't update if stream is paused
|
||||
const audioElement = document.getElementById('persistent-audio');
|
||||
if (audioElement && audioElement.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing-inline');
|
||||
if (response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title lquery="(text title)">🎵 ASTEROID RADIO 🎵</title>
|
||||
<title lquery="(text title)">ASTEROID RADIO</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">🎵 ASTEROID RADIO 🎵</title>
|
||||
<title data-text="title">ASTEROID RADIO</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
|
||||
|
|
@ -26,6 +26,28 @@
|
|||
<!-- Spectrum Analyzer Canvas -->
|
||||
<div style="text-align: center; margin: 15px 0;">
|
||||
<canvas id="spectrum-canvas" width="800" height="100" style="max-width: 100%; border: 1px solid #00ff00; background: #000; border-radius: 4px;"></canvas>
|
||||
<div style="margin-top: 8px; font-size: 0.9em;">
|
||||
<label style="margin-right: 10px;">
|
||||
Style:
|
||||
<select id="spectrum-style-selector" onchange="setSpectrumStyle(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
|
||||
<option value="bars">Bars</option>
|
||||
<option value="wave">Wave</option>
|
||||
<option value="dots">Dots</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Theme:
|
||||
<select id="spectrum-theme-selector" onchange="setSpectrumTheme(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
|
||||
<option value="monotone">Monotone</option>
|
||||
<option value="green">Green</option>
|
||||
<option value="blue">Blue</option>
|
||||
<option value="purple">Purple</option>
|
||||
<option value="red">Red</option>
|
||||
<option value="amber">Amber</option>
|
||||
<option value="rainbow">Rainbow</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
|
|
@ -53,7 +75,7 @@
|
|||
<div id="now-playing" class="now-playing"></div>
|
||||
|
||||
<!-- Recently Played Tracks -->
|
||||
<div class="recently-played-panel">
|
||||
<div id="recently-played-panel" class="recently-played-panel">
|
||||
<h3>Recently Played</h3>
|
||||
<div id="recently-played-list" class="recently-played-list">
|
||||
<p class="loading">Loading...</p>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">🎵 ASTEROID RADIO 🎵</title>
|
||||
<title data-text="title">ASTEROID RADIO</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
|
||||
|
|
@ -26,6 +26,28 @@
|
|||
<!-- Spectrum Analyzer Canvas -->
|
||||
<div style="text-align: center; margin: 15px 0;">
|
||||
<canvas id="spectrum-canvas" width="800" height="100" style="max-width: 100%; border: 1px solid #00ff00; background: #000; border-radius: 4px;"></canvas>
|
||||
<div style="margin-top: 8px; font-size: 0.9em;">
|
||||
<label style="margin-right: 10px;">
|
||||
Style:
|
||||
<select id="spectrum-style-selector" onchange="setSpectrumStyle(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
|
||||
<option value="bars">Bars</option>
|
||||
<option value="wave">Wave</option>
|
||||
<option value="dots">Dots</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Theme:
|
||||
<select id="spectrum-theme-selector" onchange="setSpectrumTheme(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
|
||||
<option value="monotone">Monotone</option>
|
||||
<option value="green">Green</option>
|
||||
<option value="blue">Blue</option>
|
||||
<option value="purple">Purple</option>
|
||||
<option value="red">Red</option>
|
||||
<option value="amber">Amber</option>
|
||||
<option value="rainbow">Rainbow</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
|
|
@ -79,7 +101,7 @@
|
|||
<div id="now-playing" class="now-playing"></div>
|
||||
|
||||
<!-- Recently Played Tracks -->
|
||||
<div class="recently-played-panel">
|
||||
<div id="recently-played-panel" class="recently-played-panel">
|
||||
<h3>Recently Played</h3>
|
||||
<div id="recently-played-list" class="recently-played-list">
|
||||
<p class="loading">Loading...</p>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,18 @@
|
|||
<title data-text="title">Asteroid Radio - Login</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/asteroid/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
|
||||
<link rel="stylesheet" type="text/css" href="/static/asteroid.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🎵 ASTEROID RADIO - LOGIN</h1>
|
||||
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
<span>ASTEROID RADIO - LOGIN</span>
|
||||
</h1>
|
||||
<nav class="nav">
|
||||
<a href="/asteroid">Home</a>
|
||||
<a href="/asteroid/player">Player</a>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,37 @@
|
|||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎵 WEB PLAYER</h1>
|
||||
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
<span>WEB PLAYER</span>
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
</h1>
|
||||
|
||||
<!-- Spectrum Analyzer Canvas -->
|
||||
<div style="text-align: center; margin: 15px 0;">
|
||||
<canvas id="spectrum-canvas" width="800" height="100" style="max-width: 100%; border: 1px solid #00ff00; background: #000; border-radius: 4px;"></canvas>
|
||||
<div style="margin-top: 8px; font-size: 0.9em;">
|
||||
<label style="margin-right: 10px;">
|
||||
Style:
|
||||
<select id="spectrum-style-selector" onchange="setSpectrumStyle(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
|
||||
<option value="bars">Bars</option>
|
||||
<option value="wave">Wave</option>
|
||||
<option value="dots">Dots</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Theme:
|
||||
<select id="spectrum-theme-selector" onchange="setSpectrumTheme(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
|
||||
<option value="monotone">Monotone</option>
|
||||
<option value="green">Green</option>
|
||||
<option value="blue">Blue</option>
|
||||
<option value="purple">Purple</option>
|
||||
<option value="red">Red</option>
|
||||
<option value="amber">Amber</option>
|
||||
<option value="rainbow">Rainbow</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
|
|
|
|||
|
|
@ -4,13 +4,20 @@
|
|||
<title data-text="title">Asteroid Radio - Web Player</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/asteroid/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/player.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎵 WEB PLAYER</h1>
|
||||
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
<span>WEB PLAYER</span>
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid">Home</a>
|
||||
<a href="/asteroid/profile">Profile</a>
|
||||
|
|
|
|||
|
|
@ -97,12 +97,6 @@
|
|||
|
||||
// Update now playing info for popout
|
||||
async function updatePopoutNowPlaying() {
|
||||
// Don't update if stream is paused
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
if (audioElement && audioElement.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing-inline');
|
||||
const html = await response.text();
|
||||
|
|
@ -131,60 +125,8 @@
|
|||
// Initial update
|
||||
updatePopoutNowPlaying();
|
||||
|
||||
// Auto-reconnect on stream errors and after long pauses
|
||||
// Auto-reconnect on stream errors
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
|
||||
// Track pause timestamp to detect long pauses and reconnect
|
||||
let pauseTimestamp = null;
|
||||
let isReconnecting = false;
|
||||
let needsReconnect = false;
|
||||
const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds
|
||||
|
||||
audioElement.addEventListener('pause', function() {
|
||||
pauseTimestamp = Date.now();
|
||||
console.log('Popout stream paused at:', pauseTimestamp);
|
||||
});
|
||||
|
||||
audioElement.addEventListener('play', function() {
|
||||
// Check if we need to reconnect after long pause
|
||||
if (!isReconnecting && pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) {
|
||||
needsReconnect = true;
|
||||
console.log('Long pause detected, will reconnect when playing starts...');
|
||||
}
|
||||
pauseTimestamp = null;
|
||||
});
|
||||
|
||||
// Intercept the playing event to stop stale audio
|
||||
audioElement.addEventListener('playing', function() {
|
||||
if (needsReconnect && !isReconnecting) {
|
||||
isReconnecting = true;
|
||||
needsReconnect = false;
|
||||
console.log('Reconnecting popout stream after long pause to clear stale buffers...');
|
||||
|
||||
// Stop the stale audio immediately
|
||||
audioElement.pause();
|
||||
|
||||
// Reset spectrum analyzer before reconnect
|
||||
if (typeof resetSpectrumAnalyzer === 'function') {
|
||||
resetSpectrumAnalyzer();
|
||||
}
|
||||
|
||||
audioElement.load(); // Force reconnect to clear accumulated buffer
|
||||
|
||||
// Start playing the fresh stream and reinitialize spectrum analyzer
|
||||
setTimeout(function() {
|
||||
audioElement.play().catch(err => console.log('Reconnect play failed:', err));
|
||||
|
||||
if (typeof initSpectrumAnalyzer === 'function') {
|
||||
initSpectrumAnalyzer();
|
||||
console.log('Spectrum analyzer reinitialized after reconnect');
|
||||
}
|
||||
|
||||
isReconnecting = false;
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('error', function(e) {
|
||||
console.log('Stream error, attempting reconnect in 3 seconds...');
|
||||
setTimeout(function() {
|
||||
|
|
|
|||
|
|
@ -4,12 +4,18 @@
|
|||
<title data-text="title">Asteroid Radio - Register</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/asteroid/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🎵 ASTEROID RADIO - REGISTER</h1>
|
||||
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
<span>ASTEROID RADIO - REGISTER</span>
|
||||
</h1>
|
||||
<nav class="nav">
|
||||
<a href="/asteroid">Home</a>
|
||||
<a href="/asteroid/player">Player</a>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
;;;; test-parenscript.lisp - Test ParenScript compilation
|
||||
|
||||
(ql:quickload :parenscript)
|
||||
|
||||
(defun test-auth-ui-compilation ()
|
||||
"Test compiling the auth-ui ParenScript to JavaScript"
|
||||
(let ((js-code
|
||||
(ps:ps
|
||||
;; Check if user is logged in by calling the API
|
||||
(defun check-auth-status ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/auth-status")
|
||||
(then (lambda (response)
|
||||
(ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; api-output wraps response in {status, message, data}
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
data)))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error checking auth status:" error))
|
||||
(ps:create :logged-in false
|
||||
:is-admin false)))))
|
||||
|
||||
;; Update UI based on authentication status
|
||||
(defun update-auth-ui (auth-status)
|
||||
;; Show/hide elements based on login status
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-logged-in]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status logged-in)
|
||||
"inline-block"
|
||||
"none")))))
|
||||
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-logged-out]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status logged-in)
|
||||
"none"
|
||||
"inline-block")))))
|
||||
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-admin]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status is-admin)
|
||||
"inline-block"
|
||||
"none"))))))
|
||||
|
||||
;; Initialize auth UI on page load
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
(async lambda ()
|
||||
(ps:chain console (log "Auth UI initializing..."))
|
||||
(let ((auth-status (await (check-auth-status))))
|
||||
(ps:chain console (log "Auth status:" auth-status))
|
||||
(update-auth-ui auth-status)
|
||||
(ps:chain console (log "Auth UI updated")))))))))
|
||||
|
||||
(format t "~%Generated JavaScript:~%~%")
|
||||
(format t "~a~%" js-code)
|
||||
(format t "~%~%")
|
||||
|
||||
;; Write to file for comparison
|
||||
(with-open-file (out "/home/glenn/Projects/Code/asteroid/static/js/auth-ui-generated.js"
|
||||
:direction :output
|
||||
:if-exists :supersede)
|
||||
(write-string js-code out))
|
||||
|
||||
(format t "Wrote generated JavaScript to: static/js/auth-ui-generated.js~%")))
|
||||
|
||||
;; Run the test
|
||||
(test-auth-ui-compilation)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
;;;; test-ps-compile.lisp - Test ParenScript compilation for auth-ui
|
||||
|
||||
(load "~/quicklisp/setup.lisp")
|
||||
(ql:quickload '(:parenscript) :silent t)
|
||||
|
||||
(format t "~%Testing ParenScript compilation for auth-ui...~%~%")
|
||||
|
||||
;; Load the auth-ui parenscript file
|
||||
(load "parenscript/auth-ui.lisp")
|
||||
|
||||
;; Test compilation
|
||||
(format t "Compiling ParenScript to JavaScript...~%~%")
|
||||
(let ((js-output (asteroid::generate-auth-ui-js)))
|
||||
(format t "Generated JavaScript (~a characters):~%~%" (length js-output))
|
||||
(format t "~a~%~%" js-output)
|
||||
|
||||
;; Write to file
|
||||
(with-open-file (out "static/js/auth-ui-parenscript-output.js"
|
||||
:direction :output
|
||||
:if-exists :supersede)
|
||||
(write-string js-output out))
|
||||
|
||||
(format t "~%✓ JavaScript written to: static/js/auth-ui-parenscript-output.js~%")
|
||||
(format t "✓ Compilation successful!~%~%"))
|
||||
|
||||
(format t "Compare with original:~%")
|
||||
(format t " Original: static/js/auth-ui.js.original~%")
|
||||
(format t " Generated: static/js/auth-ui-parenscript-output.js~%~%")
|
||||
|
|
@ -173,7 +173,7 @@
|
|||
;; Page request - redirect to login (redirect doesn't return)
|
||||
(progn
|
||||
(format t "Authentication failed - redirecting to login~%")
|
||||
(radiance:redirect "/asteroid/login"))))))
|
||||
(radiance:redirect "/login"))))))
|
||||
|
||||
(defun require-role (role &key (api nil))
|
||||
"Require user to have a specific role.
|
||||
|
|
@ -284,7 +284,11 @@
|
|||
(defun initialize-user-system ()
|
||||
"Initialize the user management system"
|
||||
(format t "Initializing user management system...~%")
|
||||
;; Skip database check at startup - database queries hang with current setup
|
||||
(format t "Skipping admin creation check - database already initialized~%")
|
||||
(format t "User management initialization complete.~%")
|
||||
;; Try immediate initialization first
|
||||
#+nil
|
||||
(handler-case
|
||||
(progn
|
||||
(format t "Setting up user management...~%")
|
||||
|
|
|
|||
Loading…
Reference in New Issue