Compare commits
2 Commits
bad9d4294b
...
249844160f
| Author | SHA1 | Date |
|---|---|---|
|
|
249844160f | |
|
|
8b6209e4e0 |
|
|
@ -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)))))))
|
||||
|
|
|
|||
|
|
@ -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)))))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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..."))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue