Compare commits

..

2 Commits

Author SHA1 Message Date
glenneth 249844160f Fix listener minutes tracking for accurate geo stats
- Add register-web-listener to now-playing-json API endpoint
  This keeps listeners registered during continuous playback instead of
  timing out after 5 minutes of inactivity

- Fix listen_minutes calculation to increment by listener_count per poll
  Previously incremented by 1 regardless of how many listeners, now properly
  tracks listener-minutes (1 minute per listener per 60s poll interval)

- Add migration 009 documenting the calculation fix
2026-01-11 13:25:55 -05:00
glenneth 8b6209e4e0 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.
2026-01-11 13:25:55 -05:00
8 changed files with 211 additions and 28 deletions

View File

@ -138,17 +138,24 @@
(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 1 per poll (approximates total listen time)." listen_minutes increments by listener_count per poll (1 minute per listener per poll)."
(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, 1) VALUES (CURRENT_DATE, '~a', ~a, ~a, ~a)
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 + 1" listen_minutes = listener_geo_stats.listen_minutes + ~a"
country-code city-sql listener-count listener-count)))) country-code city-sql listener-count listener-count 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

@ -0,0 +1,28 @@
-- 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,24 +50,18 @@
(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"))))
;; 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")) (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"))))
;; 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) (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 "Trying to access audio from parent frame...")) (ps:chain console (log "Spectrum analyzer: In frameset mode, audio is in player frame - visualization disabled to prevent audio issues")))
(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,6 +7,143 @@
(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
;; ======================================== ;; ========================================
@ -288,7 +425,16 @@
(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)))))))
@ -529,6 +675,9 @@
(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)
@ -577,6 +726,8 @@
(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 8px; padding: 3px 6px;
min-width: 140px; min-width: 55px;
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 8px" :padding "3px 6px"
:min-width "140px" :min-width "55px"
:font-size "0.9em" :font-size "0.9em"
:height "26px" :height "26px"
:line-height "18px") :line-height "18px")

View File

@ -8,6 +8,9 @@
</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:
@ -23,11 +26,10 @@
</div> </div>
<div class="quality-selector"> <div class="quality-selector">
<label for="stream-quality">Quality:</label> <select id="stream-quality" onchange="changeStreamQuality()" title="Stream Quality">
<select id="stream-quality" onchange="changeStreamQuality()"> <option value="aac">AAC</option>
<option value="aac">AAC 96k</option> <option value="mp3">MP3</option>
<option value="mp3">MP3 128k</option> <option value="low">Low</option>
<option value="low">MP3 64k</option>
</select> </select>
</div> </div>
@ -35,6 +37,7 @@
<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>