Compare commits

...

4 Commits

Author SHA1 Message Date
glenneth a9c48e59c9 Increase rate limits to 180 req/min for polling endpoints 2026-01-18 10:55:33 -05:00
glenneth 4622ae2440 Fix notification icon path to use existing asteroid.png 2026-01-18 10:21:21 -05:00
glenneth 849c1c2716 Increase rate limits on polling endpoints
Fix 429 errors caused by aggressive rate limiting on now-playing APIs.
Changed from 2-3 req/sec to 60 req/min for:
- asteroid/partial/now-playing
- asteroid/partial/now-playing-inline
- asteroid/partial/now-playing-json
- asteroid/channel-name

This fixes notifications not working and may resolve auto-reconnect issues.
2026-01-18 10:09:52 -05:00
glenneth 6d4169b707 Add system notifications for track changes
- Implement Web Notifications API in ParenScript
- Add notification toggle button (🔔/🔕) to player frame
- Show 'Artist - Track' notification when track changes
- Store notification preference in localStorage
- Auto-close notifications after 5 seconds
- Click notification to focus browser window
2026-01-18 08:55:53 -05:00
6 changed files with 167 additions and 20 deletions

1
.gitignore vendored
View File

@ -65,3 +65,4 @@ playlists/stream-queue.m3u
/radiance-bootstrap.lisp
/test-postgres-db.lisp
/userdump.csv
.envrc

View File

