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)
"Get JSON with now playing info including track ID for favorites.
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
(let* ((mount-name (or mount "asteroid.mp3"))
(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)))))))

View File

@ -268,18 +268,18 @@
(defun update-geo-stats (country-code listener-count &optional city)
"Update geo stats for today, optionally including city.
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
(handler-case
(with-db
(let ((city-sql (if city (format nil "'~a'" city) "NULL")))
(postmodern:execute
(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)
DO UPDATE SET listener_count = GREATEST(listener_geo_stats.listener_count, ~a),
listen_minutes = listener_geo_stats.listen_minutes + 1"
country-code city-sql listener-count listener-count))))
listen_minutes = listener_geo_stats.listen_minutes + ~a"
country-code city-sql listener-count listener-count listener-count listener-count))))
(error (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)
(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

View File

@ -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..."))

View File

@ -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;

View File

@ -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")

View File

@ -8,6 +8,9 @@
</head>
<body class="persistent-player-container">
<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="live-stream-indicator">🟢 </span>
LIVE:
@ -23,11 +26,10 @@
</div>
<div class="quality-selector">
<label for="stream-quality">Quality:</label>
<select id="stream-quality" onchange="changeStreamQuality()">
<option value="aac">AAC 96k</option>
<option value="mp3">MP3 128k</option>
<option value="low">MP3 64k</option>
<select id="stream-quality" onchange="changeStreamQuality()" title="Stream Quality">
<option value="aac">AAC</option>
<option value="mp3">MP3</option>
<option value="low">Low</option>
</select>
</div>
@ -35,6 +37,7 @@
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
</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="favorite-count-mini" id="favorite-count-mini"></span>