Compare commits

..

No commits in common. "249844160fe845f05ff2e603a886c2072ee1391f" and "bad9d4294b8e98c2a27c0855934c3a55bc88669f" have entirely different histories.

8 changed files with 28 additions and 211 deletions

View File

@ -138,24 +138,17 @@
(define-api-with-limit asteroid/partial/now-playing-json (&optional mount) (:limit 2 :timeout 1) (define-api-with-limit asteroid/partial/now-playing-json (&optional mount) (:limit 2 :timeout 1)
"Get JSON with now playing info including track ID for favorites. "Get JSON with now playing info including track ID for favorites.
Optional MOUNT parameter specifies which stream to get metadata from." Optional MOUNT parameter specifies which stream to get metadata from."
;; Register web listener for geo stats (keeps listener active during playback)
(register-web-listener)
(with-error-handling (with-error-handling
(let* ((mount-name (or mount "asteroid.mp3")) (let* ((mount-name (or mount "asteroid.mp3"))
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name))) (now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
(if now-playing-stats (if now-playing-stats
(let* ((title (cdr (assoc :title 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") (api-output `(("status" . "success")
("title" . ,title) ("title" . ,title)
("listeners" . ,(cdr (assoc :listeners now-playing-stats))) ("listeners" . ,(cdr (assoc :listeners now-playing-stats)))
("track_id" . ,(cdr (assoc :track-id 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") (api-output `(("status" . "offline")
("title" . "Stream Offline") ("title" . "Stream Offline")
("track_id" . nil))))))) ("track_id" . nil)))))))

View File

@ -268,18 +268,18 @@
(defun update-geo-stats (country-code listener-count &optional city) (defun update-geo-stats (country-code listener-count &optional city)
"Update geo stats for today, optionally including city. "Update geo stats for today, optionally including city.
listener_count tracks peak concurrent listeners (max seen today). listener_count tracks peak concurrent listeners (max seen today).
listen_minutes increments by listener_count per poll (1 minute per listener per poll)." listen_minutes increments by 1 per poll (approximates total listen time)."
(when country-code (when country-code
(handler-case (handler-case
(with-db (with-db
(let ((city-sql (if city (format nil "'~a'" city) "NULL"))) (let ((city-sql (if city (format nil "'~a'" city) "NULL")))
(postmodern:execute (postmodern:execute
(format nil "INSERT INTO listener_geo_stats (date, country_code, city, listener_count, listen_minutes) (format nil "INSERT INTO listener_geo_stats (date, country_code, city, listener_count, listen_minutes)
VALUES (CURRENT_DATE, '~a', ~a, ~a, ~a) VALUES (CURRENT_DATE, '~a', ~a, ~a, 1)
ON CONFLICT (date, country_code, city) ON CONFLICT (date, country_code, city)
DO UPDATE SET listener_count = GREATEST(listener_geo_stats.listener_count, ~a), DO UPDATE SET listener_count = GREATEST(listener_geo_stats.listener_count, ~a),
listen_minutes = listener_geo_stats.listen_minutes + ~a" listen_minutes = listener_geo_stats.listen_minutes + 1"
country-code city-sql listener-count listener-count listener-count listener-count)))) country-code city-sql listener-count listener-count))))
(error (e) (error (e)
(log:error "Failed to update geo stats: ~a" e))))) (log:error "Failed to update geo stats: ~a" e)))))

View File

@ -1,28 +0,0 @@
-- Migration: Fix Listen Minutes Calculation
-- Version: 009
-- Date: 2026-01-11
-- Description: Document the fix for listen_minutes calculation
--
-- ISSUE: listen_minutes was incrementing by 1 per poll regardless of listener count.
-- This meant a country with 5 listeners for 1 hour would only show 60 minutes,
-- not 300 listener-minutes.
--
-- FIX: Application code in listener-stats.lisp now increments listen_minutes by
-- the listener_count value (1 minute per listener per poll interval).
--
-- ADDITIONAL FIX: register-web-listener is now called from the now-playing-json API
-- endpoint, keeping listeners registered during continuous playback
-- instead of timing out after 5 minutes.
--
-- No schema changes required - this migration documents the application logic fix.
-- Add a comment to the table for future reference
COMMENT ON COLUMN listener_geo_stats.listen_minutes IS
'Total listener-minutes: increments by listener_count per poll (1 min per listener per 60s poll)';
-- Success message
DO $$
BEGIN
RAISE NOTICE 'Migration 009: listen_minutes calculation fix documented';
RAISE NOTICE 'listen_minutes now represents true listener-minutes (count * time)';
END $$;

View File

@ -50,18 +50,24 @@
(let ((audio-element nil) (let ((audio-element nil)
(canvas-element (ps:chain document (get-element-by-id "spectrum-canvas")))) (canvas-element (ps:chain document (get-element-by-id "spectrum-canvas"))))
;; Only use audio element from current frame ;; Try to find audio element in current frame first
;; Cross-frame MediaElementSource causes audio to break when content frame navigates
(setf audio-element (or (ps:chain document (get-element-by-id "live-audio")) (setf audio-element (or (ps:chain document (get-element-by-id "live-audio"))
(ps:chain document (get-element-by-id "persistent-audio")))) (ps:chain document (get-element-by-id "persistent-audio"))))
;; In frameset mode, the audio is in the player frame - we cannot safely connect ;; If not found and we're in a frame, try to access from parent frameset
;; to it from the content frame because createMediaElementSource breaks audio
;; routing when the content frame navigates away
(when (and (not audio-element) (when (and (not audio-element)
(ps:@ window parent) (ps:@ window parent)
(not (eq (ps:@ window parent) window))) (not (eq (ps:@ window parent) window)))
(ps:chain console (log "Spectrum analyzer: In frameset mode, audio is in player frame - visualization disabled to prevent audio issues"))) (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)))))
(when (and audio-element canvas-element) (when (and audio-element canvas-element)
;; Store current audio element ;; Store current audio element

View File

@ -7,143 +7,6 @@
(ps:ps* (ps:ps*
'(progn '(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 ;; Stream Configuration
;; ======================================== ;; ========================================
@ -425,16 +288,7 @@
(cond (cond
((= fav-count 0) (setf (ps:@ count-el text-content) "")) ((= fav-count 0) (setf (ps:@ count-el text-content) ""))
((= fav-count 1) (setf (ps:@ count-el text-content) "1 ❤️")) ((= 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) (catch (lambda (error)
(ps:chain console (log "Could not fetch now playing:" error))))))) (ps:chain console (log "Could not fetch now playing:" error)))))))
@ -675,9 +529,6 @@
(add-event-listener "playing" (add-event-listener "playing"
(lambda () (lambda ()
(ps:chain console (log "Audio playing")) (ps:chain console (log "Audio playing"))
;; Initialize and start mini spectrum on first play
(init-mini-spectrum audio-element)
(start-mini-spectrum)
(hide-status) (hide-status)
(setf *stream-error-count* 0) (setf *stream-error-count* 0)
(setf *is-reconnecting* false) (setf *is-reconnecting* false)
@ -726,8 +577,6 @@
(ps:chain audio-element (ps:chain audio-element
(add-event-listener "pause" (add-event-listener "pause"
(lambda () (lambda ()
;; Stop mini spectrum when paused
(stop-mini-spectrum)
;; If paused while muted and we didn't initiate it, browser may have throttled ;; If paused while muted and we didn't initiate it, browser may have throttled
(when (and (ps:@ audio-element muted) (not *is-reconnecting*)) (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...")) (ps:chain console (log "Stream paused while muted (possible browser throttling), will reconnect in 3 seconds..."))

View File

@ -1340,8 +1340,8 @@ body.persistent-player-container .quality-selector select{
color: #00ff00; color: #00ff00;
letter-spacing: 0.08rem; letter-spacing: 0.08rem;
border: 1px solid #00ff00; border: 1px solid #00ff00;
padding: 3px 6px; padding: 3px 8px;
min-width: 55px; min-width: 140px;
font-size: 0.9em; font-size: 0.9em;
height: 26px; height: 26px;
line-height: 18px; line-height: 18px;

View File

@ -1079,8 +1079,8 @@
:color "#00ff00" :color "#00ff00"
:letter-spacing "0.08rem" :letter-spacing "0.08rem"
:border "1px solid #00ff00" :border "1px solid #00ff00"
:padding "3px 6px" :padding "3px 8px"
:min-width "55px" :min-width "140px"
:font-size "0.9em" :font-size "0.9em"
:height "26px" :height "26px"
:line-height "18px") :line-height "18px")

View File

@ -8,9 +8,6 @@
</head> </head>
<body class="persistent-player-container"> <body class="persistent-player-container">
<div class="persistent-player"> <div class="persistent-player">
<!-- Mini spectrum visualizer -->
<canvas id="mini-spectrum-canvas" width="100" height="28" style="vertical-align: middle; margin-right: 8px; border-radius: 3px; background: #000;"></canvas>
<span class="player-label"> <span class="player-label">
<span class="live-stream-indicator">🟢 </span> <span class="live-stream-indicator">🟢 </span>
LIVE: LIVE:
@ -26,10 +23,11 @@
</div> </div>
<div class="quality-selector"> <div class="quality-selector">
<select id="stream-quality" onchange="changeStreamQuality()" title="Stream Quality"> <label for="stream-quality">Quality:</label>
<option value="aac">AAC</option> <select id="stream-quality" onchange="changeStreamQuality()">
<option value="mp3">MP3</option> <option value="aac">AAC 96k</option>
<option value="low">Low</option> <option value="mp3">MP3 128k</option>
<option value="low">MP3 64k</option>
</select> </select>
</div> </div>
@ -37,7 +35,6 @@
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)"> <source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
</audio> </audio>
<a id="mini-musicbrainz-link" href="#" target="_blank" title="Search on MusicBrainz" style="display: none; margin-right: 4px; text-decoration: none;">🔗</a>
<span class="now-playing-mini" id="mini-now-playing">Loading...</span> <span class="now-playing-mini" id="mini-now-playing">Loading...</span>
<span class="favorite-count-mini" id="favorite-count-mini"></span> <span class="favorite-count-mini" id="favorite-count-mini"></span>