From 5fddbed8115e8b3dc8e3c212d03da789182f31fb Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 11 Jan 2026 17:06:41 +0300 Subject: [PATCH] Add mini spectrum analyzer to player frame with theme/style sync - Remove cross-frame audio access that was breaking audio on navigation - Add mini spectrum visualizer to persistent player frame - Mini analyzer syncs theme and style with main analyzer via localStorage - Add MusicBrainz search link to player frame (updates with track changes) - Reduce quality selector width from 140px to 55px - Add search_url to now-playing-json API response The main analyzer is disabled in frameset mode due to browser restrictions on cross-frame MediaElementSource, but the mini analyzer in the player frame provides visualization that persists across content navigation. --- frontend-partials.lisp | 9 +- parenscript/spectrum-analyzer.lisp | 18 ++-- parenscript/stream-player.lisp | 153 ++++++++++++++++++++++++++++- static/asteroid.css | 4 +- static/asteroid.lass | 4 +- template/audio-player-frame.ctml | 13 ++- 6 files changed, 177 insertions(+), 24 deletions(-) diff --git a/frontend-partials.lisp b/frontend-partials.lisp index 4035e71..88018e6 100644 --- a/frontend-partials.lisp +++ b/frontend-partials.lisp @@ -143,12 +143,17 @@ (now-playing-stats (icecast-now-playing *stream-base-url* mount-name))) (if now-playing-stats (let* ((title (cdr (assoc :title now-playing-stats))) - (favorite-count (or (get-track-favorite-count title) 0))) + (favorite-count (or (get-track-favorite-count title) 0)) + (parsed (parse-track-title title)) + (artist (getf parsed :artist)) + (song (getf parsed :song)) + (search-url (generate-music-search-url artist song))) (api-output `(("status" . "success") ("title" . ,title) ("listeners" . ,(cdr (assoc :listeners now-playing-stats))) ("track_id" . ,(cdr (assoc :track-id now-playing-stats))) - ("favorite_count" . ,favorite-count)))) + ("favorite_count" . ,favorite-count) + ("search_url" . ,search-url)))) (api-output `(("status" . "offline") ("title" . "Stream Offline") ("track_id" . nil))))))) diff --git a/parenscript/spectrum-analyzer.lisp b/parenscript/spectrum-analyzer.lisp index 0d95643..33f84f7 100644 --- a/parenscript/spectrum-analyzer.lisp +++ b/parenscript/spectrum-analyzer.lisp @@ -50,24 +50,18 @@ (let ((audio-element nil) (canvas-element (ps:chain document (get-element-by-id "spectrum-canvas")))) - ;; Try to find audio element in current frame first + ;; Only use audio element from current frame + ;; Cross-frame MediaElementSource causes audio to break when content frame navigates (setf audio-element (or (ps:chain document (get-element-by-id "live-audio")) (ps:chain document (get-element-by-id "persistent-audio")))) - ;; If not found and we're in a frame, try to access from parent frameset + ;; In frameset mode, the audio is in the player frame - we cannot safely connect + ;; to it from the content frame because createMediaElementSource breaks audio + ;; routing when the content frame navigates away (when (and (not audio-element) (ps:@ window parent) (not (eq (ps:@ window parent) window))) - (ps:chain console (log "Trying to access audio from parent frame...")) - (ps:try - (progn - ;; Try accessing via parent.frames - (let ((player-frame (ps:getprop (ps:@ window parent) "player-frame"))) - (when player-frame - (setf audio-element (ps:chain player-frame document (get-element-by-id "persistent-audio"))) - (ps:chain console (log "Found audio in player-frame:" audio-element))))) - (:catch (e) - (ps:chain console (log "Cross-frame access error:" e))))) + (ps:chain console (log "Spectrum analyzer: In frameset mode, audio is in player frame - visualization disabled to prevent audio issues"))) (when (and audio-element canvas-element) ;; Store current audio element diff --git a/parenscript/stream-player.lisp b/parenscript/stream-player.lisp index 81ceb8f..31f5086 100644 --- a/parenscript/stream-player.lisp +++ b/parenscript/stream-player.lisp @@ -7,6 +7,143 @@ (ps:ps* '(progn + ;; ======================================== + ;; Mini Spectrum Analyzer (for player frame) + ;; ======================================== + + (defvar *mini-audio-context* nil) + (defvar *mini-analyser* nil) + (defvar *mini-media-source* nil) + (defvar *mini-canvas* nil) + (defvar *mini-canvas-ctx* nil) + (defvar *mini-animation-id* nil) + + ;; Color themes (same as main spectrum analyzer) + (defvar *mini-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 get-mini-theme () + "Get current theme from localStorage (shared with main analyzer)" + (let ((saved-theme (ps:chain local-storage (get-item "spectrum-theme")))) + (if (and saved-theme (ps:getprop *mini-themes* saved-theme)) + saved-theme + "green"))) + + (defun get-mini-style () + "Get current style from localStorage (shared with main analyzer)" + (let ((saved-style (ps:chain local-storage (get-item "spectrum-style")))) + (if (and saved-style (or (= saved-style "bars") (= saved-style "wave") (= saved-style "dots"))) + saved-style + "bars"))) + + (defun init-mini-spectrum (audio-element) + "Initialize mini spectrum analyzer in player frame" + (let ((canvas (ps:chain document (get-element-by-id "mini-spectrum-canvas")))) + (when (and audio-element canvas (not *mini-audio-context*)) + (ps:try + (progn + (setf *mini-audio-context* (ps:new (or (ps:@ window |AudioContext|) + (ps:@ window |webkitAudioContext|)))) + (setf *mini-analyser* (ps:chain *mini-audio-context* (create-analyser))) + (setf (ps:@ *mini-analyser* |fftSize|) 64) + (setf (ps:@ *mini-analyser* |smoothingTimeConstant|) 0.8) + (setf *mini-media-source* (ps:chain *mini-audio-context* (create-media-element-source audio-element))) + (ps:chain *mini-media-source* (connect *mini-analyser*)) + (ps:chain *mini-analyser* (connect (ps:@ *mini-audio-context* destination))) + (setf *mini-canvas* canvas) + (setf *mini-canvas-ctx* (ps:chain canvas (get-context "2d"))) + (ps:chain console (log "Mini spectrum analyzer initialized"))) + (:catch (e) + (ps:chain console (log "Error initializing mini spectrum:" e))))))) + + (defun draw-mini-spectrum () + "Draw mini spectrum visualization using theme and style from localStorage" + (setf *mini-animation-id* (request-animation-frame draw-mini-spectrum)) + (when (and *mini-analyser* *mini-canvas* *mini-canvas-ctx*) + (let* ((buffer-length (ps:@ *mini-analyser* |frequencyBinCount|)) + (data-array (ps:new (|Uint8Array| buffer-length))) + (width (ps:@ *mini-canvas* width)) + (height (ps:@ *mini-canvas* height)) + (bar-width (/ width buffer-length)) + (theme-name (get-mini-theme)) + (theme (ps:getprop *mini-themes* theme-name)) + (style (get-mini-style))) + (ps:chain *mini-analyser* (get-byte-frequency-data data-array)) + ;; Clear with fade effect + (setf (ps:@ *mini-canvas-ctx* fill-style) "rgba(0, 0, 0, 0.2)") + (ps:chain *mini-canvas-ctx* (fill-rect 0 0 width height)) + + (cond + ;; Bar graph style + ((= style "bars") + (dotimes (i buffer-length) + (let* ((value (ps:getprop data-array i)) + (bar-height (* (/ value 255) height)) + (x (* i bar-width))) + (when (> bar-height 0) + (let ((gradient (ps:chain *mini-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:@ *mini-canvas-ctx* fill-style) gradient) + (ps:chain *mini-canvas-ctx* (fill-rect x (- height bar-height) bar-width bar-height))))))) + + ;; Wave/line style + ((= style "wave") + (ps:chain *mini-canvas-ctx* (begin-path)) + (setf (ps:@ *mini-canvas-ctx* |lineWidth|) 2) + (setf (ps:@ *mini-canvas-ctx* |strokeStyle|) (ps:@ theme top)) + (let ((x 0)) + (dotimes (i buffer-length) + (let* ((value (ps:getprop data-array i)) + (bar-height (* (/ value 255) height)) + (y (- height bar-height))) + (if (= i 0) + (ps:chain *mini-canvas-ctx* (move-to x y)) + (ps:chain *mini-canvas-ctx* (line-to x y))) + (incf x bar-width)))) + (ps:chain *mini-canvas-ctx* (stroke))) + + ;; Dots/particles style + ((= style "dots") + (setf (ps:@ *mini-canvas-ctx* |fillStyle|) (ps:@ theme top)) + (let ((x 0)) + (dotimes (i buffer-length) + (let* ((value (ps:getprop data-array i)) + (bar-height (* (/ value 255) height)) + (y (- height bar-height)) + (dot-radius (ps:max 1 (/ bar-height 10)))) + (when (> value 0) + (ps:chain *mini-canvas-ctx* (begin-path)) + (ps:chain *mini-canvas-ctx* (arc x y dot-radius 0 6.283185307179586)) + (ps:chain *mini-canvas-ctx* (fill))) + (incf x bar-width))))))))) + + (defun start-mini-spectrum () + "Start mini spectrum animation" + (when (and *mini-analyser* (not *mini-animation-id*)) + (draw-mini-spectrum))) + + (defun stop-mini-spectrum () + "Stop mini spectrum animation" + (when *mini-animation-id* + (cancel-animation-frame *mini-animation-id*) + (setf *mini-animation-id* nil)) + ;; Clear canvas + (when (and *mini-canvas* *mini-canvas-ctx*) + (let ((width (ps:@ *mini-canvas* width)) + (height (ps:@ *mini-canvas* height))) + (setf (ps:@ *mini-canvas-ctx* fill-style) "#000000") + (ps:chain *mini-canvas-ctx* (fill-rect 0 0 width height))))) + ;; ======================================== ;; Stream Configuration ;; ======================================== @@ -288,7 +425,16 @@ (cond ((= fav-count 0) (setf (ps:@ count-el text-content) "")) ((= fav-count 1) (setf (ps:@ count-el text-content) "1 ❤️")) - (t (setf (ps:@ count-el text-content) (+ fav-count " ❤️")))))))))) + (t (setf (ps:@ count-el text-content) (+ fav-count " ❤️")))))) + ;; Update MusicBrainz search link + (let ((mb-link (ps:chain document (get-element-by-id "mini-musicbrainz-link"))) + (search-url (or (ps:@ data data search_url) (ps:@ data search_url)))) + (when mb-link + (if search-url + (progn + (setf (ps:@ mb-link href) search-url) + (setf (ps:@ mb-link style display) "inline")) + (setf (ps:@ mb-link style display) "none")))))))) (catch (lambda (error) (ps:chain console (log "Could not fetch now playing:" error))))))) @@ -529,6 +675,9 @@ (add-event-listener "playing" (lambda () (ps:chain console (log "Audio playing")) + ;; Initialize and start mini spectrum on first play + (init-mini-spectrum audio-element) + (start-mini-spectrum) (hide-status) (setf *stream-error-count* 0) (setf *is-reconnecting* false) @@ -577,6 +726,8 @@ (ps:chain audio-element (add-event-listener "pause" (lambda () + ;; Stop mini spectrum when paused + (stop-mini-spectrum) ;; If paused while muted and we didn't initiate it, browser may have throttled (when (and (ps:@ audio-element muted) (not *is-reconnecting*)) (ps:chain console (log "Stream paused while muted (possible browser throttling), will reconnect in 3 seconds...")) diff --git a/static/asteroid.css b/static/asteroid.css index a77f721..c55aca0 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1340,8 +1340,8 @@ body.persistent-player-container .quality-selector select{ color: #00ff00; letter-spacing: 0.08rem; border: 1px solid #00ff00; - padding: 3px 8px; - min-width: 140px; + padding: 3px 6px; + min-width: 55px; font-size: 0.9em; height: 26px; line-height: 18px; diff --git a/static/asteroid.lass b/static/asteroid.lass index 98d662a..7fdd73f 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -1079,8 +1079,8 @@ :color "#00ff00" :letter-spacing "0.08rem" :border "1px solid #00ff00" - :padding "3px 8px" - :min-width "140px" + :padding "3px 6px" + :min-width "55px" :font-size "0.9em" :height "26px" :line-height "18px") diff --git a/template/audio-player-frame.ctml b/template/audio-player-frame.ctml index 43ac13d..4811cdd 100644 --- a/template/audio-player-frame.ctml +++ b/template/audio-player-frame.ctml @@ -8,6 +8,9 @@
+ + + 🟢 LIVE: @@ -23,11 +26,10 @@
- - + + +
@@ -35,6 +37,7 @@ + Loading...