From 9123d40a7d5fa482fb9778693e6165ce69d502e4 Mon Sep 17 00:00:00 2001 From: glenneth Date: Thu, 6 Nov 2025 11:27:50 +0300 Subject: [PATCH] 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! --- PARENSCRIPT-EXPERIMENT.org | 57 ++++- asteroid.asd | 3 +- asteroid.lisp | 48 ++-- parenscript/front-page.lisp | 229 ++++++++++++++++++ scripts/README-PLAYLIST.org | 10 - .../{front-page.js => front-page.js.original} | 0 6 files changed, 317 insertions(+), 30 deletions(-) create mode 100644 parenscript/front-page.lisp rename static/js/{front-page.js => front-page.js.original} (100%) diff --git a/PARENSCRIPT-EXPERIMENT.org b/PARENSCRIPT-EXPERIMENT.org index 1ed0a32..51e5d91 100644 --- a/PARENSCRIPT-EXPERIMENT.org +++ b/PARENSCRIPT-EXPERIMENT.org @@ -30,7 +30,7 @@ This branch experiments with converting all JavaScript files to ParenScript, all - [X] Create experimental branch ** Phase 2: Convert Simple Files First -- [ ] Convert =auth-ui.js= (smallest, simplest) +- [X] Convert =auth-ui.js= (smallest, simplest) - COMPLETE ✅ - [ ] Convert =profile.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://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 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. + +** First Conversion Complete! +auth-ui.js successfully converted to ParenScript on 2025-11-06. All functionality working, no errors. diff --git a/asteroid.asd b/asteroid.asd index d2542e3..727c282 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -41,7 +41,8 @@ (:file "template-utils") (:file "parenscript-utils") (:module :parenscript - :components ((:file "auth-ui"))) + :components ((:file "auth-ui") + (:file "front-page"))) (:file "stream-media") (:file "user-management") (:file "playlist-management") diff --git a/asteroid.lisp b/asteroid.lisp index 3d24a2e..324066e 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -492,25 +492,37 @@ :default-stream-encoding "audio/aac")) ;; 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)) - (if (string= path "js/auth-ui.js") - ;; Serve ParenScript-compiled JavaScript - (progn - (format t "~%=== SERVING PARENSCRIPT auth-ui.js ===~%") - (setf (content-type *response*) "application/javascript") - (handler-case - (let ((js (generate-auth-ui-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 auth-ui.js: ~a~%" e) - (format nil "// Error generating JavaScript: ~a~%" e)))) - ;; Serve regular static file - (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") + (format t "~%=== SERVING PARENSCRIPT auth-ui.js ===~%") + (setf (content-type *response*) "application/javascript") + (handler-case + (let ((js (generate-auth-ui-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 auth-ui.js: ~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 + (t + (serve-file (merge-pathnames (format nil "static/~a" path) + (asdf:system-source-directory :asteroid)))))) ;; Status check functions (defun check-icecast-status () diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp new file mode 100644 index 0000000..f22cd76 --- /dev/null +++ b/parenscript/front-page.lisp @@ -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*) diff --git a/scripts/README-PLAYLIST.org b/scripts/README-PLAYLIST.org index ca21ffe..866db09 100644 --- a/scripts/README-PLAYLIST.org +++ b/scripts/README-PLAYLIST.org @@ -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 - The playlist can be manually copied to replace =stream-queue.m3u= when ready - 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 diff --git a/static/js/front-page.js b/static/js/front-page.js.original similarity index 100% rename from static/js/front-page.js rename to static/js/front-page.js.original