feat: Convert front-page.js to ParenScript

Successfully converted front-page.js with all functionality:
- Stream quality configuration and switching
- Now playing updates (every 10 seconds)
- Pop-out player functionality
- Frameset mode toggle
- Auto-reconnect on stream errors

Generated JavaScript: 6900 characters
No browser errors, all features working

Files:
- parenscript/front-page.lisp - ParenScript source
- asteroid.asd - Added front-page to parenscript module
- asteroid.lisp - Added front-page.js to static route interception
- static/js/front-page.js - Removed from git (backed up as .original)

Two files successfully converted to ParenScript!
This commit is contained in:
glenneth 2025-11-06 11:27:50 +03:00 committed by Glenn Thompson
parent 0d50f01a07
commit c35ae5a1f0
6 changed files with 317 additions and 30 deletions

View File

@ -30,7 +30,7 @@ This branch experiments with converting all JavaScript files to ParenScript, all
- [X] Create experimental branch - [X] Create experimental branch
** Phase 2: Convert Simple Files First ** Phase 2: Convert Simple Files First
- [ ] Convert =auth-ui.js= (smallest, simplest) - [X] Convert =auth-ui.js= (smallest, simplest) - COMPLETE ✅
- [ ] Convert =profile.js= - [ ] Convert =profile.js=
- [ ] Convert =users.js= - [ ] Convert =users.js=
@ -67,8 +67,63 @@ This branch experiments with converting all JavaScript files to ParenScript, all
- [[https://parenscript.common-lisp.dev/][ParenScript Documentation]] - [[https://parenscript.common-lisp.dev/][ParenScript Documentation]]
- [[https://github.com/vsedach/Parenscript][ParenScript GitHub]] - [[https://github.com/vsedach/Parenscript][ParenScript GitHub]]
* 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 * Notes
This is an EXPERIMENTAL branch. The goal is to evaluate ParenScript for this project, not to immediately replace all JavaScript. 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. If successful, we can merge incrementally, one file at a time.
** First Conversion Complete!
auth-ui.js successfully converted to ParenScript on 2025-11-06. All functionality working, no errors.

View File

@ -43,7 +43,8 @@
(:file "template-utils") (:file "template-utils")
(:file "parenscript-utils") (:file "parenscript-utils")
(:module :parenscript (:module :parenscript
:components ((:file "auth-ui"))) :components ((:file "auth-ui")
(:file "front-page")))
(:file "stream-media") (:file "stream-media")
(:file "user-management") (:file "user-management")
(:file "playlist-management") (:file "playlist-management")

View File

@ -472,25 +472,37 @@
:default-stream-encoding "audio/aac")) :default-stream-encoding "audio/aac"))
;; Configure static file serving for other files ;; Configure static file serving for other files
;; BUT exclude auth-ui.js which is served by ParenScript ;; BUT exclude ParenScript-compiled JS files
(define-page static #@"/static/(.*)" (:uri-groups (path)) (define-page static #@"/static/(.*)" (:uri-groups (path))
(if (string= path "js/auth-ui.js") (cond
;; Serve ParenScript-compiled JavaScript ;; Serve ParenScript-compiled auth-ui.js
(progn ((string= path "js/auth-ui.js")
(format t "~%=== SERVING PARENSCRIPT auth-ui.js ===~%") (format t "~%=== SERVING PARENSCRIPT auth-ui.js ===~%")
(setf (content-type *response*) "application/javascript") (setf (content-type *response*) "application/javascript")
(handler-case (handler-case
(let ((js (generate-auth-ui-js))) (let ((js (generate-auth-ui-js)))
(format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL")) (format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL"))
(if js (if js js "// Error: No JavaScript generated"))
js
"// Error: No JavaScript generated"))
(error (e) (error (e)
(format t "ERROR generating auth-ui.js: ~a~%" e) (format t "ERROR generating auth-ui.js: ~a~%" e)
(format nil "// Error generating JavaScript: ~a~%" e)))) (format nil "// Error generating JavaScript: ~a~%" e))))
;; Serve ParenScript-compiled front-page.js
((string= path "js/front-page.js")
(format t "~%=== SERVING PARENSCRIPT front-page.js ===~%")
(setf (content-type *response*) "application/javascript")
(handler-case
(let ((js (generate-front-page-js)))
(format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL"))
(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 regular static file ;; Serve regular static file
(t
(serve-file (merge-pathnames (format nil "static/~a" path) (serve-file (merge-pathnames (format nil "static/~a" path)
(asdf:system-source-directory :asteroid))))) (asdf:system-source-directory :asteroid))))))
;; Status check functions ;; Status check functions
(defun check-icecast-status () (defun check-icecast-status ()

229
parenscript/front-page.lisp Normal file
View File

@ -0,0 +1,229 @@
;;;; 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 (e)
(ps:chain console (log "Stream error, attempting reconnect in 3 seconds..."))
(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))))))))))
;; 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*)

View File

@ -78,13 +78,3 @@ This playlist contains ~50 tracks of ambient/IDM music curated for Asteroid Radi
- Music files need to be present in =/home/glenneth/Music/= on the VPS - 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 - The playlist can be manually copied to replace =stream-queue.m3u= when ready
- No changes to the main project repository required - No changes to the main project repository required
* Generating New Playlists
To create additional playlists with correct paths:
#+begin_src bash
# Create a playlist with relative paths first
# Then convert it:
python3 scripts/fix-m3u-paths.py your-playlist.m3u output-playlist.m3u --docker
#+end_src