@ -91,7 +91,7 @@
(:listeners . ,total-listeners)
(:track-id . ,(find-track-by-title title))))))))
(define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 3 :timeout 1)
(define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 180 :timeout 60)
"Get Partial HTML with live status from Icecast server.
Optional MOUNT parameter specifies which stream to get metadata from.
Always polls both streams to keep recently played lists updated."
@ -121,7 +121,7 @@
:connection-error t
:stats nil))))))
(define-api-with-limit asteroid/partial/now-playing-inline (&optional mount) (:limit 3 :timeout 1)
(define-api-with-limit asteroid/partial/now-playing-inline (&optional mount) (:limit 180 :timeout 60)
"Get inline text with now playing info (for admin dashboard and widgets).
Optional MOUNT parameter specifies which stream to get metadata from."
(with-error-handling
@ -135,7 +135,7 @@
(setf (header "Content-Type") "text/plain")
"Stream Offline")))))
(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 180 :timeout 60)
"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)
@ -160,7 +160,7 @@
("title" . "Stream Offline")
("track_id" . nil)))))))
(define-api-with-limit asteroid/channel-name () (:limit 2 :timeout 1)
(define-api-with-limit asteroid/channel-name () (:limit 180 :timeout 60)
"Get the current curated channel name for live updates.
Returns JSON with the channel name from the current playlist's PHASE header."
(with-error-handling

View File

@ -338,8 +338,116 @@
;; Track the last recorded title to avoid duplicate history entries
(defvar *last-recorded-title* nil)
;; Cache of user's favorite track titles for quick lookup (mini player)
(defvar *user-favorites-cache-mini* (array))
;; Track last notified title to avoid duplicate notifications
(defvar *last-notified-title* nil)
;; Check if notifications are enabled in localStorage
(defun notifications-enabled-p ()
(= (ps:chain local-storage (get-item "notifications-enabled")) "true"))
;; Check if browser supports notifications
(defun notifications-supported-p ()
(not (= (typeof (ps:@ window -notification)) "undefined")))
;; Get notification permission status
(defun get-notification-permission ()
(if (notifications-supported-p)
(ps:@ -notification permission)
"denied"))
;; Request notification permission from user
(defun request-notification-permission ()
(when (notifications-supported-p)
(ps:chain -notification (request-permission)
(then (lambda (permission)
(if (= permission "granted")
(progn
(ps:chain local-storage (set-item "notifications-enabled" "true"))
(update-notification-toggle-ui)
(show-track-notification "Notifications Enabled" "You'll now receive track change notifications"))
(progn
(ps:chain local-storage (set-item "notifications-enabled" "false"))
(update-notification-toggle-ui))))))))
;; Toggle notifications on/off
(defun toggle-notifications ()
(let ((permission (get-notification-permission)))
(cond
;; Not supported
((not (notifications-supported-p))
(alert "Your browser does not support notifications"))
;; Permission denied - can't do anything
((= permission "denied")
(alert "Notifications are blocked. Please enable them in your browser settings."))
;; Permission not yet requested
((= permission "default")
(request-notification-permission))
;; Permission granted - toggle the setting
((= permission "granted")
(if (notifications-enabled-p)
(progn
(ps:chain local-storage (set-item "notifications-enabled" "false"))
(update-notification-toggle-ui))
(progn
(ps:chain local-storage (set-item "notifications-enabled" "true"))
(update-notification-toggle-ui)
(show-track-notification "Notifications Enabled" "You'll now receive track change notifications")))))))
;; Update the notification toggle button UI
(defun update-notification-toggle-ui ()
(let ((btn (ps:chain document (get-element-by-id "notification-toggle"))))
(when btn
(let ((permission (get-notification-permission))
(enabled (notifications-enabled-p)))
(cond
((not (notifications-supported-p))
(setf (ps:@ btn text-content) "🔕")
(setf (ps:@ btn title) "Notifications not supported"))
((= permission "denied")
(setf (ps:@ btn text-content) "🔕")
(setf (ps:@ btn title) "Notifications blocked - enable in browser settings"))
((and (= permission "granted") enabled)
(setf (ps:@ btn text-content) "🔔")
(setf (ps:@ btn title) "Track notifications ON - click to disable"))
(t
(setf (ps:@ btn text-content) "🔕")
(setf (ps:@ btn title) "Track notifications OFF - click to enable")))))))
;; Show a system notification for track change
(defun show-track-notification (title body)
(when (and (notifications-supported-p)
(= (get-notification-permission) "granted")
(notifications-enabled-p)
(not (= title *last-notified-title*)))
(setf *last-notified-title* title)
(ps:try
(let ((notification (ps:new (-notification title
(ps:create :body body
:icon "/asteroid/static/asteroid.png"
:tag "asteroid-track-change"
:renotify true
:silent false)))))
;; Auto-close after 5 seconds
(set-timeout (lambda () (ps:chain notification (close))) 5000)
;; Click to focus the window
(setf (ps:@ notification onclick)
(lambda ()
(ps:chain window (focus))
(ps:chain notification (close)))))
(:catch (e)
(ps:chain console (log "Notification error:" e))))))
;; Notify track change (called from update-mini-now-playing)
(defun notify-track-change (title)
(when (and title
(not (= title ""))
(not (= title "Loading..."))
(not (= title *last-notified-title*)))
;; Show full "Artist - Track" in title, "Now Playing" as body
(show-track-notification title "Now Playing on Asteroid Radio")))
;; Cache of user's favorite track titles for quick lookup (mini player)
(defvar *user-favorites-cache-mini* (array))
;; Load user's favorites into cache (mini player - only if logged in)
(defun load-favorites-cache-mini ()
@ -409,9 +517,10 @@
(track-id-el (ps:chain document (get-element-by-id "current-track-id-mini")))
(title (or (ps:@ data data title) (ps:@ data title) "Loading...")))
(when el
;; Check if track changed and record to history
;; Check if track changed and record to history + notify
(when (not (= (ps:@ el text-content) title))
(record-track-listen title))
(record-track-listen title)
(notify-track-change title))
(setf (ps:@ el text-content) title)
;; Check if this track is in user's favorites
(check-favorite-status-mini))
@ -856,6 +965,9 @@
;; Update quality selector state based on channel
(update-quality-selector-state)
;; Initialize notification toggle UI
(update-notification-toggle-ui)
;; Poll server for channel name changes (works across all listeners)
(let ((last-channel-name nil))
(set-interval
@ -931,17 +1043,21 @@
(ps:chain window (add-event-listener "beforeunload" notify-popout-closing)))))
;; Make functions globally accessible
(setf (ps:@ window get-stream-config) get-stream-config)
(setf (ps:@ window change-channel) change-channel)
(setf (ps:@ window sync-channel-from-storage) sync-channel-from-storage)
(setf (ps:@ window change-stream-quality) change-stream-quality)
(setf (ps:@ window reconnect-stream) reconnect-stream)
(setf (ps:@ window disable-frameset-mode) disable-frameset-mode)
(setf (ps:@ window init-persistent-player) init-persistent-player)
(setf (ps:@ window init-popout-player) init-popout-player)
(setf (ps:@ window update-mini-now-playing) update-mini-now-playing)
(setf (ps:@ window update-popout-now-playing) update-popout-now-playing)
(setf (ps:@ window toggle-favorite-mini) toggle-favorite-mini)
(setf (ps:@ window get-stream-config) get-stream-config)
(setf (ps:@ window change-channel) change-channel)
(setf (ps:@ window sync-channel-from-storage) sync-channel-from-storage)
(setf (ps:@ window change-stream-quality) change-stream-quality)
(setf (ps:@ window reconnect-stream) reconnect-stream)
(setf (ps:@ window disable-frameset-mode) disable-frameset-mode)
(setf (ps:@ window init-persistent-player) init-persistent-player)
(setf (ps:@ window init-popout-player) init-popout-player)
(setf (ps:@ window update-mini-now-playing) update-mini-now-playing)
(setf (ps:@ window update-popout-now-playing) update-popout-now-playing)
(setf (ps:@ window toggle-favorite-mini) toggle-favorite-mini)
(setf (ps:@ window toggle-notifications) toggle-notifications)
(setf (ps:@ window update-notification-toggle-ui) update-notification-toggle-ui)
(setf (ps:@ window notify-popout-opened) notify-popout-opened)
(setf (ps:@ window notify-popout-closing) notify-popout-closing)
;; Auto-initialize on DOMContentLoaded based on which elements exist
(ps:chain document

View File

@ -1388,6 +1388,21 @@ body.persistent-player-container .persistent-reconnect-btn:hover{
background: #2a3441;
}
body.persistent-player-container .persistent-notification-btn{
background: transparent;
color: #00ff00;
border: 1px solid #00ff00;
padding: 5px 8px;
cursor: pointer;
font-size: 1em;
white-space: nowrap;
margin-right: 5px;
}
body.persistent-player-container .persistent-notification-btn:hover{
background: #2a3441;
}
body.persistent-player-container .persistent-disable-btn{
background: transparent;
color: #00ff00;

View File

@ -1122,6 +1122,19 @@
((:and .persistent-reconnect-btn :hover)
:background "#2a3441")
(.persistent-notification-btn
:background transparent
:color "#00ff00"
:border "1px solid #00ff00"
:padding "5px 8px"
:cursor "pointer"
:font-size "1em"
:white-space nowrap
:margin-right "5px")
((:and .persistent-notification-btn :hover)
:background "#2a3441")
(.persistent-disable-btn
:background transparent
:color "#00ff00"

View File

@ -46,6 +46,8 @@
</button>
<input type="hidden" id="current-track-id-mini" value="">
<button id="notification-toggle" onclick="toggleNotifications()" class="persistent-notification-btn" title="Track notifications OFF - click to enable">🔕</button>
<button id="reconnect-btn" onclick="reconnectStream()" class="persistent-reconnect-btn" title="Reconnect if audio stops working">
<img src="/asteroid/static/icons/sync.png" alt="Reconnect" style="width: 18px; height: 18px; vertical-align: middle; filter: invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%);">
</button>